Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c3edf4c6f | ||
|
|
1cbe03b49e | ||
|
|
26cba3398f | ||
|
|
d58809bdbc | ||
|
|
84cb5904df | ||
|
|
fc52d72a01 | ||
|
|
a7186173b4 | ||
|
|
827bef8478 | ||
|
|
cc0ec0ccc8 | ||
|
|
755e6aeb88 | ||
|
|
4544bc0d8b |
+24
-2
@@ -4,12 +4,34 @@ All notable changes to the published WIP Rust crates are documented here.
|
|||||||
|
|
||||||
## Unreleased
|
## 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
|
- Breaking Protocol/HTTP migration from opaque Interface reference strings to
|
||||||
exact `(scope, name)` records in Objects, fetches and calls, with optional
|
exact `(scope, name)` records in Objects, fetches and calls, with optional
|
||||||
opaque `scope_ref` metadata. Adds shape and placement-aware scope validation
|
opaque `scope_ref` metadata. Adds shape and placement-aware scope validation
|
||||||
without changing routes, validators or the wire error-code set.
|
without changing routes, validators or the wire error-code set.
|
||||||
- Mechanically migrates workspace consumers and fixtures. See [MIGRATION.md](MIGRATION.md)
|
- Breaking Host registration migration to explicit Scope publication lifetimes and
|
||||||
for API/wire examples and the deferred Host/Client lifecycle semantics.
|
local names, with coherent provider-attested scope Object binding for fetch and
|
||||||
|
dispatch. Supplied scope_ref is checked at membership precedence; scope deletion
|
||||||
|
retires retained tokens, while ordinary Object state updates preserve lifetime.
|
||||||
|
- 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
|
## 0.1.0
|
||||||
|
|
||||||
|
|||||||
Generated
+18
-9
@@ -1235,7 +1235,7 @@ checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wip-client"
|
name = "wip-client"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"wip-http",
|
"wip-http",
|
||||||
"wip-protocol",
|
"wip-protocol",
|
||||||
@@ -1243,7 +1243,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wip-fs-server"
|
name = "wip-fs-server"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"http",
|
"http",
|
||||||
"rustix 1.1.5",
|
"rustix 1.1.5",
|
||||||
@@ -1256,7 +1256,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wip-host"
|
name = "wip-host"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"trybuild",
|
"trybuild",
|
||||||
"wip-interface",
|
"wip-interface",
|
||||||
@@ -1265,7 +1265,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wip-http"
|
name = "wip-http"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"http",
|
"http",
|
||||||
@@ -1277,7 +1277,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wip-interface"
|
name = "wip-interface"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"trybuild",
|
"trybuild",
|
||||||
"wip-interface-macros",
|
"wip-interface-macros",
|
||||||
@@ -1286,7 +1286,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wip-interface-macros"
|
name = "wip-interface-macros"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -1295,20 +1295,29 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wip-protocol"
|
name = "wip-protocol"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wip-public-api-example"
|
name = "wip-public-api-example"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"wip-client",
|
||||||
"wip-host",
|
"wip-host",
|
||||||
|
"wip-http",
|
||||||
"wip-interface",
|
"wip-interface",
|
||||||
"wip-protocol",
|
"wip-protocol",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wip-text-view"
|
||||||
|
version = "0.2.0"
|
||||||
|
dependencies = [
|
||||||
|
"wip-protocol",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wip-tui"
|
name = "wip-tui"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
|
|||||||
+9
-8
@@ -3,17 +3,18 @@ members = ["crates/*"]
|
|||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.88"
|
rust-version = "1.88"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
repository = "https://gitea.hareworks.net/Hare/wip-rs"
|
repository = "https://gitea.hareworks.net/Hare/wip-rs"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
wip-client = { version = "0.1.0", path = "crates/wip-client" }
|
wip-client = { version = "0.2.0", path = "crates/wip-client" }
|
||||||
wip-fs-server = { version = "0.1.0", path = "crates/wip-fs-server" }
|
wip-fs-server = { version = "0.2.0", path = "crates/wip-fs-server" }
|
||||||
wip-host = { version = "0.1.0", path = "crates/wip-host" }
|
wip-host = { version = "0.2.0", path = "crates/wip-host" }
|
||||||
wip-http = { version = "0.1.0", path = "crates/wip-http" }
|
wip-http = { version = "0.2.0", path = "crates/wip-http" }
|
||||||
wip-interface = { version = "0.1.0", path = "crates/wip-interface" }
|
wip-interface = { version = "0.2.0", path = "crates/wip-interface" }
|
||||||
wip-interface-macros = { version = "0.1.0", path = "crates/wip-interface-macros" }
|
wip-interface-macros = { version = "0.2.0", path = "crates/wip-interface-macros" }
|
||||||
wip-protocol = { version = "0.1.0", path = "crates/wip-protocol" }
|
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`.
|
Protocol and WIP over HTTP at `6086f3c5ef10aa1464ce9c824750d7f667217bfd`.
|
||||||
It replaces the earlier opaque Interface reference contract; HTTP routes and
|
It replaces the earlier opaque Interface reference contract; HTTP routes and
|
||||||
error codes have **not** changed. Peers must upgrade together: the codec does
|
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,
|
field `scope_ref: Option<String>` in struct literals. Use `None` when omitted,
|
||||||
not an invented identity. This is an opaque scope Object ref observation/call
|
not an invented identity. This is an opaque scope Object ref observation/call
|
||||||
precondition, **not** a third Interface identity component or a validator.
|
precondition, **not** a third Interface identity component or a validator.
|
||||||
The scope-ref lifecycle/atomic Host enforcement and Client cache-recovery
|
Host enforces scope publication lifetimes and optional scope-ref identity
|
||||||
semantics are follow-up work (T-21/T-22); this migration supplies the data model
|
inside the provider boundary. Client caches keep the complete structured reference
|
||||||
and codec, not a completed lifecycle implementation.
|
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
|
## 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
|
`POST v1/observe`, `v1/fetch_interface`, `v1/call_operation`, error-code/status
|
||||||
mapping and Transport failure classification are unchanged.
|
mapping and Transport failure classification are unchanged.
|
||||||
|
|
||||||
|
## Host registration and provider migration
|
||||||
|
|
||||||
|
Registration now requires an explicit canonical Scope and local name:
|
||||||
|
|
||||||
|
```rust,ignore
|
||||||
|
let mut registry = wip_host::InterfaceRegistry::new();
|
||||||
|
let scope = wip_host::Scope::new("/service")?;
|
||||||
|
let api = registry.register(&scope, "catalog", CatalogDefinition, None)?;
|
||||||
|
|
||||||
|
// INSIDE the backend transaction/lock/stable-handle boundary:
|
||||||
|
let current = wip_host::ScopeObservation::new(&scope, ¤t_scope_object);
|
||||||
|
let publication = wip_host::PublishedObject::new(current_target_object)
|
||||||
|
.expose(&api)?
|
||||||
|
.bind_scope(current);
|
||||||
|
```
|
||||||
|
|
||||||
|
Collision keys are exact `(scope, name)` coordinates, not Rust Definition TypeId.
|
||||||
|
The same Definition can be registered in different scopes or under different
|
||||||
|
names. Root, self and ancestor scopes are valid, but ancestor compatibility and
|
||||||
|
explicit target membership remain independent checks. Descriptors still have
|
||||||
|
no publication identity fields.
|
||||||
|
|
||||||
|
A Scope carries a private publication lifetime, not a generated Protocol ref.
|
||||||
|
Clone it only while the same scope Object remains published: ordinary Object
|
||||||
|
state/validator updates do not retire it. Deletion, replacement or movement
|
||||||
|
requires retiring the old lifetime with `registry.remove_scope(&scope)` and
|
||||||
|
creating a fresh Scope before republication, even at the same path or ref.
|
||||||
|
Retained registration tokens cannot fetch or start calls after retirement.
|
||||||
|
Ref-less replacement cannot be tracked by a Client identity precondition; refs
|
||||||
|
and scope_ref remain optional, not forcibly generated.
|
||||||
|
|
||||||
|
`ScopeObservation::new(&scope, &object)` is a provider attestation that this
|
||||||
|
lifetime currently belongs to the Object at the scope path. Construct and attach
|
||||||
|
it with `PublishedObject::bind_scope` inside the same coherent boundary as target
|
||||||
|
resolution, membership, Descriptor/validator selection, implementation and
|
||||||
|
backend handles, through dispatch and commit/rollback. A matching ref or an
|
||||||
|
independent scope prelookup is not sufficient. The scope Object need not expose
|
||||||
|
any interfaces. Providers own atomicity and external-writer limitations; the
|
||||||
|
runtime checks the attached lifetime and optional scope_ref, and validates both
|
||||||
|
arguments and results against the same frozen Descriptor. A supplied scope_ref
|
||||||
|
mismatch, including a missing current ref, is `InterfaceMismatch` at membership
|
||||||
|
precedence, after target existence and a supplied Object validator mismatch.
|
||||||
|
Omission applies no scope identity precondition.
|
||||||
|
|
||||||
|
Artifact-only `registry.fetch_interface(reference)` is removed. Prefer
|
||||||
|
`ObjectProvider::fetch_interface(&provider, ®istry, &reference)`, which resolves
|
||||||
|
the visible scope Object with `with_object` and uses its attached current binding.
|
||||||
|
Low-level `registry.fetch_interface(&reference, ¤t_observation)` requires
|
||||||
|
the same coherent current attestation, not a cached snapshot. Fetch returns
|
||||||
|
Descriptor, Interface validator and optional scope Object ref together; Clients
|
||||||
|
need not additionally fetch an ancestor Object. `registry.scopes()` may repeat
|
||||||
|
a handle for several names; do not treat it as a unique-scope iterator.
|
||||||
|
|
||||||
|
`PublishedObject::protocol_object()` is a path-free composition projection, not
|
||||||
|
publication authority. For current observations use `ObjectProvider::observe_object`
|
||||||
|
or `publication.protocol_object_at(path)` inside the coherent provider boundary;
|
||||||
|
the latter also validates compatible live scope associations and response shape.
|
||||||
|
|
||||||
## Downstream status
|
## Downstream status
|
||||||
|
|
||||||
The workspace Client, Host, filesystem server, TUI and public-API example
|
The filesystem default registers local names `file.read`, `symlink.inspect` and
|
||||||
are mechanically migrated to structured protocol values and updated fixtures.
|
`file.write` at its deployment-pinned root Scope, displayed as `/::file.read` etc.
|
||||||
The existing Host's generated opaque registration handle is bridged explicitly
|
External compositions choose explicit Scope/local names and implement
|
||||||
to scope `/` with its full handle as the name; no delimiter parsing or scope
|
`Composition::scope` to attest a non-root lifetime from the exact refreshed backend
|
||||||
registration/lifetime model is introduced here. The existing Client keys use
|
entry in its connected lineage. The engine never independently resolves a scope
|
||||||
structured values; this change does not implement scope deletion/replacement
|
path during dispatch. Default composition remains root-scoped; exposing non-root
|
||||||
cache recovery. TUI formatting is only the type-compatibility adaptation, not
|
registrations requires the application's current lifetime attestation, not merely
|
||||||
the full canonical Text View work (T-24). Filesystem/TUI semantics integration
|
an artifact or matching ref/validator. All bindings are formed inside backend
|
||||||
remains T-23. Interface authoring/macros are descriptor-only and require no
|
execution, with current principal lineage visibility and publication metadata.
|
||||||
identity fields or declaration changes.
|
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
|
standard framework-independent WIP over HTTP v1 binding, a stateful
|
||||||
presentation-independent client runtime, a Rust interface-authoring facade, a
|
presentation-independent client runtime, a Rust interface-authoring facade, a
|
||||||
request-scoped host runtime, a bounded Linux filesystem projection server, an
|
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.
|
repository root is not itself a package.
|
||||||
|
|
||||||
## Crate graph and responsibilities
|
## Crate graph and responsibilities
|
||||||
@@ -15,6 +16,9 @@ repository root is not itself a package.
|
|||||||
```text
|
```text
|
||||||
Published libraries
|
Published libraries
|
||||||
|
|
||||||
|
wip-text-view
|
||||||
|
└── wip-protocol
|
||||||
|
|
||||||
wip-client
|
wip-client
|
||||||
├── wip-http
|
├── wip-http
|
||||||
│ └── wip-protocol
|
│ └── wip-protocol
|
||||||
@@ -47,19 +51,20 @@ wip-public-api-example
|
|||||||
|
|
||||||
| Crate | Responsibility | Does not own |
|
| 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-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-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-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-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` | 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-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-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. |
|
| `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
|
## 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-protocol`
|
||||||
- `wip-interface-macros` — implementation dependency re-exported by `wip-interface`
|
- `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-host`
|
||||||
- `wip-http`
|
- `wip-http`
|
||||||
- `wip-client`
|
- `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.
|
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
|
## 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.
|
name }` records and optional `scope_ref` observation/precondition metadata.
|
||||||
Legacy opaque-string wire references are rejected. See [MIGRATION.md](MIGRATION.md)
|
Legacy opaque-string wire references are rejected. See [MIGRATION.md](MIGRATION.md)
|
||||||
for construction examples, validation boundaries and downstream migration status.
|
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
|
## End-to-end example
|
||||||
|
|
||||||
@@ -226,25 +245,44 @@ choose the ordered interface set for each object publication:
|
|||||||
|
|
||||||
```rust,ignore
|
```rust,ignore
|
||||||
let mut interfaces = wip_host::InterfaceRegistry::new();
|
let mut interfaces = wip_host::InterfaceRegistry::new();
|
||||||
let messenger = interfaces.register(MessengerDefinition, None)?;
|
let scope = wip_host::Scope::new("/service")?;
|
||||||
|
let messenger = interfaces.register(&scope, "messenger", MessengerDefinition, None)?;
|
||||||
|
|
||||||
|
// Inside the provider coherent backend boundary, not a stale scope prelookup:
|
||||||
|
let current = wip_host::ScopeObservation::new(&scope, ¤t_scope_object);
|
||||||
|
|
||||||
let published = wip_host::PublishedObject::new(request_scoped_object)
|
let published = wip_host::PublishedObject::new(request_scoped_object)
|
||||||
.expose(&messenger)?;
|
.expose(&messenger)?
|
||||||
let protocol_object = published.protocol_object();
|
.bind_scope(current);
|
||||||
|
let protocol_object = published.protocol_object_at("/service/item")?;
|
||||||
```
|
```
|
||||||
|
|
||||||
`expose` compiles only when the object implements the generated definition's
|
`expose` compiles only when the object implements the generated definition's
|
||||||
interface trait. Implementing a trait does not expose it automatically, empty
|
interface trait. Implementing a trait does not expose it automatically, empty
|
||||||
interface sets are valid, insertion order is preserved, and duplicate
|
interface sets are valid, insertion order is preserved, and duplicate
|
||||||
references are rejected. Registry references are opaque and remain outside the
|
references are rejected. Registry collisions use exact `(scope, name)` pairs,
|
||||||
descriptor; generated descriptors contain neither those references nor Rust
|
not Rust Definition identity; the same Definition may be registered at multiple
|
||||||
trait identity.
|
coordinates. Structured references remain outside the Descriptor.
|
||||||
|
|
||||||
|
`Scope` carries a private Object publication lifetime. Keep it for ordinary state
|
||||||
|
updates; retire it with `remove_scope` and create a fresh Scope on deletion,
|
||||||
|
replacement or movement, even at the same path. Providers attach a current
|
||||||
|
`ScopeObservation` from the scope Object inside the coherent target/backend
|
||||||
|
boundary, never from a separate prelookup. The scope Object need not expose any
|
||||||
|
interfaces. Fetch uses `ObjectProvider::fetch_interface(&provider, ®istry,
|
||||||
|
&reference)` to observe the visible scope Object and return its optional ref,
|
||||||
|
Descriptor and Interface validator together; retained artifacts alone are not
|
||||||
|
publication. Calls require the current attached lifetime and check supplied
|
||||||
|
`scope_ref` at membership precedence; omission adds no identity precondition.
|
||||||
|
Refs, Object validators and Interface validators retain distinct responsibilities.
|
||||||
|
See [MIGRATION.md](MIGRATION.md) for the provider atomicity and lifecycle contract.
|
||||||
|
|
||||||
Dispatch is qualified by interface reference and operation name and targets a
|
Dispatch is qualified by interface reference and operation name and targets a
|
||||||
canonical Worldspace path. An `ObjectProvider` validates that path before
|
canonical Worldspace path. An `ObjectProvider` validates that path before
|
||||||
resolution, maps malformed paths to `InvalidRequest`, then resolves a valid path
|
resolution, maps malformed paths to `InvalidRequest`, then resolves a valid path
|
||||||
exactly once inside `with_object`; the host checks the supplied object validator,
|
exactly once inside `with_object`; the host checks the supplied object validator,
|
||||||
current interface membership, interface validator, descriptor operation,
|
current scope lifetime/optional scope_ref and interface membership, Interface
|
||||||
|
validator, Descriptor operation,
|
||||||
operation-specific object-validator policy, arguments, and authorization in
|
operation-specific object-validator policy, arguments, and authorization in
|
||||||
canonical precedence. Path and children remain provider resolution context and
|
canonical precedence. Path and children remain provider resolution context and
|
||||||
are never projected by `WipObject`; host-private fields and backend handles are
|
are never projected by `WipObject`; host-private fields and backend handles are
|
||||||
|
|||||||
+8
-5
@@ -1,12 +1,12 @@
|
|||||||
# Releasing
|
# 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
|
## Preconditions
|
||||||
|
|
||||||
1. Work from a clean `master` whose `HEAD` matches `origin/master`.
|
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]`.
|
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:
|
4. Run the complete repository validation suite:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
@@ -44,6 +44,9 @@ cargo publish --package wip-interface-macros
|
|||||||
Wait until both versions are available from the crates.io index, then continue:
|
Wait until both versions are available from the crates.io index, then continue:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
|
cargo publish --dry-run --package wip-text-view
|
||||||
|
cargo publish --package wip-text-view
|
||||||
|
|
||||||
cargo publish --dry-run --package wip-interface
|
cargo publish --dry-run --package wip-interface
|
||||||
cargo publish --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
|
## 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
|
```console
|
||||||
git tag -a v0.1.0 -m "release: 0.1.0"
|
git tag -a v0.2.0 -m "chore: release 0.2.0"
|
||||||
git push origin v0.1.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.
|
Do not move or recreate a published version or its tag. Prepare a new version for any subsequent correction.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Presentation-independent stateful runtime for Web Interface Protocol (WIP) clien
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[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.
|
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.
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ envelopes, and HTTP status mapping.
|
|||||||
|
|
||||||
## Library assembly and migration
|
## Library assembly and migration
|
||||||
|
|
||||||
The existing `ServerConfig`, `WipFsServer::new`, `handle_http`, CLI flags and wire
|
`ServerConfig`, `WipFsServer::new`, `handle_http` and CLI flags retain their
|
||||||
representation remain compatible. The formerly private projection has become a
|
assembly shape. The scoped Host/Protocol API is a breaking Rust/wire migration:
|
||||||
public, generic library engine. No package version or publication policy changes
|
Interface references are `{scope, name}` values, registration takes a `Scope`
|
||||||
are required.
|
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
|
Use `default_projection(root, access, limits)` to obtain the default Linux engine
|
||||||
without starting a listener. `projection.view(&principal)` is the effective view;
|
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
|
protected state. Implement `FilesystemBackend` to resolve one connected
|
||||||
`ResolvedEntry<Entry>` (root-to-parent ancestry plus pinned target), enumerate
|
`ResolvedEntry<Entry>` (root-to-parent ancestry plus pinned target), enumerate
|
||||||
bounded children, and execute against that already-resolved lineage.
|
bounded children, and execute against that already-resolved lineage.
|
||||||
2. Register generated `#[wip_interface::interface]` definitions using the existing
|
2. Create `wip_host::Scope::new("/")` for the deployment-pinned root, then
|
||||||
`wip_host::InterfaceRegistry`. `DefaultInterfaces::register` optionally supplies
|
register generated definitions with `registry.register(&scope, name,
|
||||||
reusable built-in tokens; using those tokens does not force their publication.
|
Definition, validator)`. `DefaultInterfaces::register(&mut registry, &scope)`
|
||||||
|
optionally supplies reusable built-in tokens named `file.read`,
|
||||||
|
`symlink.inspect`, and `file.write`; tokens do not force publication. 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
|
3. Implement `Composition<Entry>` on a local type. Its local Object wraps the entry
|
||||||
and implements both `WipObject` and the chosen generated traits, avoiding orphan
|
and implements both `WipObject` and the chosen generated traits, avoiding orphan
|
||||||
rules. `PublishedObject::expose` proves each implementation at compile time.
|
rules. `PublishedObject::expose` proves each implementation at compile time.
|
||||||
@@ -61,7 +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,
|
resolutions. A root target has no ancestors; other targets retain the root first,
|
||||||
then intermediate parent containers. This is backend-private context, never wire
|
then intermediate parent containers. This is backend-private context, never wire
|
||||||
metadata. Backend `resolve` and `execute` signatures both carry this type;
|
metadata. Backend `resolve` and `execute` signatures both carry this type;
|
||||||
compositions still receive only the exact scoped target `Entry`.
|
compositions receive exact retained entries, including publication of the root
|
||||||
|
scope Object while another target is executing, never independent raw-path
|
||||||
|
lookups. Root publication must be deterministic and side-effect-free.
|
||||||
|
|
||||||
|
Every lineage must retain the same deployment-pinned root Object. Replacing that
|
||||||
|
Object requires a new projection and fresh `Scope`, not reuse of registrations
|
||||||
|
at the same path. The engine constructs `ScopeObservation` from the published
|
||||||
|
root Object's metadata using the refreshed retained root capability **inside**
|
||||||
|
`FilesystemBackend::execute`, then attaches it with `PublishedObject::bind_scope`.
|
||||||
|
It never independently looks up the raw configured root path. Root Objects need
|
||||||
|
not expose any interfaces. Calls check both the private lifetime and optional
|
||||||
|
`scope_ref`; normal root state updates do not retire the lifetime.
|
||||||
|
|
||||||
|
`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
|
`FilesystemBackend::execute` must validate ancestor membership/identity and
|
||||||
refresh **all** lineage state/validators inside its transaction or lock before
|
refresh **all** lineage state/validators inside its transaction or lock before
|
||||||
@@ -196,14 +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
|
A regular-file observation returns a structured read interface reference and an
|
||||||
object validator. Built-in legacy registrations use scope `/` and copy the opaque
|
object validator. Built-ins explicitly use scope `/` and the local names
|
||||||
registration string verbatim into `name`; they do not emit `scope_ref`. Copy the
|
`file.read`, `symlink.inspect`, and `file.write`. The Linux root does not issue an
|
||||||
complete reference from the observation to retrieve that descriptor:
|
Object ref, so fetch omits `scope_ref`; its private publication lifetime still
|
||||||
|
binds fetch and call to the pinned deployment root. Copy the complete reference
|
||||||
|
from the observation to retrieve that descriptor:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
curl -sS -X POST http://127.0.0.1:8080/wip/v1/fetch_interface \
|
curl -sS -X POST http://127.0.0.1:8080/wip/v1/fetch_interface \
|
||||||
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||||
--data '{"interface":{"scope":"/","name":"<read-interface-name-from-observation>"}}'
|
--data '{"interface":{"scope":"/","name":"file.read"}}'
|
||||||
```
|
```
|
||||||
|
|
||||||
Then call a bounded range read:
|
Then call a bounded range read:
|
||||||
@@ -213,7 +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' \
|
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||||
--data '{
|
--data '{
|
||||||
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
|
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
|
||||||
"interface":{"reference":{"scope":"/","name":"<read-interface-name>"}},
|
"interface":{"reference":{"scope":"/","name":"file.read"}},
|
||||||
"operation":"read",
|
"operation":"read",
|
||||||
"arguments":{"offset":0,"length":4096}
|
"arguments":{"offset":0,"length":4096}
|
||||||
}'
|
}'
|
||||||
@@ -227,7 +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' \
|
-H 'Content-Type: application/json' -H 'Accept: application/json' \
|
||||||
--data '{
|
--data '{
|
||||||
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
|
"target":{"path":"/notes.txt","validator":"<base64-validator>"},
|
||||||
"interface":{"reference":{"scope":"/","name":"<write-interface-name>"}},
|
"interface":{"reference":{"scope":"/","name":"file.write"}},
|
||||||
"operation":"replace",
|
"operation":"replace",
|
||||||
"arguments":{"contents":"dXBkYXRlZA=="}
|
"arguments":{"contents":"dXBkYXRlZA=="}
|
||||||
}'
|
}'
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use crate::projection::{LinuxBackend, LinuxEntry};
|
|||||||
use crate::{AccessMode, ResourceLimits};
|
use crate::{AccessMode, ResourceLimits};
|
||||||
use wip_host::{
|
use wip_host::{
|
||||||
ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, PublishedObject,
|
ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, PublishedObject,
|
||||||
RegisteredInterface, RegistrationError, Validator, WipObject,
|
RegisteredInterface, RegistrationError, Scope, Validator, WipObject,
|
||||||
};
|
};
|
||||||
use wip_interface as wip;
|
use wip_interface as wip;
|
||||||
|
|
||||||
@@ -22,12 +22,19 @@ pub struct DefaultInterfaces {
|
|||||||
pub write: RegisteredInterface<FileWriteDefinition>,
|
pub write: RegisteredInterface<FileWriteDefinition>,
|
||||||
}
|
}
|
||||||
impl DefaultInterfaces {
|
impl DefaultInterfaces {
|
||||||
/// Register reusable default descriptors without publishing any objects.
|
/// Register reusable descriptors at an explicit publication scope.
|
||||||
pub fn register(registry: &mut InterfaceRegistry) -> Result<Self, RegistrationError> {
|
///
|
||||||
|
/// 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 {
|
Ok(Self {
|
||||||
read: registry.register(FileReadDefinition, None)?,
|
read: registry.register(scope, "file.read", FileReadDefinition, None)?,
|
||||||
symlink: registry.register(SymlinkInspectDefinition, None)?,
|
symlink: registry.register(scope, "symlink.inspect", SymlinkInspectDefinition, None)?,
|
||||||
write: registry.register(FileWriteDefinition, None)?,
|
write: registry.register(scope, "file.write", FileWriteDefinition, None)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,7 +142,9 @@ pub fn default_projection(
|
|||||||
) -> Result<Projection<LinuxBackend, DefaultComposition, DefaultPolicy>, std::io::Error> {
|
) -> Result<Projection<LinuxBackend, DefaultComposition, DefaultPolicy>, std::io::Error> {
|
||||||
let backend = LinuxBackend::open(root, access, limits)?;
|
let backend = LinuxBackend::open(root, access, limits)?;
|
||||||
let mut registry = InterfaceRegistry::new();
|
let mut registry = InterfaceRegistry::new();
|
||||||
let interfaces = DefaultInterfaces::register(&mut registry).map_err(std::io::Error::other)?;
|
let scope = Scope::new("/").map_err(std::io::Error::other)?;
|
||||||
|
let interfaces =
|
||||||
|
DefaultInterfaces::register(&mut registry, &scope).map_err(std::io::Error::other)?;
|
||||||
Ok(Projection::new(
|
Ok(Projection::new(
|
||||||
backend,
|
backend,
|
||||||
registry,
|
registry,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
use crate::ResourceLimits;
|
use crate::ResourceLimits;
|
||||||
use wip_host::{
|
use wip_host::{
|
||||||
EffectGuarantee, ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, ObjectProvider,
|
EffectGuarantee, ExecutionContext, HostError, InterfaceRef, InterfaceRegistry, ObjectProvider,
|
||||||
OperationAttempt, ProviderResult, PublishedObject, WipObject,
|
OperationAttempt, ProviderResult, PublishedObject, Scope, ScopeObservation, WipObject,
|
||||||
};
|
};
|
||||||
use wip_protocol::{
|
use wip_protocol::{
|
||||||
FetchInterfaceRequest, FetchInterfaceResponse, ObjectObservation, ObserveRequest,
|
FetchInterfaceRequest, FetchInterfaceResponse, ObjectObservation, ObserveRequest,
|
||||||
@@ -72,6 +72,12 @@ impl<E> ResolvedEntry<E> {
|
|||||||
/// advertise capabilities the backend cannot guarantee. Errors must not include
|
/// advertise capabilities the backend cannot guarantee. Errors must not include
|
||||||
/// backend paths, credentials or hidden metadata.
|
/// backend paths, credentials or hidden metadata.
|
||||||
///
|
///
|
||||||
|
/// The root is a deployment-pinned Object: every resolution/execution lineage
|
||||||
|
/// must retain that same root, even if its textual backend path is renamed or
|
||||||
|
/// replaced. Replacing the deployment root requires a new projection and Scope
|
||||||
|
/// lifetime. The engine 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
|
/// `execute` must retain the same target and complete lineage and serialize
|
||||||
/// ancestor identity/membership checks, policy metadata, validator observation,
|
/// ancestor identity/membership checks, policy metadata, validator observation,
|
||||||
/// publication, validation, typed dispatch and commit/rollback. Changed/detached
|
/// publication, validation, typed dispatch and commit/rollback. Changed/detached
|
||||||
@@ -120,10 +126,40 @@ pub trait FilesystemBackend {
|
|||||||
pub trait Composition<E: BackendEntry> {
|
pub trait Composition<E: BackendEntry> {
|
||||||
/// Local typed operation implementation and safe public metadata.
|
/// Local typed operation implementation and safe public metadata.
|
||||||
type Object: WipObject;
|
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.
|
/// Build a request-scoped publication using this exact capability/context.
|
||||||
///
|
///
|
||||||
/// Publication and policy hooks must be side-effect-free with respect to the
|
/// Publication and policy hooks must be side-effect-free with respect to the
|
||||||
/// backend. A publication failure is a pre-dispatch rejection, never a
|
/// backend. This hook may also publish 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.
|
/// started Operation. Delegate metadata/validators to the boundary snapshot.
|
||||||
fn publish(
|
fn publish(
|
||||||
&self,
|
&self,
|
||||||
@@ -285,13 +321,78 @@ where
|
|||||||
|
|
||||||
fn publication(
|
fn publication(
|
||||||
&self,
|
&self,
|
||||||
entry: B::Entry,
|
resolved: &ResolvedEntry<B::Entry>,
|
||||||
context: &ExecutionContext,
|
context: &ExecutionContext,
|
||||||
) -> Result<PublishedObject<C::Object>, HostError> {
|
) -> Result<PublishedObject<C::Object>, HostError> {
|
||||||
|
let entry = resolved.target();
|
||||||
let publication = self
|
let publication = self
|
||||||
.projection
|
.projection
|
||||||
.composition
|
.composition
|
||||||
.publish(entry.clone(), context)?;
|
.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
|
let available = publication
|
||||||
.protocol_object()
|
.protocol_object()
|
||||||
.interfaces
|
.interfaces
|
||||||
@@ -306,19 +407,73 @@ where
|
|||||||
let mut selected = self
|
let mut selected = self
|
||||||
.projection
|
.projection
|
||||||
.policy
|
.policy
|
||||||
.interfaces(self.principal, &entry, &available);
|
.interfaces(self.principal, entry, &available);
|
||||||
selected.retain(|reference| {
|
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
|
.policy
|
||||||
.interface_visible(self.principal, reference)
|
.interface_visible(self.principal, reference)
|
||||||
});
|
});
|
||||||
publication
|
let mut publication = publication
|
||||||
.project(&selected, |reference, operation| {
|
.project(&selected, |reference, operation| {
|
||||||
self.projection
|
self.projection
|
||||||
.policy
|
.policy
|
||||||
.operation_visible(self.principal, reference, operation)
|
.operation_visible(self.principal, reference, operation)
|
||||||
})
|
})
|
||||||
.map_err(|_| internal())
|
.map_err(|_| internal())?;
|
||||||
|
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.
|
/// Retrieve the descriptor with precisely this principal's visible operations.
|
||||||
@@ -333,10 +488,8 @@ where
|
|||||||
&self,
|
&self,
|
||||||
request: &FetchInterfaceRequest,
|
request: &FetchInterfaceRequest,
|
||||||
) -> Result<FetchInterfaceResponse, ProtocolError> {
|
) -> Result<FetchInterfaceResponse, ProtocolError> {
|
||||||
let observation = self
|
let observation =
|
||||||
.projection
|
ObjectProvider::fetch_interface(self, &self.projection.registry, &request.interface)?;
|
||||||
.registry
|
|
||||||
.fetch_interface(&request.interface)?;
|
|
||||||
if !self
|
if !self
|
||||||
.projection
|
.projection
|
||||||
.policy
|
.policy
|
||||||
@@ -387,10 +540,10 @@ where
|
|||||||
}
|
}
|
||||||
ProviderResult::Failure(error) => return Err(read_error(error)),
|
ProviderResult::Failure(error) => return Err(read_error(error)),
|
||||||
};
|
};
|
||||||
let (_ancestors, entry) = entry.into_parts();
|
|
||||||
let mut remaining = u64::from(self.projection.limits.max_observation_nodes)
|
let mut remaining = u64::from(self.projection.limits.max_observation_nodes)
|
||||||
.min(context.resource_budget().unwrap_or(u64::MAX));
|
.min(context.resource_budget().unwrap_or(u64::MAX));
|
||||||
let response = self.observe_entry(context, entry, request.depth, &mut remaining)?;
|
let response =
|
||||||
|
self.observe_entry(context, &request.path, entry, request.depth, &mut remaining)?;
|
||||||
request.validate_response(&response).map_err(|_| {
|
request.validate_response(&response).map_err(|_| {
|
||||||
protocol(
|
protocol(
|
||||||
ProtocolErrorCode::Internal,
|
ProtocolErrorCode::Internal,
|
||||||
@@ -403,7 +556,8 @@ where
|
|||||||
fn observe_entry(
|
fn observe_entry(
|
||||||
&self,
|
&self,
|
||||||
context: &ExecutionContext,
|
context: &ExecutionContext,
|
||||||
entry: B::Entry,
|
path: &str,
|
||||||
|
resolved: ResolvedEntry<B::Entry>,
|
||||||
depth: u32,
|
depth: u32,
|
||||||
remaining: &mut u64,
|
remaining: &mut u64,
|
||||||
) -> Result<ObjectObservation, ProtocolError> {
|
) -> Result<ObjectObservation, ProtocolError> {
|
||||||
@@ -412,10 +566,19 @@ where
|
|||||||
return Err(resource_protocol());
|
return Err(resource_protocol());
|
||||||
}
|
}
|
||||||
*remaining -= 1;
|
*remaining -= 1;
|
||||||
let object = self
|
let object = match self.with_publication(context, path, resolved.clone(), |publication| {
|
||||||
.publication(entry.clone(), context)
|
publication.protocol_object_at(path)
|
||||||
.map_err(read_error)?
|
}) {
|
||||||
.protocol_object();
|
ProviderResult::Found(object) => object.map_err(|error| error.protocol_error())?,
|
||||||
|
ProviderResult::NotFound => {
|
||||||
|
return Err(protocol(
|
||||||
|
ProtocolErrorCode::NotFound,
|
||||||
|
"target path is not published",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
ProviderResult::Failure(error) => return Err(read_error(error)),
|
||||||
|
};
|
||||||
|
let (mut ancestors, entry) = resolved.into_parts();
|
||||||
if depth == 0 {
|
if depth == 0 {
|
||||||
return Ok(ObjectObservation {
|
return Ok(ObjectObservation {
|
||||||
object,
|
object,
|
||||||
@@ -436,13 +599,25 @@ where
|
|||||||
if entries.len() as u64 > *remaining {
|
if entries.len() as u64 > *remaining {
|
||||||
return Err(resource_protocol());
|
return Err(resource_protocol());
|
||||||
}
|
}
|
||||||
|
ancestors.push(entry.clone());
|
||||||
for child in entries {
|
for child in entries {
|
||||||
if self
|
if self
|
||||||
.projection
|
.projection
|
||||||
.policy
|
.policy
|
||||||
.object_visible(self.principal, &child)
|
.object_visible(self.principal, &child)
|
||||||
{
|
{
|
||||||
children.push(self.observe_entry(context, child, depth - 1, remaining)?);
|
let child_path = if path == "/" {
|
||||||
|
format!("/{}", child.name())
|
||||||
|
} else {
|
||||||
|
format!("{path}/{}", child.name())
|
||||||
|
};
|
||||||
|
children.push(self.observe_entry(
|
||||||
|
context,
|
||||||
|
&child_path,
|
||||||
|
ResolvedEntry::new(ancestors.clone(), child),
|
||||||
|
depth - 1,
|
||||||
|
remaining,
|
||||||
|
)?);
|
||||||
} else {
|
} else {
|
||||||
if *remaining == 0 {
|
if *remaining == 0 {
|
||||||
return Err(resource_protocol());
|
return Err(resource_protocol());
|
||||||
@@ -473,11 +648,7 @@ where
|
|||||||
) -> ProviderResult<R> {
|
) -> ProviderResult<R> {
|
||||||
match self.resolve(context, path) {
|
match self.resolve(context, path) {
|
||||||
ProviderResult::Found(resolved) => {
|
ProviderResult::Found(resolved) => {
|
||||||
let (_ancestors, entry) = resolved.into_parts();
|
self.with_publication(context, path, resolved, operation)
|
||||||
match self.publication(entry, context) {
|
|
||||||
Ok(publication) => ProviderResult::Found(operation(&publication)),
|
|
||||||
Err(error) => ProviderResult::Failure(error),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ProviderResult::NotFound => ProviderResult::NotFound,
|
ProviderResult::NotFound => ProviderResult::NotFound,
|
||||||
ProviderResult::Failure(error) => ProviderResult::Failure(error),
|
ProviderResult::Failure(error) => ProviderResult::Failure(error),
|
||||||
@@ -503,7 +674,6 @@ where
|
|||||||
if !self.lineage_visible(&resolved) {
|
if !self.lineage_visible(&resolved) {
|
||||||
return OperationAttempt::reject(wip_host::CallErrorKind::NotFound);
|
return OperationAttempt::reject(wip_host::CallErrorKind::NotFound);
|
||||||
}
|
}
|
||||||
let (_ancestors, entry) = resolved.into_parts();
|
|
||||||
if let Err(error) = preflight(context) {
|
if let Err(error) = preflight(context) {
|
||||||
return OperationAttempt::reject(match error.kind() {
|
return OperationAttempt::reject(match error.kind() {
|
||||||
wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal,
|
wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal,
|
||||||
@@ -512,7 +682,7 @@ where
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
match self.publication(entry, context) {
|
match self.publication(&resolved, context) {
|
||||||
Ok(publication) => operation(&publication),
|
Ok(publication) => operation(&publication),
|
||||||
Err(error) => OperationAttempt::reject(match error.kind() {
|
Err(error) => OperationAttempt::reject(match error.kind() {
|
||||||
wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal,
|
wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal,
|
||||||
|
|||||||
@@ -17,6 +17,19 @@
|
|||||||
//! listener is needed to use [`ProjectionView`]. [`HttpAdapter`] applies the
|
//! listener is needed to use [`ProjectionView`]. [`HttpAdapter`] applies the
|
||||||
//! existing standard HTTP binding to a deployment-established principal view.
|
//! existing standard HTTP binding to a deployment-established principal view.
|
||||||
//!
|
//!
|
||||||
|
//! # Scope publication migration
|
||||||
|
//!
|
||||||
|
//! Registration uses `registry.register(&scope, name, Definition, validator)`.
|
||||||
|
//! [`DefaultInterfaces::register`] takes an explicit [`wip_host::Scope`]; built-ins
|
||||||
|
//! use root-local names `file.read`, `symlink.inspect` and `file.write`. The engine
|
||||||
|
//! binds 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,
|
//! Backends and Operations are trusted code, responsible for truthful effects,
|
||||||
//! public-only metadata and ongoing execution-context bounds. Policies must be
|
//! public-only metadata and ongoing execution-context bounds. Policies must be
|
||||||
//! stable within a view; changing descriptor visibility requires new reference
|
//! stable within a view; changing descriptor visibility requires new reference
|
||||||
|
|||||||
@@ -1612,6 +1612,55 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn root_scoped_interfaces_follow_the_pinned_deployment_not_textual_replacement() {
|
||||||
|
let temporary = tempdir().expect("temporary parent");
|
||||||
|
let configured = temporary.path().join("root");
|
||||||
|
let moved = temporary.path().join("moved");
|
||||||
|
fs::create_dir(&configured).unwrap();
|
||||||
|
fs::write(configured.join("file"), b"original").unwrap();
|
||||||
|
let engine = projection(&configured, AccessMode::ReadOnly);
|
||||||
|
let object = engine.observe_object("/file").unwrap();
|
||||||
|
let reference = object.interfaces[0].clone();
|
||||||
|
assert_eq!(reference.scope, "/");
|
||||||
|
assert_eq!(reference.name, "file.read");
|
||||||
|
fs::rename(&configured, &moved).unwrap();
|
||||||
|
fs::create_dir(&configured).unwrap();
|
||||||
|
fs::write(configured.join("file"), b"replacement").unwrap();
|
||||||
|
let fetched = engine
|
||||||
|
.fetch_interface(&wip_protocol::FetchInterfaceRequest {
|
||||||
|
interface: reference.clone(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(fetched.scope_ref.is_none());
|
||||||
|
let result = call(
|
||||||
|
&engine,
|
||||||
|
"/file",
|
||||||
|
object.validator,
|
||||||
|
reference.clone(),
|
||||||
|
"read",
|
||||||
|
BTreeMap::new(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result.result, Value::Bytes(b"original".to_vec()));
|
||||||
|
let error = engine
|
||||||
|
.call_operation(wip_host::CallRequest {
|
||||||
|
target: wip_host::Target {
|
||||||
|
path: "/file".into(),
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
interface: wip_host::InterfaceTarget {
|
||||||
|
reference,
|
||||||
|
scope_ref: Some("invented-root-ref".into()),
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
operation: "read".into(),
|
||||||
|
arguments: BTreeMap::new(),
|
||||||
|
})
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error.kind(), CallErrorKind::InterfaceMismatch);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn file_lock_wait_observes_the_operation_deadline() {
|
fn file_lock_wait_observes_the_operation_deadline() {
|
||||||
let temporary = tempdir().expect("temporary root");
|
let temporary = tempdir().expect("temporary root");
|
||||||
@@ -1622,8 +1671,6 @@ mod tests {
|
|||||||
.write(true)
|
.write(true)
|
||||||
.open(&path)
|
.open(&path)
|
||||||
.expect("open lock owner");
|
.expect("open lock owner");
|
||||||
rustix::fs::flock(&locked, FlockOperation::LockExclusive).expect("hold external lock");
|
|
||||||
|
|
||||||
let limits = ResourceLimits {
|
let limits = ResourceLimits {
|
||||||
request_timeout: std::time::Duration::from_millis(20),
|
request_timeout: std::time::Duration::from_millis(20),
|
||||||
..ResourceLimits::default()
|
..ResourceLimits::default()
|
||||||
@@ -1631,6 +1678,9 @@ mod tests {
|
|||||||
let projection = crate::default_projection(temporary.path(), AccessMode::ReadOnly, limits)
|
let projection = crate::default_projection(temporary.path(), AccessMode::ReadOnly, limits)
|
||||||
.expect("open projection");
|
.expect("open projection");
|
||||||
let observed = projection.observe_object("/file").expect("observe file");
|
let observed = projection.observe_object("/file").expect("observe file");
|
||||||
|
// Observation also enters the coherent backend boundary now. Acquire
|
||||||
|
// the interfering lock only after obtaining Client-known metadata.
|
||||||
|
rustix::fs::flock(&locked, FlockOperation::LockExclusive).expect("hold external lock");
|
||||||
let started = std::time::Instant::now();
|
let started = std::time::Instant::now();
|
||||||
let error = call(
|
let error = call(
|
||||||
&projection,
|
&projection,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use wip_fs_server::{
|
|||||||
use wip_host::{
|
use wip_host::{
|
||||||
CancellationToken, EffectGuarantee, ExecutionContext, HostError, InterfaceRef,
|
CancellationToken, EffectGuarantee, ExecutionContext, HostError, InterfaceRef,
|
||||||
InterfaceRegistry, ObjectProvider, OperationAttempt, ProviderResult, PublishedObject,
|
InterfaceRegistry, ObjectProvider, OperationAttempt, ProviderResult, PublishedObject,
|
||||||
RegisteredInterface, Validator, WipObject,
|
RegisteredInterface, Scope, Validator, WipObject,
|
||||||
};
|
};
|
||||||
use wip_http::{
|
use wip_http::{
|
||||||
DecodedResponse, Endpoint, decode_call_operation_response, decode_fetch_interface_response,
|
DecodedResponse, Endpoint, decode_call_operation_response, decode_fetch_interface_response,
|
||||||
@@ -101,6 +101,9 @@ impl WipObject for Entry {
|
|||||||
fn description(&self) -> Option<&str> {
|
fn description(&self) -> Option<&str> {
|
||||||
Some("public memory entry")
|
Some("public memory entry")
|
||||||
}
|
}
|
||||||
|
fn reference(&self) -> Option<&str> {
|
||||||
|
(self.path == "/").then_some("entry-root")
|
||||||
|
}
|
||||||
fn validator(&self) -> Option<&Validator> {
|
fn validator(&self) -> Option<&Validator> {
|
||||||
Some(&self.validator)
|
Some(&self.validator)
|
||||||
}
|
}
|
||||||
@@ -122,6 +125,7 @@ struct Seen {
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct Controls {
|
struct Controls {
|
||||||
bump_before_execute: bool,
|
bump_before_execute: bool,
|
||||||
|
bump_ancestor: Option<String>,
|
||||||
hide_before_execute: bool,
|
hide_before_execute: bool,
|
||||||
hide_ancestor: Option<String>,
|
hide_ancestor: Option<String>,
|
||||||
replace_ancestor: Option<String>,
|
replace_ancestor: Option<String>,
|
||||||
@@ -157,7 +161,13 @@ impl Shared {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct MemoryBackend(Arc<Shared>);
|
struct MemoryBackend(Arc<Shared>, Arc<Node>);
|
||||||
|
impl MemoryBackend {
|
||||||
|
fn new(shared: Arc<Shared>) -> Self {
|
||||||
|
let root = shared.node("/");
|
||||||
|
Self(shared, root)
|
||||||
|
}
|
||||||
|
}
|
||||||
fn check_context(context: &ExecutionContext) -> Result<(), HostError> {
|
fn check_context(context: &ExecutionContext) -> Result<(), HostError> {
|
||||||
if context.cancellation().is_cancelled()
|
if context.cancellation().is_cancelled()
|
||||||
|| context
|
|| context
|
||||||
@@ -193,6 +203,12 @@ impl FilesystemBackend for MemoryBackend {
|
|||||||
// One connected map snapshot, serialized with participating mutations.
|
// One connected map snapshot, serialized with participating mutations.
|
||||||
let _transaction = self.0.transaction.lock().unwrap();
|
let _transaction = self.0.transaction.lock().unwrap();
|
||||||
let nodes = self.0.nodes.lock().unwrap();
|
let nodes = self.0.nodes.lock().unwrap();
|
||||||
|
if !nodes
|
||||||
|
.get("/")
|
||||||
|
.is_some_and(|root| Arc::ptr_eq(root, &self.1))
|
||||||
|
{
|
||||||
|
return ProviderResult::NotFound;
|
||||||
|
}
|
||||||
let Some(node) = nodes.get(path) else {
|
let Some(node) = nodes.get(path) else {
|
||||||
return ProviderResult::NotFound;
|
return ProviderResult::NotFound;
|
||||||
};
|
};
|
||||||
@@ -274,6 +290,9 @@ impl FilesystemBackend for MemoryBackend {
|
|||||||
entry.node.data.lock().unwrap().revision += 1;
|
entry.node.data.lock().unwrap().revision += 1;
|
||||||
controls.bump_before_execute = false;
|
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 {
|
if controls.hide_before_execute {
|
||||||
entry.node.data.lock().unwrap().hidden = true;
|
entry.node.data.lock().unwrap().hidden = true;
|
||||||
}
|
}
|
||||||
@@ -320,6 +339,12 @@ impl FilesystemBackend for MemoryBackend {
|
|||||||
// Verify lineage identity/membership within the same transaction. The
|
// Verify lineage identity/membership within the same transaction. The
|
||||||
// callback cannot see a substituted ancestor, nor a redirected target.
|
// callback cannot see a substituted ancestor, nor a redirected target.
|
||||||
let nodes = self.0.nodes.lock().unwrap();
|
let nodes = self.0.nodes.lock().unwrap();
|
||||||
|
if !nodes
|
||||||
|
.get("/")
|
||||||
|
.is_some_and(|root| Arc::ptr_eq(root, &self.1))
|
||||||
|
{
|
||||||
|
return ProviderResult::NotFound;
|
||||||
|
}
|
||||||
for ancestor in &ancestors {
|
for ancestor in &ancestors {
|
||||||
if !nodes
|
if !nodes
|
||||||
.get(&ancestor.path)
|
.get(&ancestor.path)
|
||||||
@@ -371,6 +396,11 @@ impl WipObject for Object {
|
|||||||
fn description(&self) -> Option<&str> {
|
fn description(&self) -> Option<&str> {
|
||||||
self.entry.description()
|
self.entry.description()
|
||||||
}
|
}
|
||||||
|
fn reference(&self) -> Option<&str> {
|
||||||
|
// Public representation identity need not be the backend entry ref.
|
||||||
|
// ScopeObservation must capture this published Object's ref.
|
||||||
|
(self.entry.path == "/").then_some("memory-root")
|
||||||
|
}
|
||||||
fn validator(&self) -> Option<&Validator> {
|
fn validator(&self) -> Option<&Validator> {
|
||||||
self.entry.validator()
|
self.entry.validator()
|
||||||
}
|
}
|
||||||
@@ -574,11 +604,19 @@ fn observation_and_fetch_policy_panics_remain_sanitized_read_failures() {
|
|||||||
for panic_object in [true, false] {
|
for panic_object in [true, false] {
|
||||||
let fixture = Fixture::new();
|
let fixture = Fixture::new();
|
||||||
let mut registry = InterfaceRegistry::new();
|
let mut registry = InterfaceRegistry::new();
|
||||||
let defaults = DefaultInterfaces::register(&mut registry).unwrap();
|
let scope = Scope::new("/").unwrap();
|
||||||
let custom = registry.register(MemoryOperationsDefinition, None).unwrap();
|
let defaults = DefaultInterfaces::register(&mut registry, &scope).unwrap();
|
||||||
|
let custom = registry
|
||||||
|
.register(
|
||||||
|
&scope,
|
||||||
|
"memory.operations",
|
||||||
|
MemoryOperationsDefinition,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let reference = custom.reference().protocol_reference();
|
let reference = custom.reference().protocol_reference();
|
||||||
let engine = Projection::new(
|
let engine = Projection::new(
|
||||||
MemoryBackend(fixture.shared.clone()),
|
MemoryBackend::new(fixture.shared.clone()),
|
||||||
registry,
|
registry,
|
||||||
MemoryComposition {
|
MemoryComposition {
|
||||||
defaults,
|
defaults,
|
||||||
@@ -619,6 +657,8 @@ struct Fixture {
|
|||||||
custom: wip_protocol::InterfaceReference,
|
custom: wip_protocol::InterfaceReference,
|
||||||
read: wip_protocol::InterfaceReference,
|
read: wip_protocol::InterfaceReference,
|
||||||
limits: ResourceLimits,
|
limits: ResourceLimits,
|
||||||
|
// Client-known descriptors acquired coherently before execution fault injection.
|
||||||
|
descriptors: BTreeMap<(bool, wip_protocol::InterfaceReference), InterfaceDescriptor>,
|
||||||
}
|
}
|
||||||
impl Fixture {
|
impl Fixture {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
@@ -669,9 +709,15 @@ impl Fixture {
|
|||||||
seen: Mutex::new(vec![]),
|
seen: Mutex::new(vec![]),
|
||||||
});
|
});
|
||||||
let mut registry = InterfaceRegistry::new();
|
let mut registry = InterfaceRegistry::new();
|
||||||
let defaults = DefaultInterfaces::register(&mut registry).unwrap();
|
let scope = Scope::new("/").unwrap();
|
||||||
|
let defaults = DefaultInterfaces::register(&mut registry, &scope).unwrap();
|
||||||
let custom = registry
|
let custom = registry
|
||||||
.register(MemoryOperationsDefinition, interface_validator)
|
.register(
|
||||||
|
&scope,
|
||||||
|
"memory.operations",
|
||||||
|
MemoryOperationsDefinition,
|
||||||
|
interface_validator,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let read_ref = defaults.read.reference().clone();
|
let read_ref = defaults.read.reference().clone();
|
||||||
let custom_ref = custom.reference().clone();
|
let custom_ref = custom.reference().clone();
|
||||||
@@ -687,18 +733,38 @@ impl Fixture {
|
|||||||
custom,
|
custom,
|
||||||
shared: shared.clone(),
|
shared: shared.clone(),
|
||||||
};
|
};
|
||||||
|
let engine = Projection::new(
|
||||||
|
MemoryBackend::new(shared.clone()),
|
||||||
|
registry,
|
||||||
|
composition,
|
||||||
|
policy,
|
||||||
|
limits,
|
||||||
|
);
|
||||||
|
let mut descriptors = BTreeMap::new();
|
||||||
|
if !invalid_selection {
|
||||||
|
for principal in [Principal::Admin, Principal::Limited] {
|
||||||
|
for reference in [&read_ref, &custom_ref] {
|
||||||
|
let reference = reference.protocol_reference();
|
||||||
|
if let Ok(observation) = engine
|
||||||
|
.view(&principal)
|
||||||
|
.fetch_interface(&fetch_request(&reference))
|
||||||
|
{
|
||||||
|
descriptors.insert(
|
||||||
|
(matches!(principal, Principal::Admin), reference),
|
||||||
|
observation.descriptor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shared.seen.lock().unwrap().clear();
|
||||||
Self {
|
Self {
|
||||||
engine: Projection::new(
|
engine,
|
||||||
MemoryBackend(shared.clone()),
|
|
||||||
registry,
|
|
||||||
composition,
|
|
||||||
policy,
|
|
||||||
limits,
|
|
||||||
),
|
|
||||||
shared,
|
shared,
|
||||||
custom: custom_ref.protocol_reference(),
|
custom: custom_ref.protocol_reference(),
|
||||||
read: read_ref.protocol_reference(),
|
read: read_ref.protocol_reference(),
|
||||||
limits,
|
limits,
|
||||||
|
descriptors,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn observe(&self, principal: &Principal, path: &str, depth: u32) -> ObjectObservation {
|
fn observe(&self, principal: &Principal, path: &str, depth: u32) -> ObjectObservation {
|
||||||
@@ -712,11 +778,7 @@ impl Fixture {
|
|||||||
principal: &Principal,
|
principal: &Principal,
|
||||||
reference: &wip_protocol::InterfaceReference,
|
reference: &wip_protocol::InterfaceReference,
|
||||||
) -> InterfaceDescriptor {
|
) -> InterfaceDescriptor {
|
||||||
self.engine
|
self.descriptors[&(matches!(principal, Principal::Admin), reference.clone())].clone()
|
||||||
.view(principal)
|
|
||||||
.fetch_interface(&fetch_request(reference))
|
|
||||||
.unwrap()
|
|
||||||
.descriptor
|
|
||||||
}
|
}
|
||||||
fn request(
|
fn request(
|
||||||
&self,
|
&self,
|
||||||
@@ -930,7 +992,10 @@ fn external_composition_adds_replaces_and_hides_default_interfaces_by_entry_cond
|
|||||||
Some("public memory entry")
|
Some("public memory entry")
|
||||||
);
|
);
|
||||||
assert!(observation.children.is_none());
|
assert!(observation.children.is_none());
|
||||||
assert!(observation.object.r#ref.is_none());
|
assert_eq!(
|
||||||
|
observation.object.r#ref.as_deref(),
|
||||||
|
(path == "/").then_some("memory-root")
|
||||||
|
);
|
||||||
for reference in &observation.object.interfaces {
|
for reference in &observation.object.interfaces {
|
||||||
let fetched = fixture
|
let fetched = fixture
|
||||||
.engine
|
.engine
|
||||||
@@ -1383,10 +1448,12 @@ fn policy_cannot_add_an_unimplemented_interface() {
|
|||||||
ProtocolErrorCode::Internal
|
ProtocolErrorCode::Internal
|
||||||
);
|
);
|
||||||
let request = fixture.request("/plain", &fixture.custom, "denied", None);
|
let request = fixture.request("/plain", &fixture.custom, "denied", None);
|
||||||
|
// This deliberately invalid publication cannot supply a fetch response.
|
||||||
|
// Use the application's authored schema solely to encode the test request.
|
||||||
let (response, decoded) = fixture.http_call(
|
let (response, decoded) = fixture.http_call(
|
||||||
&Principal::Admin,
|
&Principal::Admin,
|
||||||
&request,
|
&request,
|
||||||
&fixture.descriptor(&Principal::Admin, &fixture.custom),
|
&MemoryOperationsDefinition::descriptor(),
|
||||||
&ExecutionContext::new(),
|
&ExecutionContext::new(),
|
||||||
);
|
);
|
||||||
wire_error(decoded, ProtocolErrorCode::Internal);
|
wire_error(decoded, ProtocolErrorCode::Internal);
|
||||||
@@ -2036,3 +2103,153 @@ fn observation_depth_node_and_context_budgets_fail_without_truncating_the_tree()
|
|||||||
);
|
);
|
||||||
no_secrets(&response);
|
no_secrets(&response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn root_scope_fetch_and_call_use_the_current_boundary_snapshot() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let view = fixture.engine.view(&Principal::Admin);
|
||||||
|
let fetched = view
|
||||||
|
.fetch_interface(&fetch_request(&fixture.custom))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(fetched.scope_ref.as_deref(), Some("memory-root"));
|
||||||
|
assert!(
|
||||||
|
fixture
|
||||||
|
.observe(&Principal::Admin, "/", 0)
|
||||||
|
.object
|
||||||
|
.interfaces
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut request = fixture.request("/added", &fixture.custom, "denied", None);
|
||||||
|
request.interface.scope_ref = Some("wrong-root".into());
|
||||||
|
assert_eq!(
|
||||||
|
view.call_operation(request.clone()).unwrap_err().kind(),
|
||||||
|
wip_host::CallErrorKind::InterfaceMismatch
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
fixture
|
||||||
|
.shared
|
||||||
|
.node("/added")
|
||||||
|
.calls
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ordinary state updates do not replace the publication lifetime or ref.
|
||||||
|
fixture.shared.node("/").data.lock().unwrap().revision += 1;
|
||||||
|
request.interface.scope_ref = fetched.scope_ref;
|
||||||
|
view.call_operation(request).unwrap();
|
||||||
|
|
||||||
|
// Fetch cannot attest a stale prelookup: visibility is refreshed under the
|
||||||
|
// same boundary even though resolve initially sees a visible root.
|
||||||
|
fixture.shared.controls.lock().unwrap().hide_before_execute = true;
|
||||||
|
assert_eq!(
|
||||||
|
view.fetch_interface(&fetch_request(&fixture.custom))
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
ProtocolErrorCode::InterfaceNotFound
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn root_scope_replacement_during_call_rejects_the_prelookup() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let view = fixture.engine.view(&Principal::Admin);
|
||||||
|
// Simulate a retained non-root lineage whose root is replaced before call.
|
||||||
|
fixture.shared.controls.lock().unwrap().replace_ancestor = Some("/".into());
|
||||||
|
let request = fixture.request("/added", &fixture.custom, "denied", None);
|
||||||
|
assert_eq!(
|
||||||
|
view.call_operation(request).unwrap_err().kind(),
|
||||||
|
wip_host::CallErrorKind::NotFound
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
fixture
|
||||||
|
.shared
|
||||||
|
.node("/added")
|
||||||
|
.calls
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
view.fetch_interface(&fetch_request(&fixture.custom))
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
ProtocolErrorCode::InterfaceNotFound
|
||||||
|
);
|
||||||
|
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
fixture.shared.controls.lock().unwrap().replace_ancestor = Some("/".into());
|
||||||
|
assert_eq!(
|
||||||
|
fixture
|
||||||
|
.engine
|
||||||
|
.view(&Principal::Admin)
|
||||||
|
.fetch_interface(&fetch_request(&fixture.custom))
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
ProtocolErrorCode::InterfaceNotFound
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
wip-host = "0.1.0"
|
wip-host = "0.2.0"
|
||||||
wip-interface = "0.1.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`.
|
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
|
//! # Responsibility boundaries
|
||||||
//!
|
//!
|
||||||
//! [`InterfaceRegistry::register`] validates and freezes a generated interface
|
//! [`InterfaceRegistry::register`] validates and freezes a generated interface
|
||||||
//! descriptor, assigns an opaque [`InterfaceRef`], and leaves that reference
|
//! descriptor at an explicit scope and local name, and leaves that reference
|
||||||
//! outside the descriptor. [`PublishedObject::expose`] explicitly combines a
|
//! outside the descriptor. [`PublishedObject::expose`] explicitly combines a
|
||||||
//! request-scoped Rust object with a registered interface; its
|
//! request-scoped Rust object with a registered interface; its
|
||||||
//! [`InterfaceImplementation`] bound proves at compile time that the object
|
//! [`InterfaceImplementation`] bound proves at compile time that the object
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
//! and a publication with no interfaces is valid.
|
//! and a publication with no interfaces is valid.
|
||||||
//!
|
//!
|
||||||
//! [`ObjectProvider::call_operation`] resolves `target.path` exactly once and
|
//! [`ObjectProvider::call_operation`] resolves `target.path` exactly once and
|
||||||
//! keeps the resulting object, interface membership, descriptor, implementation,
|
//! keeps the resulting object, current scope Object association/ref, interface
|
||||||
|
//! membership, descriptor, implementation,
|
||||||
//! and backend target fixed within the provider's [`ObjectProvider::with_object`]
|
//! and backend target fixed within the provider's [`ObjectProvider::with_object`]
|
||||||
//! callback. Dispatch is qualified by `(interface reference, operation name)`.
|
//! callback. Dispatch is qualified by `(interface reference, operation name)`.
|
||||||
//! The same frozen operation declaration validates both arguments and results.
|
//! The same frozen operation declaration validates both arguments and results.
|
||||||
@@ -29,14 +30,13 @@
|
|||||||
|
|
||||||
#![deny(missing_docs)]
|
#![deny(missing_docs)]
|
||||||
|
|
||||||
use std::any::TypeId;
|
|
||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::fmt::{self, Display, Formatter};
|
use std::fmt::{self, Display, Formatter};
|
||||||
use std::marker::PhantomData;
|
use std::marker::PhantomData;
|
||||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use wip_interface::DispatchError;
|
use wip_interface::DispatchError;
|
||||||
@@ -187,36 +187,104 @@ impl ExecutionContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opaque host-assigned reference to one registered interface artifact.
|
/// One publication lifetime of the Object at a canonical scope path.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
///
|
||||||
pub struct InterfaceRef(String);
|
/// Clone while the same Object remains published, including state updates. Create
|
||||||
|
/// a new handle after deletion, replacement or movement, even with the same ref.
|
||||||
|
/// This private lifetime is not a Protocol identity component or generated ref.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Scope {
|
||||||
|
path: String,
|
||||||
|
lifetime: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
impl InterfaceRef {
|
impl Scope {
|
||||||
/// Returns the legacy opaque registration reference.
|
/// Creates a fresh lifetime without implicitly publishing an Object.
|
||||||
#[must_use]
|
pub fn new(path: impl Into<String>) -> Result<Self, ValidationError> {
|
||||||
pub fn as_str(&self) -> &str {
|
let path = path.into();
|
||||||
&self.0
|
wip_protocol::validate_path(&path)?;
|
||||||
|
Ok(Self {
|
||||||
|
path,
|
||||||
|
lifetime: Arc::new(AtomicBool::new(true)),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maps the legacy registration reference to a root-scoped protocol reference.
|
/// Returns the client-facing canonical scope path.
|
||||||
///
|
#[must_use]
|
||||||
/// The opaque registration string is copied as the name without parsing it.
|
pub fn path(&self) -> &str {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn same_lifetime(&self, other: &Self) -> bool {
|
||||||
|
self.path == other.path && Arc::ptr_eq(&self.lifetime, &other.lifetime)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active(&self) -> bool {
|
||||||
|
self.lifetime.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provider-attested current scope Object association.
|
||||||
|
///
|
||||||
|
/// Construct only inside a coherent boundary that proves this lifetime belongs
|
||||||
|
/// to the Object at `scope.path()`. A prelookup or matching ref alone is not proof.
|
||||||
|
/// The Object need not expose interfaces. Its validator is not scope identity.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ScopeObservation {
|
||||||
|
scope: Scope,
|
||||||
|
reference: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScopeObservation {
|
||||||
|
/// Attests the association and captures the current Object's optional ref.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(scope: &Scope, object: &impl WipObject) -> Self {
|
||||||
|
Self {
|
||||||
|
scope: scope.clone(),
|
||||||
|
reference: object.reference().map(str::to_owned),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the observed optional ref.
|
||||||
|
#[must_use]
|
||||||
|
pub fn reference(&self) -> Option<&str> {
|
||||||
|
self.reference.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the associated scope publication lifetime.
|
||||||
|
#[must_use]
|
||||||
|
pub fn scope(&self) -> &Scope {
|
||||||
|
&self.scope
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Structured client-facing reference to a scoped Interface publication.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct InterfaceRef(InterfaceReference);
|
||||||
|
|
||||||
|
impl InterfaceRef {
|
||||||
|
/// Returns the local name without parsing or adding display delimiters.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0.name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the structured Protocol reference.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn protocol_reference(&self) -> InterfaceReference {
|
pub fn protocol_reference(&self) -> InterfaceReference {
|
||||||
InterfaceReference {
|
self.0.clone()
|
||||||
scope: "/".into(),
|
|
||||||
name: self.0.clone(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One atomic observation of an interface reference, descriptor, and validator.
|
/// One atomic observation of an interface reference, descriptor, and validator.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct InterfaceObservation {
|
pub struct InterfaceObservation {
|
||||||
/// Opaque reference selected by published objects and operation calls.
|
/// Structured reference selected by published objects and operation calls.
|
||||||
pub reference: InterfaceRef,
|
pub reference: InterfaceRef,
|
||||||
/// Canonical descriptor frozen when the interface was registered.
|
/// Canonical descriptor frozen when the interface was registered.
|
||||||
pub descriptor: InterfaceDescriptor,
|
pub descriptor: InterfaceDescriptor,
|
||||||
|
/// Optional ref of the scope Object from this same observation.
|
||||||
|
pub scope_ref: Option<String>,
|
||||||
/// Optional validator. Its presence makes it a required call precondition.
|
/// Optional validator. Its presence makes it a required call precondition.
|
||||||
pub validator: Option<Validator>,
|
pub validator: Option<Validator>,
|
||||||
}
|
}
|
||||||
@@ -242,7 +310,7 @@ impl InterfaceObservation {
|
|||||||
let response = FetchInterfaceResponse {
|
let response = FetchInterfaceResponse {
|
||||||
interface: self.reference.protocol_reference(),
|
interface: self.reference.protocol_reference(),
|
||||||
descriptor: self.descriptor.clone(),
|
descriptor: self.descriptor.clone(),
|
||||||
scope_ref: None,
|
scope_ref: self.scope_ref.clone(),
|
||||||
validator: self.validator.as_ref().map(|validator| validator.0.clone()),
|
validator: self.validator.as_ref().map(|validator| validator.0.clone()),
|
||||||
};
|
};
|
||||||
response
|
response
|
||||||
@@ -259,6 +327,7 @@ impl InterfaceObservation {
|
|||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct InterfaceArtifact {
|
struct InterfaceArtifact {
|
||||||
|
scope: Scope,
|
||||||
reference: InterfaceRef,
|
reference: InterfaceRef,
|
||||||
descriptor: InterfaceDescriptor,
|
descriptor: InterfaceDescriptor,
|
||||||
validator: Option<Validator>,
|
validator: Option<Validator>,
|
||||||
@@ -267,7 +336,7 @@ struct InterfaceArtifact {
|
|||||||
/// A typed registration token for a generated interface definition.
|
/// A typed registration token for a generated interface definition.
|
||||||
///
|
///
|
||||||
/// Tokens can be reused by any number of request-scoped objects. They expose an
|
/// Tokens can be reused by any number of request-scoped objects. They expose an
|
||||||
/// opaque reference, not the Rust type name or an address.
|
/// scoped reference and a private publication lifetime, not a Rust address.
|
||||||
pub struct RegisteredInterface<D> {
|
pub struct RegisteredInterface<D> {
|
||||||
artifact: Arc<InterfaceArtifact>,
|
artifact: Arc<InterfaceArtifact>,
|
||||||
definition: PhantomData<fn() -> D>,
|
definition: PhantomData<fn() -> D>,
|
||||||
@@ -283,170 +352,161 @@ impl<D> Clone for RegisteredInterface<D> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<D> RegisteredInterface<D> {
|
impl<D> RegisteredInterface<D> {
|
||||||
/// Returns the opaque protocol reference assigned by the registry.
|
/// Returns the explicit scope/name reference.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn reference(&self) -> &InterfaceRef {
|
pub fn reference(&self) -> &InterfaceRef {
|
||||||
&self.artifact.reference
|
&self.artifact.reference
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registry of validated, immutable interface artifacts.
|
/// Registry of frozen definition artifacts and explicitly scoped registrations.
|
||||||
///
|
///
|
||||||
/// Generated descriptors are never rewritten to contain their assigned
|
/// Artifact retention is not proof of current publication. Fetch requires a live
|
||||||
/// references. A validator-free artifact is immutable for the lifetime of its
|
/// provider-attested ScopeObservation; execution requires the same lifetime in
|
||||||
/// reference. A validator-bearing artifact requires that exact validator on a
|
/// the target's atomic boundary. Collision keys are exact (scope, name) pairs,
|
||||||
/// call. Re-registering a definition is rejected rather than silently changing
|
/// not Rust TypeId: the same Definition can be registered at multiple coordinates.
|
||||||
/// the observation associated with an existing reference.
|
#[derive(Debug, Default)]
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct InterfaceRegistry {
|
pub struct InterfaceRegistry {
|
||||||
namespace: u64,
|
|
||||||
next_reference: u64,
|
|
||||||
definitions: HashSet<TypeId>,
|
|
||||||
artifacts: HashMap<InterfaceRef, Arc<InterfaceArtifact>>,
|
artifacts: HashMap<InterfaceRef, Arc<InterfaceArtifact>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
static NEXT_REGISTRY_NAMESPACE: AtomicU64 = AtomicU64::new(1);
|
|
||||||
|
|
||||||
impl Default for InterfaceRegistry {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
namespace: NEXT_REGISTRY_NAMESPACE.fetch_add(1, Ordering::Relaxed),
|
|
||||||
next_reference: 0,
|
|
||||||
definitions: HashSet::new(),
|
|
||||||
artifacts: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InterfaceRegistry {
|
impl InterfaceRegistry {
|
||||||
/// Creates an empty interface registry.
|
/// Creates an empty artifact registry without publishing any Object.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registers and freezes one generated interface definition.
|
/// Freezes a Definition at an explicit scope and local name.
|
||||||
pub fn register<D>(
|
pub fn register<D>(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
scope: &Scope,
|
||||||
|
name: impl Into<String>,
|
||||||
_definition: D,
|
_definition: D,
|
||||||
validator: Option<Validator>,
|
validator: Option<Validator>,
|
||||||
) -> Result<RegisteredInterface<D>, RegistrationError>
|
) -> Result<RegisteredInterface<D>, RegistrationError>
|
||||||
where
|
where
|
||||||
D: InterfaceDefinition + 'static,
|
D: InterfaceDefinition + 'static,
|
||||||
{
|
{
|
||||||
let definition = TypeId::of::<D>();
|
let reference = InterfaceRef(InterfaceReference {
|
||||||
if self.definitions.contains(&definition) {
|
scope: scope.path.clone(),
|
||||||
|
name: name.into(),
|
||||||
|
});
|
||||||
|
reference
|
||||||
|
.0
|
||||||
|
.validate()
|
||||||
|
.map_err(RegistrationError::InvalidReference)?;
|
||||||
|
if !scope.active() {
|
||||||
|
return Err(RegistrationError::RetiredScope);
|
||||||
|
}
|
||||||
|
if self.artifacts.contains_key(&reference) {
|
||||||
return Err(RegistrationError::AlreadyRegistered);
|
return Err(RegistrationError::AlreadyRegistered);
|
||||||
}
|
}
|
||||||
|
|
||||||
let descriptor = D::descriptor();
|
let descriptor = D::descriptor();
|
||||||
descriptor
|
descriptor
|
||||||
.validate()
|
.validate()
|
||||||
.map_err(RegistrationError::InvalidDescriptor)?;
|
.map_err(RegistrationError::InvalidDescriptor)?;
|
||||||
|
|
||||||
let reference = InterfaceRef(format!(
|
|
||||||
"wip-interface:{}:{}",
|
|
||||||
self.namespace, self.next_reference
|
|
||||||
));
|
|
||||||
self.next_reference = self
|
|
||||||
.next_reference
|
|
||||||
.checked_add(1)
|
|
||||||
.ok_or(RegistrationError::ReferenceSpaceExhausted)?;
|
|
||||||
let artifact = Arc::new(InterfaceArtifact {
|
let artifact = Arc::new(InterfaceArtifact {
|
||||||
|
scope: scope.clone(),
|
||||||
reference: reference.clone(),
|
reference: reference.clone(),
|
||||||
descriptor,
|
descriptor,
|
||||||
validator,
|
validator,
|
||||||
});
|
});
|
||||||
self.definitions.insert(definition);
|
|
||||||
self.artifacts.insert(reference, Arc::clone(&artifact));
|
self.artifacts.insert(reference, Arc::clone(&artifact));
|
||||||
|
|
||||||
Ok(RegisteredInterface {
|
Ok(RegisteredInterface {
|
||||||
artifact,
|
artifact,
|
||||||
definition: PhantomData,
|
definition: PhantomData,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves a structured protocol reference to a legacy registration token.
|
/// Ends a scope lifetime and releases its registry entries.
|
||||||
///
|
///
|
||||||
/// Legacy artifacts are published only at root scope. The name is looked up
|
/// Retained tokens cannot start new calls/fetches. Started calls retain their
|
||||||
/// verbatim; no scope or name parsing is performed.
|
/// frozen Descriptor. Re-publication requires a fresh Scope.
|
||||||
|
pub fn remove_scope(&mut self, scope: &Scope) {
|
||||||
|
scope.lifetime.store(false, Ordering::Release);
|
||||||
|
self.artifacts
|
||||||
|
.retain(|_, artifact| !artifact.scope.same_lifetime(scope));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves a stored artifact, not evidence of current publication.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn resolve(&self, reference: &InterfaceReference) -> Option<InterfaceRef> {
|
pub fn resolve(&self, reference: &InterfaceReference) -> Option<InterfaceRef> {
|
||||||
if reference.scope != "/" {
|
let reference = InterfaceRef(reference.clone());
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let reference = InterfaceRef(reference.name.clone());
|
|
||||||
self.artifacts.contains_key(&reference).then_some(reference)
|
self.artifacts.contains_key(&reference).then_some(reference)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Observes a registered interface by its host-side reference.
|
/// Iterates registered scope handles, possibly repeated for multiple names.
|
||||||
#[must_use]
|
pub fn scopes(&self) -> impl Iterator<Item = &Scope> {
|
||||||
pub fn observe_object(&self, reference: &InterfaceRef) -> Option<InterfaceObservation> {
|
self.artifacts.values().map(|artifact| &artifact.scope)
|
||||||
self.artifacts.get(reference).map(Self::observation)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Implements canonical `fetch_interface` semantics for a structured reference.
|
/// Fetches only with a coherent current scope Object attestation.
|
||||||
///
|
|
||||||
/// Malformed references map to `InvalidRequest`. Unknown and currently
|
|
||||||
/// unpublished references (including non-root scopes) map to `InterfaceNotFound`.
|
|
||||||
pub fn fetch_interface(
|
pub fn fetch_interface(
|
||||||
&self,
|
&self,
|
||||||
reference: &InterfaceReference,
|
reference: &InterfaceReference,
|
||||||
|
current: &ScopeObservation,
|
||||||
) -> Result<InterfaceObservation, ProtocolError> {
|
) -> Result<InterfaceObservation, ProtocolError> {
|
||||||
reference.validate().map_err(|error| ProtocolError {
|
reference.validate().map_err(|error| ProtocolError {
|
||||||
code: ProtocolErrorCode::InvalidRequest,
|
code: ProtocolErrorCode::InvalidRequest,
|
||||||
message: error.to_string(),
|
message: error.to_string(),
|
||||||
})?;
|
})?;
|
||||||
self.resolve(reference)
|
self.artifacts
|
||||||
.and_then(|reference| self.artifacts.get(&reference))
|
.get(&InterfaceRef(reference.clone()))
|
||||||
.map(Self::observation)
|
.filter(|artifact| {
|
||||||
.ok_or_else(|| ProtocolError {
|
artifact.scope.active() && artifact.scope.same_lifetime(¤t.scope)
|
||||||
code: ProtocolErrorCode::InterfaceNotFound,
|
|
||||||
message: "interface reference is not currently published".into(),
|
|
||||||
})
|
})
|
||||||
}
|
.map(|artifact| InterfaceObservation {
|
||||||
|
reference: artifact.reference.clone(),
|
||||||
fn observation(artifact: &Arc<InterfaceArtifact>) -> InterfaceObservation {
|
descriptor: artifact.descriptor.clone(),
|
||||||
InterfaceObservation {
|
scope_ref: current.reference.clone(),
|
||||||
reference: artifact.reference.clone(),
|
validator: artifact.validator.clone(),
|
||||||
descriptor: artifact.descriptor.clone(),
|
})
|
||||||
validator: artifact.validator.clone(),
|
.ok_or_else(interface_not_found)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Failure while registering a generated interface definition.
|
fn interface_not_found() -> ProtocolError {
|
||||||
|
ProtocolError {
|
||||||
|
code: ProtocolErrorCode::InterfaceNotFound,
|
||||||
|
message: "interface is not currently published".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Failure while registering a generated interface Definition.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum RegistrationError {
|
pub enum RegistrationError {
|
||||||
/// The same generated definition was already assigned a reference.
|
/// The exact scope/name coordinate is already occupied.
|
||||||
AlreadyRegistered,
|
AlreadyRegistered,
|
||||||
/// The generated descriptor is structurally invalid.
|
/// The generated Descriptor is invalid.
|
||||||
InvalidDescriptor(ValidationError),
|
InvalidDescriptor(ValidationError),
|
||||||
/// The registry cannot assign another opaque reference.
|
/// The scope or local name is invalid.
|
||||||
ReferenceSpaceExhausted,
|
InvalidReference(ValidationError),
|
||||||
|
/// The lifetime ended; re-publication needs a fresh Scope.
|
||||||
|
RetiredScope,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Display for RegistrationError {
|
impl Display for RegistrationError {
|
||||||
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::AlreadyRegistered => {
|
Self::AlreadyRegistered => {
|
||||||
formatter.write_str("interface definition already registered")
|
formatter.write_str("interface scope/name already registered")
|
||||||
}
|
}
|
||||||
Self::InvalidDescriptor(error) => {
|
Self::InvalidDescriptor(error) => {
|
||||||
write!(formatter, "invalid interface descriptor: {error}")
|
write!(formatter, "invalid interface descriptor: {error}")
|
||||||
}
|
}
|
||||||
Self::ReferenceSpaceExhausted => {
|
Self::InvalidReference(error) => {
|
||||||
formatter.write_str("interface reference space exhausted")
|
write!(formatter, "invalid interface reference: {error}")
|
||||||
}
|
}
|
||||||
|
Self::RetiredScope => formatter.write_str("scope publication lifetime has ended"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Error for RegistrationError {
|
impl Error for RegistrationError {
|
||||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||||
match self {
|
match self {
|
||||||
Self::InvalidDescriptor(error) => Some(error),
|
Self::InvalidDescriptor(error) | Self::InvalidReference(error) => Some(error),
|
||||||
Self::AlreadyRegistered | Self::ReferenceSpaceExhausted => None,
|
Self::AlreadyRegistered | Self::RetiredScope => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -553,6 +613,7 @@ struct BoundInterface<T> {
|
|||||||
pub struct PublishedObject<T: WipObject> {
|
pub struct PublishedObject<T: WipObject> {
|
||||||
implementation: T,
|
implementation: T,
|
||||||
interfaces: Vec<BoundInterface<T>>,
|
interfaces: Vec<BoundInterface<T>>,
|
||||||
|
scopes: Vec<ScopeObservation>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: WipObject> PublishedObject<T> {
|
impl<T: WipObject> PublishedObject<T> {
|
||||||
@@ -562,13 +623,45 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
Self {
|
Self {
|
||||||
implementation,
|
implementation,
|
||||||
interfaces: Vec::new(),
|
interfaces: Vec::new(),
|
||||||
|
scopes: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attaches a current scope snapshot inside the provider's atomic boundary.
|
||||||
|
///
|
||||||
|
/// Never populate this from a stale prelookup. Target, scope association,
|
||||||
|
/// Descriptor and backend handles must remain coherent through dispatch and
|
||||||
|
/// commit. A later same-path binding replaces the earlier snapshot.
|
||||||
|
#[must_use]
|
||||||
|
pub fn bind_scope(mut self, current: ScopeObservation) -> Self {
|
||||||
|
self.scopes
|
||||||
|
.retain(|old| old.scope.path != current.scope.path);
|
||||||
|
self.scopes.push(current);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_scope(&self, scope: &Scope) -> Option<&ScopeObservation> {
|
||||||
|
self.scopes
|
||||||
|
.iter()
|
||||||
|
.find(|current| scope.active() && scope.same_lifetime(¤t.scope))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scopes_valid_at(&self, path: &str) -> bool {
|
||||||
|
self.interfaces.iter().all(|bound| {
|
||||||
|
bound.artifact.reference.0.validate_for_path(path).is_ok()
|
||||||
|
&& self
|
||||||
|
.current_scope(&bound.artifact.scope)
|
||||||
|
.is_some_and(|current| {
|
||||||
|
current.scope.path != path
|
||||||
|
|| current.reference() == self.implementation.reference()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Explicitly exposes a registered generated interface in insertion order.
|
/// Explicitly exposes a registered generated interface in insertion order.
|
||||||
///
|
///
|
||||||
/// The `InterfaceImplementation<T>` bound is generated only when `T`
|
/// The `InterfaceImplementation<T>` bound is generated only when `T`
|
||||||
/// implements the corresponding Rust interface trait. Duplicate opaque
|
/// implements the corresponding Rust interface trait. Duplicate scoped
|
||||||
/// references are rejected.
|
/// references are rejected.
|
||||||
pub fn expose<D>(mut self, interface: &RegisteredInterface<D>) -> Result<Self, PublicationError>
|
pub fn expose<D>(mut self, interface: &RegisteredInterface<D>) -> Result<Self, PublicationError>
|
||||||
where
|
where
|
||||||
@@ -621,6 +714,7 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
let mut descriptor = bound.artifact.descriptor.clone();
|
let mut descriptor = bound.artifact.descriptor.clone();
|
||||||
restrict_descriptor(&mut descriptor, |operation| visible(reference, operation));
|
restrict_descriptor(&mut descriptor, |operation| visible(reference, operation));
|
||||||
bound.artifact = Arc::new(InterfaceArtifact {
|
bound.artifact = Arc::new(InterfaceArtifact {
|
||||||
|
scope: bound.artifact.scope.clone(),
|
||||||
reference: reference.clone(),
|
reference: reference.clone(),
|
||||||
descriptor,
|
descriptor,
|
||||||
validator: bound.artifact.validator.clone(),
|
validator: bound.artifact.validator.clone(),
|
||||||
@@ -637,9 +731,13 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
&self.implementation
|
&self.implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds canonical protocol object metadata in interface insertion order.
|
/// Builds metadata from the explicit exposure set in insertion order.
|
||||||
///
|
///
|
||||||
/// The observation contains no path, children, descriptor, backend handle,
|
/// This path-free projection is useful while composing a publication, but is
|
||||||
|
/// not proof that its scopes are currently published. Use
|
||||||
|
/// [`ObjectProvider::observe_object`] for a validated current observation, or
|
||||||
|
/// [`Self::protocol_object_at`] inside a provider's coherent boundary.
|
||||||
|
/// The metadata contains no path, children, descriptor, backend handle,
|
||||||
/// credential, or provider resolution state.
|
/// credential, or provider resolution state.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn protocol_object(&self) -> Object {
|
pub fn protocol_object(&self) -> Object {
|
||||||
@@ -659,6 +757,30 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validates a current Object observation inside the provider's boundary.
|
||||||
|
///
|
||||||
|
/// All exposed interfaces must have compatible, live scope associations. The
|
||||||
|
/// provider must still establish those associations atomically; this method
|
||||||
|
/// cannot verify backend lifetime assertions or lock external writers.
|
||||||
|
pub fn protocol_object_at(&self, path: &str) -> Result<Object, CallError> {
|
||||||
|
wip_protocol::validate_path(path)
|
||||||
|
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
||||||
|
if !self.scopes_valid_at(path) {
|
||||||
|
return Err(CallError::new(
|
||||||
|
CallErrorKind::Internal,
|
||||||
|
"Host published interfaces without current compatible scope Objects",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let object = self.protocol_object();
|
||||||
|
object.validate_at_path(path).map_err(|_| {
|
||||||
|
CallError::new(
|
||||||
|
CallErrorKind::Internal,
|
||||||
|
"Host produced an invalid object response",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(object)
|
||||||
|
}
|
||||||
|
|
||||||
fn dispatch_resolved(
|
fn dispatch_resolved(
|
||||||
&self,
|
&self,
|
||||||
context: &ExecutionContext,
|
context: &ExecutionContext,
|
||||||
@@ -720,6 +842,31 @@ impl<T: WipObject> PublishedObject<T> {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let Some(current_scope) = self.current_scope(&bound.artifact.scope) else {
|
||||||
|
return pre_dispatch_failure(
|
||||||
|
CallErrorKind::InterfaceMismatch,
|
||||||
|
"interface scope Object is not currently associated with this publication",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
if current_scope.scope.path == target.path
|
||||||
|
&& current_scope.reference() != self.implementation.reference()
|
||||||
|
{
|
||||||
|
return pre_dispatch_failure(
|
||||||
|
CallErrorKind::Internal,
|
||||||
|
"provider produced inconsistent self-scope metadata",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if interface
|
||||||
|
.scope_ref
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|supplied| current_scope.reference() != Some(supplied))
|
||||||
|
{
|
||||||
|
return pre_dispatch_failure(
|
||||||
|
CallErrorKind::InterfaceMismatch,
|
||||||
|
"scope Object ref does not match the current association",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
match (
|
match (
|
||||||
bound.artifact.validator.as_ref(),
|
bound.artifact.validator.as_ref(),
|
||||||
interface.validator.as_deref(),
|
interface.validator.as_deref(),
|
||||||
@@ -958,7 +1105,12 @@ pub enum ProviderResult<T> {
|
|||||||
///
|
///
|
||||||
/// `with_object` must resolve the current object at `path` exactly once and pass
|
/// `with_object` must resolve the current object at `path` exactly once and pass
|
||||||
/// a publication that pins its current interface set, implementations, and
|
/// a publication that pins its current interface set, implementations, and
|
||||||
/// backend target for the duration of `operation`. The execution context is the
|
/// backend target and all current scope Object associations for the duration of
|
||||||
|
/// `operation`. Attach them with PublishedObject::bind_scope inside this same
|
||||||
|
/// boundary, never via an independent scope prelookup. A new Object at the same
|
||||||
|
/// path needs a fresh Scope; ordinary state changes retain the handle. Providers
|
||||||
|
/// own lifetime/atomicity enforcement, including external writers and backend
|
||||||
|
/// commit/rollback. The execution context is the
|
||||||
/// runtime-neutral carrier for cancellation, deadline, and resource controls.
|
/// runtime-neutral carrier for cancellation, deadline, and resource controls.
|
||||||
/// The borrowed publication cannot escape the callback.
|
/// The borrowed publication cannot escape the callback.
|
||||||
///
|
///
|
||||||
@@ -1022,7 +1174,9 @@ pub trait ObjectProvider {
|
|||||||
wip_protocol::validate_path(path)
|
wip_protocol::validate_path(path)
|
||||||
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
|
||||||
let resolved = catch_unwind(AssertUnwindSafe(|| {
|
let resolved = catch_unwind(AssertUnwindSafe(|| {
|
||||||
self.with_object(context, path, PublishedObject::protocol_object)
|
self.with_object(context, path, |publication| {
|
||||||
|
publication.protocol_object_at(path)
|
||||||
|
})
|
||||||
}))
|
}))
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
CallError::new(
|
CallError::new(
|
||||||
@@ -1031,7 +1185,7 @@ pub trait ObjectProvider {
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let object = match resolved {
|
let object = match resolved {
|
||||||
ProviderResult::Found(object) => object,
|
ProviderResult::Found(object) => object?,
|
||||||
ProviderResult::NotFound => {
|
ProviderResult::NotFound => {
|
||||||
return Err(CallError::new(
|
return Err(CallError::new(
|
||||||
CallErrorKind::NotFound,
|
CallErrorKind::NotFound,
|
||||||
@@ -1040,15 +1194,58 @@ pub trait ObjectProvider {
|
|||||||
}
|
}
|
||||||
ProviderResult::Failure(error) => return Err(CallError::from_read_failure(error)),
|
ProviderResult::Failure(error) => return Err(CallError::from_read_failure(error)),
|
||||||
};
|
};
|
||||||
object.validate_at_path(path).map_err(|_| {
|
|
||||||
CallError::new(
|
|
||||||
CallErrorKind::Internal,
|
|
||||||
"Host produced an invalid object response",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
Ok(object)
|
Ok(object)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetches Descriptor, scope Object ref and Interface validator atomically.
|
||||||
|
///
|
||||||
|
/// The scope Object may expose no interfaces. Its publication must still
|
||||||
|
/// bind the live ScopeObservation. Registry retention is not publication.
|
||||||
|
fn fetch_interface(
|
||||||
|
&self,
|
||||||
|
registry: &InterfaceRegistry,
|
||||||
|
reference: &InterfaceReference,
|
||||||
|
) -> Result<InterfaceObservation, ProtocolError> {
|
||||||
|
reference.validate().map_err(|error| ProtocolError {
|
||||||
|
code: ProtocolErrorCode::InvalidRequest,
|
||||||
|
message: error.to_string(),
|
||||||
|
})?;
|
||||||
|
let observed = catch_unwind(AssertUnwindSafe(|| {
|
||||||
|
self.with_object(
|
||||||
|
&ExecutionContext::default(),
|
||||||
|
&reference.scope,
|
||||||
|
|publication| {
|
||||||
|
publication
|
||||||
|
.scopes
|
||||||
|
.iter()
|
||||||
|
.find(|current| current.scope.path == reference.scope)
|
||||||
|
.ok_or_else(interface_not_found)
|
||||||
|
.and_then(|current| {
|
||||||
|
if current.reference() != publication.implementation.reference() {
|
||||||
|
return Err(ProtocolError {
|
||||||
|
code: ProtocolErrorCode::Internal,
|
||||||
|
message: "provider produced inconsistent scope Object metadata"
|
||||||
|
.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
registry.fetch_interface(reference, current)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
.map_err(|_| ProtocolError {
|
||||||
|
code: ProtocolErrorCode::Internal,
|
||||||
|
message: "provider panicked while observing an interface".into(),
|
||||||
|
})?;
|
||||||
|
match observed {
|
||||||
|
ProviderResult::Found(observation) => observation,
|
||||||
|
ProviderResult::NotFound => Err(interface_not_found()),
|
||||||
|
ProviderResult::Failure(error) => {
|
||||||
|
Err(CallError::from_read_failure(error).protocol_error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolves and executes a canonical path-targeted operation request.
|
/// Resolves and executes a canonical path-targeted operation request.
|
||||||
///
|
///
|
||||||
/// Structural path validation precedes provider resolution. For a valid
|
/// Structural path validation precedes provider resolution. For a valid
|
||||||
@@ -1168,7 +1365,7 @@ pub trait ObjectProvider {
|
|||||||
/// Failure to construct an explicit object publication.
|
/// Failure to construct an explicit object publication.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum PublicationError {
|
pub enum PublicationError {
|
||||||
/// The same opaque interface reference was exposed more than once.
|
/// The same scope/name Interface reference was exposed more than once.
|
||||||
DuplicateInterface(InterfaceRef),
|
DuplicateInterface(InterfaceRef),
|
||||||
/// Projection attempted to add an interface without an implementation.
|
/// Projection attempted to add an interface without an implementation.
|
||||||
UnexposedInterface(InterfaceRef),
|
UnexposedInterface(InterfaceRef),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use std::time::{Duration, Instant};
|
|||||||
use wip_host::{
|
use wip_host::{
|
||||||
CallError, CallErrorKind, CallRequest, CancellationToken, ExecutionContext, InterfaceRef,
|
CallError, CallErrorKind, CallRequest, CancellationToken, ExecutionContext, InterfaceRef,
|
||||||
InterfaceRegistry, InterfaceTarget, ObjectProvider, OperationAttempt, ProviderResult,
|
InterfaceRegistry, InterfaceTarget, ObjectProvider, OperationAttempt, ProviderResult,
|
||||||
PublishedObject, RegisteredInterface, Target, Validator, WipObject,
|
PublishedObject, RegisteredInterface, Scope, ScopeObservation, Target, Validator, WipObject,
|
||||||
};
|
};
|
||||||
use wip_interface::{
|
use wip_interface::{
|
||||||
CodecError, EffectGuarantee, HostError, OperationResult, TypeDeclaration, TypeExpr, Value,
|
CodecError, EffectGuarantee, HostError, OperationResult, TypeDeclaration, TypeExpr, Value,
|
||||||
@@ -17,9 +17,16 @@ use wip_protocol::ProtocolErrorCode;
|
|||||||
#[test]
|
#[test]
|
||||||
fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_tokens() {
|
fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_tokens() {
|
||||||
let mut registry = InterfaceRegistry::new();
|
let mut registry = InterfaceRegistry::new();
|
||||||
let read = registry.register(ReaderDefinition, None).unwrap();
|
let root = Scope::new("/").unwrap();
|
||||||
let write = registry.register(WriterDefinition, None).unwrap();
|
let read = registry
|
||||||
let hidden = registry.register(HiddenDefinition, None).unwrap();
|
.register(&root, "reader", ReaderDefinition, None)
|
||||||
|
.unwrap();
|
||||||
|
let write = registry
|
||||||
|
.register(&root, "writer::#日本語", WriterDefinition, None)
|
||||||
|
.unwrap();
|
||||||
|
let hidden = registry
|
||||||
|
.register(&root, "hidden", HiddenDefinition, None)
|
||||||
|
.unwrap();
|
||||||
let publication = || {
|
let publication = || {
|
||||||
PublishedObject::new(Document::open("document", None, Rc::new(Cell::new(0))))
|
PublishedObject::new(Document::open("document", None, Rc::new(Cell::new(0))))
|
||||||
.expose(&read)
|
.expose(&read)
|
||||||
@@ -61,8 +68,9 @@ fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_to
|
|||||||
.interfaces
|
.interfaces
|
||||||
.is_empty()
|
.is_empty()
|
||||||
);
|
);
|
||||||
|
let current = ScopeObservation::new(&root, &Document::open("", None, Rc::new(Cell::new(0))));
|
||||||
let full = registry
|
let full = registry
|
||||||
.fetch_interface(&read.reference().protocol_reference())
|
.fetch_interface(&read.reference().protocol_reference(), ¤t)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let filtered = full
|
let filtered = full
|
||||||
.clone()
|
.clone()
|
||||||
@@ -71,7 +79,7 @@ fn principal_projection_orders_subsets_and_rejects_unimplemented_or_duplicate_to
|
|||||||
assert_eq!(full.descriptor.operations.len(), 3);
|
assert_eq!(full.descriptor.operations.len(), 3);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
registry
|
registry
|
||||||
.fetch_interface(&read.reference().protocol_reference())
|
.fetch_interface(&read.reference().protocol_reference(), ¤t)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
full
|
full
|
||||||
);
|
);
|
||||||
@@ -309,6 +317,7 @@ impl Broken for Document {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct TestProvider {
|
struct TestProvider {
|
||||||
|
root: Scope,
|
||||||
reader: RegisteredInterface<ReaderDefinition>,
|
reader: RegisteredInterface<ReaderDefinition>,
|
||||||
writer: RegisteredInterface<WriterDefinition>,
|
writer: RegisteredInterface<WriterDefinition>,
|
||||||
broken: RegisteredInterface<BrokenDefinition>,
|
broken: RegisteredInterface<BrokenDefinition>,
|
||||||
@@ -326,11 +335,13 @@ struct TestProvider {
|
|||||||
|
|
||||||
impl TestProvider {
|
impl TestProvider {
|
||||||
fn new(
|
fn new(
|
||||||
|
root: Scope,
|
||||||
reader: RegisteredInterface<ReaderDefinition>,
|
reader: RegisteredInterface<ReaderDefinition>,
|
||||||
writer: RegisteredInterface<WriterDefinition>,
|
writer: RegisteredInterface<WriterDefinition>,
|
||||||
broken: RegisteredInterface<BrokenDefinition>,
|
broken: RegisteredInterface<BrokenDefinition>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
root,
|
||||||
reader,
|
reader,
|
||||||
writer,
|
writer,
|
||||||
broken,
|
broken,
|
||||||
@@ -362,7 +373,10 @@ impl ObjectProvider for TestProvider {
|
|||||||
self.observed_cancelled
|
self.observed_cancelled
|
||||||
.set(context.cancellation().is_cancelled());
|
.set(context.cancellation().is_cancelled());
|
||||||
self.resolutions.set(self.resolutions.get() + 1);
|
self.resolutions.set(self.resolutions.get() + 1);
|
||||||
|
let scope_object = Document::open("", None, Rc::clone(&self.calls));
|
||||||
|
let current_scope = ScopeObservation::new(&self.root, &scope_object);
|
||||||
let (document, interfaces) = match path {
|
let (document, interfaces) = match path {
|
||||||
|
"/" => (scope_object, 0),
|
||||||
"/document" => (
|
"/document" => (
|
||||||
Document::strict(
|
Document::strict(
|
||||||
"document",
|
"document",
|
||||||
@@ -420,6 +434,7 @@ impl ObjectProvider for TestProvider {
|
|||||||
3 => publication.expose(&self.broken).unwrap(),
|
3 => publication.expose(&self.broken).unwrap(),
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
};
|
};
|
||||||
|
let publication = publication.bind_scope(current_scope);
|
||||||
ProviderResult::Found(operation(&publication))
|
ProviderResult::Found(operation(&publication))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,13 +476,25 @@ fn registry_and_provider() -> (
|
|||||||
RegisteredInterface<HiddenDefinition>,
|
RegisteredInterface<HiddenDefinition>,
|
||||||
) {
|
) {
|
||||||
let mut registry = InterfaceRegistry::new();
|
let mut registry = InterfaceRegistry::new();
|
||||||
|
let root = Scope::new("/").unwrap();
|
||||||
let reader = registry
|
let reader = registry
|
||||||
.register(ReaderDefinition, Some(Validator::new(b"schema-v1")))
|
.register(
|
||||||
|
&root,
|
||||||
|
"reader",
|
||||||
|
ReaderDefinition,
|
||||||
|
Some(Validator::new(b"schema-v1")),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let writer = registry.register(WriterDefinition, None).unwrap();
|
let writer = registry
|
||||||
let hidden = registry.register(HiddenDefinition, None).unwrap();
|
.register(&root, "writer::#日本語", WriterDefinition, None)
|
||||||
let broken = registry.register(BrokenDefinition, None).unwrap();
|
.unwrap();
|
||||||
let provider = TestProvider::new(reader, writer, broken);
|
let hidden = registry
|
||||||
|
.register(&root, "hidden", HiddenDefinition, None)
|
||||||
|
.unwrap();
|
||||||
|
let broken = registry
|
||||||
|
.register(&root, "broken", BrokenDefinition, None)
|
||||||
|
.unwrap();
|
||||||
|
let provider = TestProvider::new(root, reader, writer, broken);
|
||||||
(registry, provider, hidden)
|
(registry, provider, hidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,8 +532,8 @@ fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor()
|
|||||||
let reference = provider.reader.reference();
|
let reference = provider.reader.reference();
|
||||||
|
|
||||||
assert_ne!(reference.as_str(), "Reader");
|
assert_ne!(reference.as_str(), "Reader");
|
||||||
let observation = registry
|
let observation = provider
|
||||||
.fetch_interface(&reference.protocol_reference())
|
.fetch_interface(®istry, &reference.protocol_reference())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(observation.reference, *reference);
|
assert_eq!(observation.reference, *reference);
|
||||||
assert_eq!(observation.descriptor, ReaderDefinition::descriptor());
|
assert_eq!(observation.descriptor, ReaderDefinition::descriptor());
|
||||||
@@ -518,8 +545,8 @@ fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor()
|
|||||||
assert_eq!(response.validator.as_deref(), Some(b"schema-v1".as_slice()));
|
assert_eq!(response.validator.as_deref(), Some(b"schema-v1".as_slice()));
|
||||||
response.descriptor.validate().unwrap();
|
response.descriptor.validate().unwrap();
|
||||||
|
|
||||||
let mut invalid_observation = registry
|
let mut invalid_observation = provider
|
||||||
.fetch_interface(&reference.protocol_reference())
|
.fetch_interface(®istry, &reference.protocol_reference())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let duplicate_operation = invalid_observation.descriptor.operations[0].clone();
|
let duplicate_operation = invalid_observation.descriptor.operations[0].clone();
|
||||||
invalid_observation
|
invalid_observation
|
||||||
@@ -529,11 +556,14 @@ fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor()
|
|||||||
let invalid_response = invalid_observation.protocol_response().unwrap_err();
|
let invalid_response = invalid_observation.protocol_response().unwrap_err();
|
||||||
assert_eq!(invalid_response.code, ProtocolErrorCode::Internal);
|
assert_eq!(invalid_response.code, ProtocolErrorCode::Internal);
|
||||||
|
|
||||||
let error = registry
|
let error = provider
|
||||||
.fetch_interface(&wip_protocol::InterfaceReference {
|
.fetch_interface(
|
||||||
scope: "/".into(),
|
®istry,
|
||||||
name: "unknown".into(),
|
&wip_protocol::InterfaceReference {
|
||||||
})
|
scope: "/".into(),
|
||||||
|
name: "unknown".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert_eq!(error.code, ProtocolErrorCode::InterfaceNotFound);
|
assert_eq!(error.code, ProtocolErrorCode::InterfaceNotFound);
|
||||||
}
|
}
|
||||||
@@ -1146,14 +1176,13 @@ fn execution_context_is_propagated_and_preflight_prevents_invocation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn legacy_registry_boundaries_copy_names_verbatim_and_match_exact_scope() {
|
fn registry_boundaries_preserve_names_verbatim_and_match_exact_scope() {
|
||||||
let (registry, provider, _) = registry_and_provider();
|
let (registry, provider, _) = registry_and_provider();
|
||||||
let legacy = provider.writer.reference();
|
let legacy = provider.writer.reference();
|
||||||
let reference = legacy.protocol_reference();
|
let reference = legacy.protocol_reference();
|
||||||
assert_eq!(reference.scope, "/");
|
assert_eq!(reference.scope, "/");
|
||||||
assert_eq!(reference.name, legacy.as_str());
|
assert_eq!(reference.name, legacy.as_str());
|
||||||
// The registry's colon-delimited name remains opaque, not parsed.
|
assert_eq!(reference.name, "writer::#日本語");
|
||||||
assert!(reference.name.starts_with("wip-interface:"));
|
|
||||||
assert_eq!(registry.resolve(&reference).as_ref(), Some(legacy));
|
assert_eq!(registry.resolve(&reference).as_ref(), Some(legacy));
|
||||||
let nested = wip_protocol::InterfaceReference {
|
let nested = wip_protocol::InterfaceReference {
|
||||||
scope: "/open".into(),
|
scope: "/open".into(),
|
||||||
@@ -1161,7 +1190,10 @@ fn legacy_registry_boundaries_copy_names_verbatim_and_match_exact_scope() {
|
|||||||
};
|
};
|
||||||
assert!(registry.resolve(&nested).is_none());
|
assert!(registry.resolve(&nested).is_none());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
registry.fetch_interface(&nested).unwrap_err().code,
|
provider
|
||||||
|
.fetch_interface(®istry, &nested)
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
ProtocolErrorCode::InterfaceNotFound
|
ProtocolErrorCode::InterfaceNotFound
|
||||||
);
|
);
|
||||||
let malformed = wip_protocol::InterfaceReference {
|
let malformed = wip_protocol::InterfaceReference {
|
||||||
@@ -1169,7 +1201,10 @@ fn legacy_registry_boundaries_copy_names_verbatim_and_match_exact_scope() {
|
|||||||
name: reference.name,
|
name: reference.name,
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
registry.fetch_interface(&malformed).unwrap_err().code,
|
provider
|
||||||
|
.fetch_interface(®istry, &malformed)
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
ProtocolErrorCode::InvalidRequest
|
ProtocolErrorCode::InvalidRequest
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,560 @@
|
|||||||
|
use std::cell::{Cell, RefCell};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use wip_host::{
|
||||||
|
CallErrorKind, CallRequest, ExecutionContext, InterfaceRegistry, InterfaceTarget,
|
||||||
|
ObjectProvider, ProviderResult, PublishedObject, RegisteredInterface, RegistrationError, Scope,
|
||||||
|
ScopeObservation, Target, Validator, WipObject,
|
||||||
|
};
|
||||||
|
use wip_interface::{OperationResult, interface};
|
||||||
|
use wip_protocol::{InterfaceReference, ProtocolErrorCode, Value};
|
||||||
|
|
||||||
|
#[interface]
|
||||||
|
pub trait Read {
|
||||||
|
fn read(&self, value: String) -> OperationResult<String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[interface]
|
||||||
|
pub trait Count {
|
||||||
|
fn read(&self, value: i64) -> OperationResult<i64>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Metadata {
|
||||||
|
name: String,
|
||||||
|
reference: Option<String>,
|
||||||
|
validator: Option<Validator>,
|
||||||
|
}
|
||||||
|
impl WipObject for Metadata {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
fn reference(&self) -> Option<&str> {
|
||||||
|
self.reference.as_deref()
|
||||||
|
}
|
||||||
|
fn validator(&self) -> Option<&Validator> {
|
||||||
|
self.validator.as_ref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type OnStart = Rc<RefCell<Option<Box<dyn Fn()>>>>;
|
||||||
|
|
||||||
|
struct Implementation {
|
||||||
|
metadata: Metadata,
|
||||||
|
calls: Rc<Cell<usize>>,
|
||||||
|
on_start: OnStart,
|
||||||
|
}
|
||||||
|
impl WipObject for Implementation {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
self.metadata.name()
|
||||||
|
}
|
||||||
|
fn reference(&self) -> Option<&str> {
|
||||||
|
self.metadata.reference()
|
||||||
|
}
|
||||||
|
fn validator(&self) -> Option<&Validator> {
|
||||||
|
self.metadata.validator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Read for Implementation {
|
||||||
|
fn read(&self, value: String) -> OperationResult<String> {
|
||||||
|
self.calls.set(self.calls.get() + 1);
|
||||||
|
if let Some(action) = self.on_start.borrow_mut().take() {
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Provider {
|
||||||
|
interface: RegisteredInterface<ReadDefinition>,
|
||||||
|
current: Rc<RefCell<Option<(Scope, Metadata)>>>,
|
||||||
|
target_path: String,
|
||||||
|
target_exists: Cell<bool>,
|
||||||
|
member: Cell<bool>,
|
||||||
|
calls: Rc<Cell<usize>>,
|
||||||
|
resolutions: Cell<usize>,
|
||||||
|
on_start: OnStart,
|
||||||
|
}
|
||||||
|
impl Provider {
|
||||||
|
fn new(
|
||||||
|
scope: &Scope,
|
||||||
|
interface: RegisteredInterface<ReadDefinition>,
|
||||||
|
target: &str,
|
||||||
|
reference: Option<&str>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
interface,
|
||||||
|
current: Rc::new(RefCell::new(Some((
|
||||||
|
scope.clone(),
|
||||||
|
Metadata {
|
||||||
|
name: scope.path().rsplit('/').next().unwrap().into(),
|
||||||
|
reference: reference.map(str::to_owned),
|
||||||
|
validator: Some(Validator::new(b"scope-state-v1")),
|
||||||
|
},
|
||||||
|
)))),
|
||||||
|
target_path: target.into(),
|
||||||
|
target_exists: Cell::new(true),
|
||||||
|
member: Cell::new(true),
|
||||||
|
calls: Rc::new(Cell::new(0)),
|
||||||
|
resolutions: Cell::new(0),
|
||||||
|
on_start: Rc::new(RefCell::new(None)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn request(&self, scope_ref: Option<&str>) -> CallRequest {
|
||||||
|
CallRequest {
|
||||||
|
target: Target {
|
||||||
|
path: self.target_path.clone(),
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
interface: InterfaceTarget {
|
||||||
|
reference: self.interface.reference().protocol_reference(),
|
||||||
|
scope_ref: scope_ref.map(str::to_owned),
|
||||||
|
validator: Some(b"descriptor-v1".to_vec()),
|
||||||
|
},
|
||||||
|
operation: "read".into(),
|
||||||
|
arguments: BTreeMap::from([("value".into(), Value::String("original backend".into()))]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl ObjectProvider for Provider {
|
||||||
|
type Object = Implementation;
|
||||||
|
fn with_object<R>(
|
||||||
|
&self,
|
||||||
|
_: &ExecutionContext,
|
||||||
|
path: &str,
|
||||||
|
callback: impl FnOnce(&PublishedObject<Self::Object>) -> R,
|
||||||
|
) -> ProviderResult<R> {
|
||||||
|
self.resolutions.set(self.resolutions.get() + 1);
|
||||||
|
// A single boundary captures both the target and the live scope association.
|
||||||
|
let current = self.current.borrow().clone();
|
||||||
|
let target = path == self.target_path;
|
||||||
|
let metadata = if target {
|
||||||
|
if !self.target_exists.get() {
|
||||||
|
return ProviderResult::NotFound;
|
||||||
|
}
|
||||||
|
if let Some((scope, object)) = ¤t
|
||||||
|
&& scope.path() == path
|
||||||
|
{
|
||||||
|
object.clone()
|
||||||
|
} else {
|
||||||
|
Metadata {
|
||||||
|
name: path.rsplit('/').next().unwrap().into(),
|
||||||
|
reference: None,
|
||||||
|
validator: Some(Validator::new(b"target-v1")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some((scope, object)) = ¤t {
|
||||||
|
if path != scope.path() {
|
||||||
|
return ProviderResult::NotFound;
|
||||||
|
}
|
||||||
|
object.clone()
|
||||||
|
} else {
|
||||||
|
return ProviderResult::NotFound;
|
||||||
|
};
|
||||||
|
let mut publication = PublishedObject::new(Implementation {
|
||||||
|
metadata,
|
||||||
|
calls: self.calls.clone(),
|
||||||
|
on_start: self.on_start.clone(),
|
||||||
|
});
|
||||||
|
if target && self.member.get() {
|
||||||
|
publication = publication.expose(&self.interface).unwrap();
|
||||||
|
}
|
||||||
|
if let Some((scope, object)) = current {
|
||||||
|
publication = publication.bind_scope(ScopeObservation::new(&scope, &object));
|
||||||
|
}
|
||||||
|
ProviderResult::Found(callback(&publication))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn setup(
|
||||||
|
scope: &str,
|
||||||
|
target: &str,
|
||||||
|
reference: Option<&str>,
|
||||||
|
) -> (InterfaceRegistry, Scope, Provider) {
|
||||||
|
let scope = Scope::new(scope).unwrap();
|
||||||
|
let mut registry = InterfaceRegistry::new();
|
||||||
|
let interface = registry
|
||||||
|
.register(
|
||||||
|
&scope,
|
||||||
|
"read::#日本語",
|
||||||
|
ReadDefinition,
|
||||||
|
Some(Validator::new(b"descriptor-v1")),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let provider = Provider::new(&scope, interface, target, reference);
|
||||||
|
(registry, scope, provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registrations_use_scope_name_not_definition_type_or_delimiters() {
|
||||||
|
let mut registry = InterfaceRegistry::new();
|
||||||
|
let left = Scope::new("/left").unwrap();
|
||||||
|
let right = Scope::new("/right").unwrap();
|
||||||
|
let a = registry
|
||||||
|
.register(&left, "read::#日本語", ReadDefinition, None)
|
||||||
|
.unwrap();
|
||||||
|
let b = registry
|
||||||
|
.register(&right, "read::#日本語", ReadDefinition, None)
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(a.reference(), b.reference());
|
||||||
|
assert_eq!(
|
||||||
|
a.reference().protocol_reference(),
|
||||||
|
InterfaceReference {
|
||||||
|
scope: "/left".into(),
|
||||||
|
name: "read::#日本語".into(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
registry.register(&left, "read::#日本語", CountDefinition, None),
|
||||||
|
Err(RegistrationError::AlreadyRegistered)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
registry.register(&left, "", ReadDefinition, None),
|
||||||
|
Err(RegistrationError::InvalidReference(_))
|
||||||
|
));
|
||||||
|
assert!(Scope::new("/invalid/").is_err());
|
||||||
|
registry
|
||||||
|
.register(&left, "another", ReadDefinition, None)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn self_root_ancestor_and_membership_are_independent() {
|
||||||
|
for (scope, target) in [
|
||||||
|
("/", "/"),
|
||||||
|
("/", "/target"),
|
||||||
|
("/scope", "/scope"),
|
||||||
|
("/scope", "/scope/target"),
|
||||||
|
] {
|
||||||
|
let (registry, _, provider) = setup(scope, target, Some("scope-id"));
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.call_operation(provider.request(Some("scope-id")))
|
||||||
|
.unwrap()
|
||||||
|
.result,
|
||||||
|
Value::String("original backend".into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.fetch_interface(®istry, &provider.request(None).interface.reference)
|
||||||
|
.unwrap()
|
||||||
|
.scope_ref
|
||||||
|
.as_deref(),
|
||||||
|
Some("scope-id")
|
||||||
|
);
|
||||||
|
provider.member.set(false);
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.call_operation(provider.request(None))
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
CallErrorKind::InterfaceMismatch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Non-self scope Objects need not implement or expose any Interface.
|
||||||
|
let (registry, _, provider) = setup("/scope", "/scope/target", None);
|
||||||
|
assert!(
|
||||||
|
provider
|
||||||
|
.observe_object("/scope")
|
||||||
|
.unwrap()
|
||||||
|
.interfaces
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
provider
|
||||||
|
.fetch_interface(®istry, &provider.request(None).interface.reference)
|
||||||
|
.unwrap();
|
||||||
|
for target in ["/sibling/target", "/scopelong", "/"] {
|
||||||
|
let (_, _, provider) = setup("/scope", target, None);
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.call_operation(provider.request(None))
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
CallErrorKind::InterfaceMismatch
|
||||||
|
);
|
||||||
|
assert_eq!(provider.calls.get(), 0);
|
||||||
|
assert_eq!(
|
||||||
|
provider.observe_object(target).unwrap_err().kind(),
|
||||||
|
CallErrorKind::Internal
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn optional_scope_ref_and_state_validator_have_distinct_responsibilities() {
|
||||||
|
for current in [Some("scope-id"), None, Some("")] {
|
||||||
|
let (registry, _, provider) = setup("/scope", "/scope/target", current);
|
||||||
|
let observation = provider
|
||||||
|
.fetch_interface(®istry, &provider.request(None).interface.reference)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
observation
|
||||||
|
.protocol_response()
|
||||||
|
.unwrap()
|
||||||
|
.scope_ref
|
||||||
|
.as_deref(),
|
||||||
|
current
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
observation.validator,
|
||||||
|
Some(Validator::new(b"descriptor-v1"))
|
||||||
|
);
|
||||||
|
provider.call_operation(provider.request(None)).unwrap();
|
||||||
|
if let Some(reference) = current {
|
||||||
|
provider
|
||||||
|
.call_operation(provider.request(Some(reference)))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let before = provider.calls.get();
|
||||||
|
for supplied in ["other", "missing"] {
|
||||||
|
let mut call = provider.request(Some(supplied));
|
||||||
|
call.interface.validator = None;
|
||||||
|
call.operation = "missing".into();
|
||||||
|
assert_eq!(
|
||||||
|
provider.call_operation(call).unwrap_err().kind(),
|
||||||
|
CallErrorKind::InterfaceMismatch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(provider.calls.get(), before);
|
||||||
|
provider.current.borrow_mut().as_mut().unwrap().1.validator =
|
||||||
|
Some(Validator::new(b"scope-state-v2"));
|
||||||
|
provider.call_operation(provider.request(current)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.fetch_interface(®istry, &provider.request(None).interface.reference)
|
||||||
|
.unwrap(),
|
||||||
|
observation
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_precondition_is_membership_stage_in_both_dispatch_paths() {
|
||||||
|
let (_, _, provider) = setup("/scope", "/scope/target", Some("current"));
|
||||||
|
let mut call = provider.request(Some("stale"));
|
||||||
|
call.target.validator = Some(b"wrong".to_vec());
|
||||||
|
assert_eq!(
|
||||||
|
provider.call_operation(call.clone()).unwrap_err().kind(),
|
||||||
|
CallErrorKind::ValidatorMismatch
|
||||||
|
);
|
||||||
|
provider.target_exists.set(false);
|
||||||
|
assert_eq!(
|
||||||
|
provider.call_operation(call).unwrap_err().kind(),
|
||||||
|
CallErrorKind::NotFound
|
||||||
|
);
|
||||||
|
provider.target_exists.set(true);
|
||||||
|
let call = provider.request(Some("stale"));
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.call_operation_with_context_and_decoder(
|
||||||
|
&ExecutionContext::new(),
|
||||||
|
call.target,
|
||||||
|
call.interface,
|
||||||
|
call.operation,
|
||||||
|
|_| -> Result<BTreeMap<String, Value>, ()> { panic!("decoder must not run") }
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
CallErrorKind::InterfaceMismatch
|
||||||
|
);
|
||||||
|
assert_eq!(provider.calls.get(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deletion_republication_and_movement_never_inherit_retained_artifacts() {
|
||||||
|
let (mut registry, scope, provider) = setup("/scope", "/scope/target", Some("old"));
|
||||||
|
let reference = provider.request(None).interface.reference;
|
||||||
|
provider.fetch_interface(®istry, &reference).unwrap();
|
||||||
|
*provider.current.borrow_mut() = None;
|
||||||
|
provider.target_exists.set(false);
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.fetch_interface(®istry, &reference)
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
ProtocolErrorCode::InterfaceNotFound
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.call_operation(provider.request(None))
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
CallErrorKind::NotFound
|
||||||
|
);
|
||||||
|
provider.target_exists.set(true);
|
||||||
|
// Even the same ref is not proof that a replacement inherits registration.
|
||||||
|
let replacement = Scope::new("/scope").unwrap();
|
||||||
|
*provider.current.borrow_mut() = Some((
|
||||||
|
replacement.clone(),
|
||||||
|
Metadata {
|
||||||
|
name: "scope".into(),
|
||||||
|
reference: Some("old".into()),
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.fetch_interface(®istry, &reference)
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
ProtocolErrorCode::InterfaceNotFound
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.call_operation(provider.request(Some("old")))
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
CallErrorKind::InterfaceMismatch
|
||||||
|
);
|
||||||
|
registry.remove_scope(&scope);
|
||||||
|
assert!(matches!(
|
||||||
|
registry.register(&scope, "new", ReadDefinition, None),
|
||||||
|
Err(RegistrationError::RetiredScope)
|
||||||
|
));
|
||||||
|
let fresh = registry
|
||||||
|
.register(&replacement, reference.name.clone(), ReadDefinition, None)
|
||||||
|
.unwrap();
|
||||||
|
provider.fetch_interface(®istry, &reference).unwrap();
|
||||||
|
// Old target membership still binds the old lifetime, even after new publication.
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.call_operation(provider.request(None))
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
CallErrorKind::InterfaceMismatch
|
||||||
|
);
|
||||||
|
let new_provider = Provider::new(&replacement, fresh, "/scope/target", Some("new"));
|
||||||
|
let mut new_call = new_provider.request(Some("new"));
|
||||||
|
new_call.interface.validator = None;
|
||||||
|
new_provider.call_operation(new_call).unwrap();
|
||||||
|
let moved = Scope::new("/moved").unwrap();
|
||||||
|
new_provider.target_exists.set(false);
|
||||||
|
*new_provider.current.borrow_mut() = Some((
|
||||||
|
moved.clone(),
|
||||||
|
Metadata {
|
||||||
|
name: "moved".into(),
|
||||||
|
reference: Some("new".into()),
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
new_provider
|
||||||
|
.fetch_interface(®istry, &reference)
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
ProtocolErrorCode::InterfaceNotFound
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
new_provider
|
||||||
|
.call_operation(new_provider.request(None))
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
CallErrorKind::NotFound
|
||||||
|
);
|
||||||
|
let moved_interface = registry
|
||||||
|
.register(&moved, reference.name, ReadDefinition, None)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
moved_interface.reference().protocol_reference().scope,
|
||||||
|
"/moved"
|
||||||
|
);
|
||||||
|
assert_eq!(provider.calls.get(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replacement_after_fetch_rejects_old_ref_but_omission_and_refless_are_allowed() {
|
||||||
|
for previous_ref in [Some("old"), None] {
|
||||||
|
let (mut registry, old_scope, old_provider) =
|
||||||
|
setup("/scope", "/scope/target", previous_ref);
|
||||||
|
let observed = old_provider
|
||||||
|
.fetch_interface(®istry, &old_provider.request(None).interface.reference)
|
||||||
|
.unwrap();
|
||||||
|
registry.remove_scope(&old_scope);
|
||||||
|
let new_scope = Scope::new("/scope").unwrap();
|
||||||
|
let token = registry
|
||||||
|
.register(
|
||||||
|
&new_scope,
|
||||||
|
"read::#日本語",
|
||||||
|
ReadDefinition,
|
||||||
|
Some(Validator::new(b"descriptor-v1")),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let new_provider = Provider::new(&new_scope, token, "/scope/target", None);
|
||||||
|
if let Some(reference) = observed.scope_ref {
|
||||||
|
assert_eq!(
|
||||||
|
new_provider
|
||||||
|
.call_operation(new_provider.request(Some(&reference)))
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
CallErrorKind::InterfaceMismatch
|
||||||
|
);
|
||||||
|
assert_eq!(new_provider.calls.get(), 0);
|
||||||
|
}
|
||||||
|
new_provider
|
||||||
|
.call_operation(new_provider.request(None))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn started_execution_retains_target_and_same_descriptor_through_replacement() {
|
||||||
|
let (registry, old_scope, provider) = setup("/scope", "/scope/target", Some("old"));
|
||||||
|
let registry = Rc::new(RefCell::new(registry));
|
||||||
|
let current = provider.current.clone();
|
||||||
|
let replacement = Scope::new("/scope").unwrap();
|
||||||
|
let registry_for_action = registry.clone();
|
||||||
|
let replacement_for_action = replacement.clone();
|
||||||
|
*provider.on_start.borrow_mut() = Some(Box::new(move || {
|
||||||
|
let mut registry = registry_for_action.borrow_mut();
|
||||||
|
registry.remove_scope(&old_scope);
|
||||||
|
registry
|
||||||
|
.register(
|
||||||
|
&replacement_for_action,
|
||||||
|
"read::#日本語",
|
||||||
|
CountDefinition,
|
||||||
|
Some(Validator::new(b"descriptor-v2")),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
*current.borrow_mut() = Some((
|
||||||
|
replacement_for_action.clone(),
|
||||||
|
Metadata {
|
||||||
|
name: "scope".into(),
|
||||||
|
reference: Some("new".into()),
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}));
|
||||||
|
let call = provider.request(Some("old"));
|
||||||
|
let result = provider
|
||||||
|
.call_operation_with_context_and_decoder(
|
||||||
|
&ExecutionContext::new(),
|
||||||
|
call.target,
|
||||||
|
call.interface,
|
||||||
|
call.operation,
|
||||||
|
|descriptor| {
|
||||||
|
assert_eq!(descriptor, &ReadDefinition::descriptor());
|
||||||
|
Ok::<_, ()>(call.arguments)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result.result, Value::String("original backend".into()));
|
||||||
|
assert_eq!(provider.calls.get(), 1);
|
||||||
|
assert_eq!(provider.resolutions.get(), 1);
|
||||||
|
let new = provider
|
||||||
|
.fetch_interface(
|
||||||
|
®istry.borrow(),
|
||||||
|
&provider.request(None).interface.reference,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(new.descriptor, CountDefinition::descriptor());
|
||||||
|
assert_eq!(new.scope_ref.as_deref(), Some("new"));
|
||||||
|
// The old bound target can no longer start a call against the new descriptor.
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.call_operation(provider.request(None))
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
CallErrorKind::InterfaceMismatch
|
||||||
|
);
|
||||||
|
assert_eq!(provider.calls.get(), 1);
|
||||||
|
}
|
||||||
@@ -16,6 +16,6 @@ impl WipObject for Object {
|
|||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let mut registry = InterfaceRegistry::new();
|
let mut registry = InterfaceRegistry::new();
|
||||||
let search = registry.register(SearchDefinition, None).unwrap();
|
let search = registry.register(&wip_host::Scope::new("/").unwrap(), "search", SearchDefinition, None).unwrap();
|
||||||
let _published = PublishedObject::new(Object).expose(&search).unwrap();
|
let _published = PublishedObject::new(Object).expose(&search).unwrap();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ The crate maps the three WIP Core interactions to fixed HTTP routes and strict U
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
wip-http = "0.1.0"
|
wip-http = "0.2.0"
|
||||||
```
|
```
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ The crate re-exports `#[interface]` and `#[derive(WipType)]`, generates Interfac
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
wip-interface = "0.1.0"
|
wip-interface = "0.2.0"
|
||||||
```
|
```
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ This crate defines the canonical Object and Interface Descriptor models, the thr
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
wip-protocol = "0.1.0"
|
wip-protocol = "0.2.0"
|
||||||
```
|
```
|
||||||
|
|
||||||
Interface identity is an exact `InterfaceReference { scope, name }` pair.
|
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
|
target Object path. Fetch responses and interface call targets carry optional
|
||||||
opaque string `scope_ref` metadata, not a third identity field. Descriptors do
|
opaque string `scope_ref` metadata, not a third identity field. Descriptors do
|
||||||
not embed identity. See the repository's [migration guide](../../MIGRATION.md)
|
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.
|
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-host.workspace = true
|
||||||
wip-interface.workspace = true
|
wip-interface.workspace = true
|
||||||
wip-protocol.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
|
//! request-scoped implementation, backend handle, authorization policy, and
|
||||||
//! publication wiring remain host-private. A transport binding would translate
|
//! 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
|
//! 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
|
//! # Breaking migration exercised here
|
||||||
//!
|
//!
|
||||||
//! The canonical API targets operations by `target.path` rather than a mandatory
|
//! The canonical API targets operations by `target.path` rather than a mandatory
|
||||||
//! object identifier. Object `ref` and object/interface validators are separate,
|
//! object identifier. Object `ref` and object/interface validators are separate,
|
||||||
//! optional observation fields. Interface references are structured scope/name values observed
|
//! optional observation fields. This demo registers explicit `catalog`, `audit`
|
||||||
|
//! and `diagnostics` names at the service Object self-scope. A private [`Scope`]
|
||||||
|
//! lifetime and coherent [`ScopeObservation`] bind fetch and call to that Object;
|
||||||
|
//! successful calls carry its optional `scope_ref` identity precondition.
|
||||||
|
//! Interface references are structured scope/name values observed
|
||||||
//! separately from descriptors, and descriptors contain neither a Rust trait
|
//! separately from descriptors, and descriptors contain neither a Rust trait
|
||||||
//! name nor an opaque-reference `name` field. Application records, enums,
|
//! name nor an opaque-reference `name` field. Application records, enums,
|
||||||
//! unions, entry paths, and JSON all encode through the schema-neutral [`Value`]
|
//! unions, entry paths, and JSON all encode through the schema-neutral [`Value`]
|
||||||
@@ -41,8 +48,8 @@ use std::io;
|
|||||||
|
|
||||||
use wip_host::{
|
use wip_host::{
|
||||||
CallErrorKind, CallRequest, ExecutionContext, InterfaceRef, InterfaceRegistry, InterfaceTarget,
|
CallErrorKind, CallRequest, ExecutionContext, InterfaceRef, InterfaceRegistry, InterfaceTarget,
|
||||||
ObjectProvider, ProviderResult, PublishedObject, RegisteredInterface, Target, Validator,
|
ObjectProvider, ProviderResult, PublishedObject, RegisteredInterface, Scope, ScopeObservation,
|
||||||
WipObject,
|
Target, Validator, WipObject,
|
||||||
};
|
};
|
||||||
use wip_interface as wip;
|
use wip_interface as wip;
|
||||||
use wip_interface::WipType;
|
use wip_interface::WipType;
|
||||||
@@ -136,14 +143,20 @@ pub struct DemoReport {
|
|||||||
pub object_validator: Option<Vec<u8>>,
|
pub object_validator: Option<Vec<u8>>,
|
||||||
/// Structured interface references published by the object, in exposure order.
|
/// Structured interface references published by the object, in exposure order.
|
||||||
pub published_interfaces: Vec<InterfaceReference>,
|
pub published_interfaces: Vec<InterfaceReference>,
|
||||||
/// Root-scoped reference assigned to the catalog descriptor.
|
/// Self-scoped reference assigned to the catalog descriptor.
|
||||||
pub catalog_interface_reference: InterfaceReference,
|
pub catalog_interface_reference: InterfaceReference,
|
||||||
/// Root-scoped reference assigned to the audit descriptor.
|
/// Self-scoped reference assigned to the audit descriptor.
|
||||||
pub audit_interface_reference: InterfaceReference,
|
pub audit_interface_reference: InterfaceReference,
|
||||||
/// Descriptor format observed separately for the catalog reference.
|
/// Descriptor format observed separately for the catalog reference.
|
||||||
pub catalog_descriptor_format: String,
|
pub catalog_descriptor_format: String,
|
||||||
/// Descriptor format observed separately for the audit reference.
|
/// Descriptor format observed separately for the audit reference.
|
||||||
pub audit_descriptor_format: String,
|
pub audit_descriptor_format: String,
|
||||||
|
/// Scope Object ref observed together with the catalog Descriptor.
|
||||||
|
pub catalog_scope_ref: Option<String>,
|
||||||
|
/// Scope Object ref observed together with the audit Descriptor.
|
||||||
|
pub audit_scope_ref: Option<String>,
|
||||||
|
/// Membership rejection when the supplied scope Object ref is stale.
|
||||||
|
pub scope_mismatch: ProtocolErrorCode,
|
||||||
/// Typed successful application result decoded from a validated [`Value`].
|
/// Typed successful application result decoded from a validated [`Value`].
|
||||||
pub published: PublishOutcome,
|
pub published: PublishOutcome,
|
||||||
/// Typed application-domain rejection decoded from a validated [`Value`].
|
/// Typed application-domain rejection decoded from a validated [`Value`].
|
||||||
@@ -182,25 +195,34 @@ pub struct DemoReport {
|
|||||||
/// application-value decoding fails unexpectedly.
|
/// application-value decoding fails unexpectedly.
|
||||||
pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
||||||
let mut registry = InterfaceRegistry::new();
|
let mut registry = InterfaceRegistry::new();
|
||||||
let catalog = registry.register(CatalogDefinition, Some(Validator::new(CATALOG_VALIDATOR)))?;
|
let scope = Scope::new(OBJECT_PATH)?;
|
||||||
let audit = registry.register(AuditDefinition, None)?;
|
let catalog = registry.register(
|
||||||
let diagnostics = registry.register(DiagnosticsDefinition, None)?;
|
&scope,
|
||||||
|
"catalog",
|
||||||
|
CatalogDefinition,
|
||||||
|
Some(Validator::new(CATALOG_VALIDATOR)),
|
||||||
|
)?;
|
||||||
|
let audit = registry.register(&scope, "audit", AuditDefinition, None)?;
|
||||||
|
let diagnostics = registry.register(&scope, "diagnostics", DiagnosticsDefinition, None)?;
|
||||||
let provider = ApplicationProvider {
|
let provider = ApplicationProvider {
|
||||||
|
scope,
|
||||||
catalog: catalog.clone(),
|
catalog: catalog.clone(),
|
||||||
audit: audit.clone(),
|
audit: audit.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let object = provider.observe_object(OBJECT_PATH)?;
|
let object = provider.observe_object(OBJECT_PATH)?;
|
||||||
let catalog_response = registry
|
let catalog_response = provider
|
||||||
.fetch_interface(
|
.fetch_interface(
|
||||||
|
®istry,
|
||||||
object
|
object
|
||||||
.interfaces
|
.interfaces
|
||||||
.first()
|
.first()
|
||||||
.ok_or_else(|| io::Error::other("catalog interface was not published"))?,
|
.ok_or_else(|| io::Error::other("catalog interface was not published"))?,
|
||||||
)?
|
)?
|
||||||
.protocol_response()?;
|
.protocol_response()?;
|
||||||
let audit_response = registry
|
let audit_response = provider
|
||||||
.fetch_interface(
|
.fetch_interface(
|
||||||
|
®istry,
|
||||||
object
|
object
|
||||||
.interfaces
|
.interfaces
|
||||||
.get(1)
|
.get(1)
|
||||||
@@ -210,7 +232,7 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
|||||||
|
|
||||||
let published = dispatch_publish(
|
let published = dispatch_publish(
|
||||||
&provider,
|
&provider,
|
||||||
catalog.reference(),
|
&catalog_response,
|
||||||
PublishRequest {
|
PublishRequest {
|
||||||
title: "Public APIs".into(),
|
title: "Public APIs".into(),
|
||||||
body: Some("protocol values in, validated values out".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(
|
let rejected = dispatch_publish(
|
||||||
&provider,
|
&provider,
|
||||||
catalog.reference(),
|
&catalog_response,
|
||||||
PublishRequest {
|
PublishRequest {
|
||||||
title: String::new(),
|
title: String::new(),
|
||||||
body: None,
|
body: None,
|
||||||
@@ -237,7 +259,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
|||||||
&provider,
|
&provider,
|
||||||
request(
|
request(
|
||||||
OBJECT_PATH,
|
OBJECT_PATH,
|
||||||
catalog.reference(),
|
&catalog_response.interface,
|
||||||
|
catalog_response.scope_ref.as_deref(),
|
||||||
Some(CATALOG_VALIDATOR),
|
Some(CATALOG_VALIDATOR),
|
||||||
"status",
|
"status",
|
||||||
BTreeMap::new(),
|
BTreeMap::new(),
|
||||||
@@ -247,7 +270,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
|||||||
&provider,
|
&provider,
|
||||||
request(
|
request(
|
||||||
OBJECT_PATH,
|
OBJECT_PATH,
|
||||||
audit.reference(),
|
&audit_response.interface,
|
||||||
|
audit_response.scope_ref.as_deref(),
|
||||||
None,
|
None,
|
||||||
"status",
|
"status",
|
||||||
BTreeMap::new(),
|
BTreeMap::new(),
|
||||||
@@ -257,7 +281,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
|||||||
let host_error = provider
|
let host_error = provider
|
||||||
.call_operation(request(
|
.call_operation(request(
|
||||||
OBJECT_PATH,
|
OBJECT_PATH,
|
||||||
catalog.reference(),
|
&catalog_response.interface,
|
||||||
|
catalog_response.scope_ref.as_deref(),
|
||||||
Some(CATALOG_VALIDATOR),
|
Some(CATALOG_VALIDATOR),
|
||||||
"backend_status",
|
"backend_status",
|
||||||
BTreeMap::new(),
|
BTreeMap::new(),
|
||||||
@@ -269,7 +294,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
|||||||
let unknown_protocol_failure = provider
|
let unknown_protocol_failure = provider
|
||||||
.call_operation(request(
|
.call_operation(request(
|
||||||
OBJECT_PATH,
|
OBJECT_PATH,
|
||||||
catalog.reference(),
|
&catalog_response.interface,
|
||||||
|
catalog_response.scope_ref.as_deref(),
|
||||||
Some(CATALOG_VALIDATOR),
|
Some(CATALOG_VALIDATOR),
|
||||||
"uncertain_backend_status",
|
"uncertain_backend_status",
|
||||||
BTreeMap::new(),
|
BTreeMap::new(),
|
||||||
@@ -280,7 +306,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
|||||||
let permission_failure = provider
|
let permission_failure = provider
|
||||||
.call_operation(request(
|
.call_operation(request(
|
||||||
OBJECT_PATH,
|
OBJECT_PATH,
|
||||||
catalog.reference(),
|
&catalog_response.interface,
|
||||||
|
catalog_response.scope_ref.as_deref(),
|
||||||
Some(CATALOG_VALIDATOR),
|
Some(CATALOG_VALIDATOR),
|
||||||
"restricted_status",
|
"restricted_status",
|
||||||
BTreeMap::new(),
|
BTreeMap::new(),
|
||||||
@@ -310,7 +337,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
|||||||
let interface_mismatch = provider
|
let interface_mismatch = provider
|
||||||
.call_operation(request(
|
.call_operation(request(
|
||||||
OBJECT_PATH,
|
OBJECT_PATH,
|
||||||
diagnostics.reference(),
|
&diagnostics.reference().protocol_reference(),
|
||||||
|
catalog_response.scope_ref.as_deref(),
|
||||||
None,
|
None,
|
||||||
"diagnostics",
|
"diagnostics",
|
||||||
BTreeMap::new(),
|
BTreeMap::new(),
|
||||||
@@ -319,14 +347,32 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
|||||||
.protocol_error()
|
.protocol_error()
|
||||||
.code;
|
.code;
|
||||||
|
|
||||||
let interface_not_found = registry
|
let interface_not_found = provider
|
||||||
.fetch_interface(&InterfaceReference {
|
.fetch_interface(
|
||||||
scope: "/".into(),
|
®istry,
|
||||||
name: "missing-interface".into(),
|
&InterfaceReference {
|
||||||
})
|
scope: OBJECT_PATH.into(),
|
||||||
|
name: "missing-interface".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
.expect_err("unknown interface references must not resolve")
|
.expect_err("unknown interface references must not resolve")
|
||||||
.code;
|
.code;
|
||||||
|
|
||||||
|
let mut stale_scope = request(
|
||||||
|
OBJECT_PATH,
|
||||||
|
&catalog_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
|
let precedence_failure = provider
|
||||||
.call_operation(CallRequest {
|
.call_operation(CallRequest {
|
||||||
target: Target {
|
target: Target {
|
||||||
@@ -354,6 +400,9 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
|
|||||||
audit_interface_reference: audit.reference().protocol_reference(),
|
audit_interface_reference: audit.reference().protocol_reference(),
|
||||||
catalog_descriptor_format: catalog_response.descriptor.format,
|
catalog_descriptor_format: catalog_response.descriptor.format,
|
||||||
audit_descriptor_format: audit_response.descriptor.format,
|
audit_descriptor_format: audit_response.descriptor.format,
|
||||||
|
catalog_scope_ref: catalog_response.scope_ref,
|
||||||
|
audit_scope_ref: audit_response.scope_ref,
|
||||||
|
scope_mismatch,
|
||||||
published,
|
published,
|
||||||
rejected,
|
rejected,
|
||||||
catalog_status,
|
catalog_status,
|
||||||
@@ -382,13 +431,14 @@ fn ensure_kind(error: &wip_host::CallError, expected: CallErrorKind) -> Result<(
|
|||||||
|
|
||||||
fn dispatch_publish(
|
fn dispatch_publish(
|
||||||
provider: &ApplicationProvider,
|
provider: &ApplicationProvider,
|
||||||
catalog: &InterfaceRef,
|
catalog: &wip_protocol::FetchInterfaceResponse,
|
||||||
request_value: PublishRequest,
|
request_value: PublishRequest,
|
||||||
) -> Result<PublishOutcome, Box<dyn Error>> {
|
) -> Result<PublishOutcome, Box<dyn Error>> {
|
||||||
let encoded_request = request_value.encode()?;
|
let encoded_request = request_value.encode()?;
|
||||||
let result = provider.call_operation(request(
|
let result = provider.call_operation(request(
|
||||||
OBJECT_PATH,
|
OBJECT_PATH,
|
||||||
catalog,
|
&catalog.interface,
|
||||||
|
catalog.scope_ref.as_deref(),
|
||||||
Some(CATALOG_VALIDATOR),
|
Some(CATALOG_VALIDATOR),
|
||||||
"publish",
|
"publish",
|
||||||
BTreeMap::from([("request".into(), encoded_request)]),
|
BTreeMap::from([("request".into(), encoded_request)]),
|
||||||
@@ -406,7 +456,8 @@ fn dispatch_string(
|
|||||||
|
|
||||||
fn request(
|
fn request(
|
||||||
path: &str,
|
path: &str,
|
||||||
interface: &InterfaceRef,
|
interface: &InterfaceReference,
|
||||||
|
scope_ref: Option<&str>,
|
||||||
interface_validator: Option<&[u8]>,
|
interface_validator: Option<&[u8]>,
|
||||||
operation: &str,
|
operation: &str,
|
||||||
arguments: BTreeMap<String, Value>,
|
arguments: BTreeMap<String, Value>,
|
||||||
@@ -417,8 +468,8 @@ fn request(
|
|||||||
validator: Some(OBJECT_VALIDATOR.to_vec()),
|
validator: Some(OBJECT_VALIDATOR.to_vec()),
|
||||||
},
|
},
|
||||||
interface: InterfaceTarget {
|
interface: InterfaceTarget {
|
||||||
scope_ref: None,
|
scope_ref: scope_ref.map(str::to_owned),
|
||||||
reference: interface.protocol_reference(),
|
reference: interface.clone(),
|
||||||
validator: interface_validator.map(<[u8]>::to_vec),
|
validator: interface_validator.map(<[u8]>::to_vec),
|
||||||
},
|
},
|
||||||
operation: operation.into(),
|
operation: operation.into(),
|
||||||
@@ -427,6 +478,7 @@ fn request(
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct ApplicationProvider {
|
struct ApplicationProvider {
|
||||||
|
scope: Scope,
|
||||||
catalog: RegisteredInterface<CatalogDefinition>,
|
catalog: RegisteredInterface<CatalogDefinition>,
|
||||||
audit: RegisteredInterface<AuditDefinition>,
|
audit: RegisteredInterface<AuditDefinition>,
|
||||||
}
|
}
|
||||||
@@ -443,7 +495,12 @@ impl ObjectProvider for ApplicationProvider {
|
|||||||
if path != OBJECT_PATH {
|
if path != OBJECT_PATH {
|
||||||
return ProviderResult::NotFound;
|
return ProviderResult::NotFound;
|
||||||
}
|
}
|
||||||
let publication = PublishedObject::new(ApplicationService::new())
|
// The immutable demo has no mutable backend actors. A real backend
|
||||||
|
// must retain its transaction/lock across association and operation.
|
||||||
|
let service = ApplicationService::new();
|
||||||
|
let current = ScopeObservation::new(&self.scope, &service);
|
||||||
|
let publication = PublishedObject::new(service)
|
||||||
|
.bind_scope(current)
|
||||||
.expose(&self.catalog)
|
.expose(&self.catalog)
|
||||||
.expect("ApplicationService implements Catalog")
|
.expect("ApplicationService implements Catalog")
|
||||||
.expose(&self.audit)
|
.expose(&self.audit)
|
||||||
|
|||||||
@@ -28,10 +28,20 @@ fn public_crates_support_the_complete_qualified_end_to_end_scenario() {
|
|||||||
);
|
);
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
report.catalog_interface_reference, report.audit_interface_reference,
|
report.catalog_interface_reference, report.audit_interface_reference,
|
||||||
"interfaces form an ordered set of distinct opaque references"
|
"interfaces form an ordered set of distinct scope/name references"
|
||||||
);
|
);
|
||||||
|
assert_eq!(report.catalog_interface_reference.scope, "/article-service");
|
||||||
|
assert_eq!(report.catalog_interface_reference.name, "catalog");
|
||||||
|
assert_eq!(report.audit_interface_reference.scope, "/article-service");
|
||||||
|
assert_eq!(report.audit_interface_reference.name, "audit");
|
||||||
assert_eq!(report.catalog_descriptor_format, INTERFACE_FORMAT_V1);
|
assert_eq!(report.catalog_descriptor_format, INTERFACE_FORMAT_V1);
|
||||||
assert_eq!(report.audit_descriptor_format, INTERFACE_FORMAT_V1);
|
assert_eq!(report.audit_descriptor_format, INTERFACE_FORMAT_V1);
|
||||||
|
assert_eq!(
|
||||||
|
report.catalog_scope_ref.as_deref(),
|
||||||
|
Some("article-service-instance")
|
||||||
|
);
|
||||||
|
assert_eq!(report.audit_scope_ref, report.catalog_scope_ref);
|
||||||
|
assert_eq!(report.scope_mismatch, ProtocolErrorCode::InterfaceMismatch);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
report.published,
|
report.published,
|
||||||
PublishOutcome::Published("article-1".into())
|
PublishOutcome::Published("article-1".into())
|
||||||
@@ -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 {
|
fn root_reference(name: &str) -> wip_protocol::InterfaceReference {
|
||||||
wip_protocol::InterfaceReference {
|
wip_protocol::InterfaceReference {
|
||||||
scope: "/".into(),
|
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
@@ -64,6 +64,57 @@ enter `contents` as RFC 4648 base64—for example, `dXBkYXRlZA==` for
|
|||||||
`updated`—then review the target Entry, interface reference, operation, and value
|
`updated`—then review the target Entry, interface reference, operation, and value
|
||||||
on the confirmation screen before pressing Enter again.
|
on the confirmation screen before pressing Enter again.
|
||||||
|
|
||||||
|
## Scoped reference formatting
|
||||||
|
|
||||||
|
`wip_tui::reference::{format_interface_reference, format_qualified_operation}`
|
||||||
|
provides labels for the existing UI from `InterfaceReference` and a separate
|
||||||
|
operation name, following `4.2-text-view.md` reference notation (`6086f3c`).
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wip_protocol::InterfaceReference;
|
||||||
|
use wip_tui::reference::{format_interface_reference, format_qualified_operation};
|
||||||
|
|
||||||
|
let reference = InterfaceReference { scope: "/github".into(), name: "issue#read".into() };
|
||||||
|
assert_eq!(format_interface_reference(&reference)?, "/github::\"issue#read\"");
|
||||||
|
assert_eq!(format_qualified_operation(&reference, "read#raw")?,
|
||||||
|
"/github::\"issue#read\"#\"read#raw\"");
|
||||||
|
# Ok::<(), wip_protocol::ValidationError>(())
|
||||||
|
```
|
||||||
|
|
||||||
|
Both return `Result<String, ValidationError>` and reject invalid reference shape,
|
||||||
|
including relative/omitted scope. Root is explicit `/`. Non-simple components use
|
||||||
|
JSON string quoting with additional DEL/C1, Unicode line/paragraph separator and
|
||||||
|
bidi-control escapes. Input values are borrowed and never normalized, case-folded,
|
||||||
|
URL-encoded or split on delimiters. Ref, validators and `scope_ref` do not enter
|
||||||
|
these labels. The output has no extra outer signature string quoting.
|
||||||
|
|
||||||
|
Keep selection, cache and calls keyed by structured references and operation
|
||||||
|
names, never display text. No parser, scope inference, identity reconstruction,
|
||||||
|
Object/Interface signature renderer or new crate is introduced. Membership,
|
||||||
|
ancestry, authorization and freshness remain runtime concerns. 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
|
## Keyboard bindings
|
||||||
|
|
||||||
All required actions are keyboard-accessible.
|
All required actions are keyboard-accessible.
|
||||||
@@ -96,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
|
interfaces, and freshness. Validators, credentials, Host internals, and backend
|
||||||
paths are deliberately hidden. If current metadata disappears, old text is not
|
paths are deliberately hidden. If current metadata disappears, old text is not
|
||||||
presented as current.
|
presented as current.
|
||||||
- **Interfaces / Operations** identifies every operation by `(interface scope,
|
- **Interfaces / Operations** identifies every operation by structured `(scope,
|
||||||
interface name, operation name)` and explicitly displays `scope=…` and `name=…`.
|
interface name, operation name)`, displaying `scope::interface#operation` with
|
||||||
Structured references remain intact in selection, acquisition, and call state;
|
explicit absolute scope and safely quoted components. Structured references
|
||||||
display labels are never parsed back into references. Descriptor lookahead follows fresh visible Objects, shares the
|
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.
|
client's in-flight/cache bounds, and never retries retained failures implicitly.
|
||||||
The pane shows descriptor documentation, ordered parameters, required/optional
|
The pane shows descriptor documentation, ordered parameters, required/optional
|
||||||
status, and result types.
|
status, and result types.
|
||||||
@@ -126,6 +179,22 @@ Validator mismatch, interface mismatch, `OperationOutcomeUnknown`, undecodable
|
|||||||
post-dispatch responses, and post-dispatch timeout/disconnect are displayed using
|
post-dispatch responses, and post-dispatch timeout/disconnect are displayed using
|
||||||
the runtime's authoritative transition.
|
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
|
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
|
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,
|
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>,
|
pub interfaces: BTreeMap<InterfaceReference, InterfaceView>,
|
||||||
/// Selected row in the flattened interface/operation view.
|
/// Selected row in the flattened interface/operation view.
|
||||||
pub operation_cursor: usize,
|
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.
|
/// Selected recent-history row.
|
||||||
pub history_cursor: usize,
|
pub history_cursor: usize,
|
||||||
/// Active overlay.
|
/// Active overlay.
|
||||||
@@ -310,6 +313,7 @@ impl App {
|
|||||||
focus: Focus::Tree,
|
focus: Focus::Tree,
|
||||||
interfaces: BTreeMap::new(),
|
interfaces: BTreeMap::new(),
|
||||||
operation_cursor: 0,
|
operation_cursor: 0,
|
||||||
|
operation_selection: None,
|
||||||
history_cursor: 0,
|
history_cursor: 0,
|
||||||
modal: Modal::None,
|
modal: Modal::None,
|
||||||
in_flight: BTreeMap::new(),
|
in_flight: BTreeMap::new(),
|
||||||
@@ -362,7 +366,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
InputKey::Enter | InputKey::Character('e') => {
|
InputKey::Enter | InputKey::Character('e') => {
|
||||||
let action = Action::Execute {
|
let action = Action::Execute {
|
||||||
path: self.selected_path.clone(),
|
path: editor.path.clone(),
|
||||||
interface: editor.interface.clone(),
|
interface: editor.interface.clone(),
|
||||||
operation: editor.operation.name.clone(),
|
operation: editor.operation.name.clone(),
|
||||||
arguments,
|
arguments,
|
||||||
@@ -464,17 +468,21 @@ impl App {
|
|||||||
if let Some(row) = rows.get(self.operation_cursor) {
|
if let Some(row) = rows.get(self.operation_cursor) {
|
||||||
if let Some(operation_name) = &row.operation {
|
if let Some(operation_name) = &row.operation {
|
||||||
if let Some(interface) = self.interfaces.get(&row.interface)
|
if let Some(interface) = self.interfaces.get(&row.interface)
|
||||||
|
&& interface.state == ViewState::Fresh
|
||||||
&& let Some(descriptor) = &interface.descriptor
|
&& let Some(descriptor) = &interface.descriptor
|
||||||
&& let Some(operation) = descriptor
|
&& let Some(operation) = descriptor
|
||||||
.operations
|
.operations
|
||||||
.iter()
|
.iter()
|
||||||
.find(|operation| operation.name == *operation_name)
|
.find(|operation| operation.name == *operation_name)
|
||||||
{
|
{
|
||||||
self.modal = Modal::Editor(ArgumentEditor::new(
|
self.modal = Modal::Editor(
|
||||||
row.interface.clone(),
|
ArgumentEditor::new(
|
||||||
descriptor.clone(),
|
row.interface.clone(),
|
||||||
operation.clone(),
|
descriptor.clone(),
|
||||||
));
|
operation.clone(),
|
||||||
|
)
|
||||||
|
.for_target(self.selected_path.clone()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return vec![Action::FetchInterface(row.interface.clone())];
|
return vec![Action::FetchInterface(row.interface.clone())];
|
||||||
@@ -515,6 +523,10 @@ impl App {
|
|||||||
|
|
||||||
/// Records a pending request separately by interaction type.
|
/// Records a pending request separately by interaction type.
|
||||||
pub fn request_started(&mut self, id: u64, kind: InFlightKind) {
|
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 {
|
match &kind {
|
||||||
InFlightKind::Object(path) => {
|
InFlightKind::Object(path) => {
|
||||||
self.ensure_entry(path).state = ViewState::Loading(id);
|
self.ensure_entry(path).state = ViewState::Loading(id);
|
||||||
@@ -683,6 +695,37 @@ impl App {
|
|||||||
rows
|
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 {
|
fn ensure_entry(&mut self, path: &str) -> &mut EntryView {
|
||||||
if !self.entries.contains_key(path) && self.entries.len() >= MAX_PRESENTATION_ENTRIES {
|
if !self.entries.contains_key(path) && self.entries.len() >= MAX_PRESENTATION_ENTRIES {
|
||||||
let candidate = self
|
let candidate = self
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ pub struct InvalidArguments;
|
|||||||
/// Pure, terminal-independent editor built from an operation descriptor.
|
/// Pure, terminal-independent editor built from an operation descriptor.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ArgumentEditor {
|
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.
|
/// Structured interface reference selected for the call.
|
||||||
pub interface: InterfaceReference,
|
pub interface: InterfaceReference,
|
||||||
/// Selected operation declaration.
|
/// Selected operation declaration.
|
||||||
@@ -68,6 +70,7 @@ impl ArgumentEditor {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Self {
|
Self {
|
||||||
|
path: "/".into(),
|
||||||
interface,
|
interface,
|
||||||
operation,
|
operation,
|
||||||
descriptor,
|
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.
|
/// Moves to the preceding field without wrapping.
|
||||||
pub fn previous_field(&mut self) {
|
pub fn previous_field(&mut self) {
|
||||||
self.cursor = self.cursor.saturating_sub(1);
|
self.cursor = self.cursor.saturating_sub(1);
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
//! Presentation and terminal interaction for stateful WIP client sessions.
|
//! 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)]
|
#![deny(missing_docs)]
|
||||||
|
|
||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod editor;
|
pub mod editor;
|
||||||
|
pub mod reference;
|
||||||
pub mod render;
|
pub mod render;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
//! Scoped Interface/Operation labels for the existing TUI.
|
||||||
|
//!
|
||||||
|
//! Follows the reference notation and safety escapes in WIP `4.2-text-view.md`.
|
||||||
|
//! These are display labels, not wire values, cache keys or call targets. Keep
|
||||||
|
//! [`InterfaceReference`] and operation names structured for selection and calls;
|
||||||
|
//! do not split or reparse labels. No Object/Interface signature renderer or
|
||||||
|
//! shorthand input parser is provided. Screen integration and bounded layout
|
||||||
|
//! remain separate from formatting.
|
||||||
|
//!
|
||||||
|
//! Scope is always explicit and canonical absolute. Adapters accepting omitted
|
||||||
|
//! scope must resolve it with an explicit context at their input boundary, never
|
||||||
|
//! default to root, infer from a target path or search ancestors. Membership,
|
||||||
|
//! ancestry and authorization are separate checks, not claims made by a label.
|
||||||
|
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
use wip_protocol::{InterfaceReference, ValidationError};
|
||||||
|
|
||||||
|
/// Formats a complete `scope::interface` label from structured values.
|
||||||
|
///
|
||||||
|
/// Validates reference shape (including canonical absolute scope), then quotes
|
||||||
|
/// components as needed. There is no outer signature JSON string layer. The
|
||||||
|
/// exact input is preserved without normalization, case conversion or URL
|
||||||
|
/// encoding. Ref, validators and `scope_ref` are not inputs and cannot leak into
|
||||||
|
/// the label. This does not validate membership, freshness or authorization.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use wip_protocol::InterfaceReference;
|
||||||
|
/// use wip_tui::reference::format_interface_reference;
|
||||||
|
/// let reference = InterfaceReference {
|
||||||
|
/// scope: "/github::archive".into(), name: "issue#read".into(),
|
||||||
|
/// };
|
||||||
|
/// assert_eq!(format_interface_reference(&reference)?,
|
||||||
|
/// "\"/github::archive\"::\"issue#read\"");
|
||||||
|
/// # Ok::<(), wip_protocol::ValidationError>(())
|
||||||
|
/// ```
|
||||||
|
pub fn format_interface_reference(
|
||||||
|
reference: &InterfaceReference,
|
||||||
|
) -> Result<String, ValidationError> {
|
||||||
|
reference.validate()?;
|
||||||
|
Ok(format!(
|
||||||
|
"{}::{}",
|
||||||
|
component(&reference.scope, true),
|
||||||
|
component(&reference.name, false)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats a complete `scope::interface#operation` display designation.
|
||||||
|
///
|
||||||
|
/// Operation remains a separate exact Unicode name, not an input expression.
|
||||||
|
/// Delimiters inside it are quoted rather than split. This checks reference
|
||||||
|
/// shape, not operation existence, and adds no operation-name restrictions to
|
||||||
|
/// the Protocol. The output is plain text, without ANSI or Markdown; the UI
|
||||||
|
/// remains responsible for bounded screen layout, not identity reconstruction.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use wip_protocol::InterfaceReference;
|
||||||
|
/// use wip_tui::reference::format_qualified_operation;
|
||||||
|
/// let reference = InterfaceReference { scope: "/".into(), name: "issue.read".into() };
|
||||||
|
/// assert_eq!(format_qualified_operation(&reference, "read#raw")?,
|
||||||
|
/// "/::issue.read#\"read#raw\"");
|
||||||
|
/// # Ok::<(), wip_protocol::ValidationError>(())
|
||||||
|
/// ```
|
||||||
|
pub fn format_qualified_operation(
|
||||||
|
reference: &InterfaceReference,
|
||||||
|
operation: &str,
|
||||||
|
) -> Result<String, ValidationError> {
|
||||||
|
let mut output = format_interface_reference(reference)?;
|
||||||
|
output.push('#');
|
||||||
|
output.push_str(&component(operation, false));
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn component(value: &str, scope: bool) -> String {
|
||||||
|
if !value.is_empty()
|
||||||
|
&& value
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b) || (scope && b == b'/'))
|
||||||
|
{
|
||||||
|
return value.to_owned();
|
||||||
|
}
|
||||||
|
let mut output = String::from("\"");
|
||||||
|
for ch in value.chars() {
|
||||||
|
match ch {
|
||||||
|
'\\' => output.push_str("\\\\"),
|
||||||
|
'"' => output.push_str("\\\""),
|
||||||
|
'\u{08}' => output.push_str("\\b"),
|
||||||
|
'\u{0c}' => output.push_str("\\f"),
|
||||||
|
'\n' => output.push_str("\\n"),
|
||||||
|
'\r' => output.push_str("\\r"),
|
||||||
|
'\t' => output.push_str("\\t"),
|
||||||
|
'\u{00}'..='\u{1f}'
|
||||||
|
| '\u{7f}'..='\u{9f}'
|
||||||
|
| '\u{2028}'
|
||||||
|
| '\u{2029}'
|
||||||
|
| '\u{061c}'
|
||||||
|
| '\u{200e}'..='\u{200f}'
|
||||||
|
| '\u{202a}'..='\u{202e}'
|
||||||
|
| '\u{2066}'..='\u{2069}' => {
|
||||||
|
// String's fmt::Write implementation is infallible.
|
||||||
|
write!(output, "\\u{:04x}", ch as u32).expect("String write");
|
||||||
|
}
|
||||||
|
_ => output.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.push('"');
|
||||||
|
output
|
||||||
|
}
|
||||||
+118
-18
@@ -11,11 +11,22 @@ use crate::editor::type_name;
|
|||||||
|
|
||||||
const MAX_RENDER_TEXT: usize = 512;
|
const MAX_RENDER_TEXT: usize = 512;
|
||||||
|
|
||||||
fn interface_label(reference: &wip_protocol::InterfaceReference) -> String {
|
/// Bounded presentation only; invalid references are never repaired or inferred.
|
||||||
format!(
|
pub(crate) fn interface_label(reference: &wip_protocol::InterfaceReference) -> String {
|
||||||
"scope={}, name={}",
|
sanitize(
|
||||||
sanitize(&reference.scope),
|
&crate::reference::format_interface_reference(reference)
|
||||||
sanitize(&reference.name)
|
.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)
|
.find(|operation| operation.name == *name)
|
||||||
});
|
});
|
||||||
let signature = declaration.map_or_else(
|
let signature = declaration.map_or_else(
|
||||||
|| format!(" ({}, {})", interface_label(&row.interface), name),
|
|| format!(" {}", operation_label(&row.interface, name)),
|
||||||
|operation| {
|
|operation| {
|
||||||
let parameters = operation
|
let parameters = operation
|
||||||
.parameters
|
.parameters
|
||||||
@@ -253,9 +264,8 @@ fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) {
|
|||||||
.map(|documentation| format!(" — {}", documentation.summary))
|
.map(|documentation| format!(" — {}", documentation.summary))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
format!(
|
format!(
|
||||||
" ({}, {}) ({parameters}) -> {}{}",
|
" {} ({parameters}) -> {}{}",
|
||||||
interface_label(&row.interface),
|
operation_label(&row.interface, name),
|
||||||
name,
|
|
||||||
type_name(&operation.returns.r#type),
|
type_name(&operation.returns.r#type),
|
||||||
documentation
|
documentation
|
||||||
)
|
)
|
||||||
@@ -361,10 +371,9 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) {
|
|||||||
let area = centered(frame.area(), 86, 78);
|
let area = centered(frame.area(), 86, 78);
|
||||||
frame.render_widget(Clear, area);
|
frame.render_widget(Clear, area);
|
||||||
let mut lines = vec![Line::from(format!(
|
let mut lines = vec![Line::from(format!(
|
||||||
"Target: {} Operation: ({}, {})",
|
"Target: {} Operation: {}",
|
||||||
sanitize(&app.selected_path),
|
sanitize(&editor.path),
|
||||||
interface_label(&editor.interface),
|
operation_label(&editor.interface, &editor.operation.name)
|
||||||
sanitize(&editor.operation.name)
|
|
||||||
))];
|
))];
|
||||||
if editor.fields.is_empty() {
|
if editor.fields.is_empty() {
|
||||||
lines.push(Line::from("(no parameters)"));
|
lines.push(Line::from("(no parameters)"));
|
||||||
@@ -412,9 +421,12 @@ fn render_modal(frame: &mut Frame<'_>, app: &App) {
|
|||||||
.fg(Color::Yellow)
|
.fg(Color::Yellow)
|
||||||
.add_modifier(Modifier::BOLD),
|
.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!("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:"),
|
Line::from("Input values:"),
|
||||||
];
|
];
|
||||||
for (name, value) in arguments {
|
for (name, value) in arguments {
|
||||||
@@ -726,7 +738,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let snapshot = rendered(&app, 140, 40);
|
let snapshot = rendered(&app, 140, 40);
|
||||||
assert!(snapshot.contains("public root description"));
|
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("SUCCESS"));
|
||||||
assert!(snapshot.contains("PROTOCOL ERROR"));
|
assert!(snapshot.contains("PROTOCOL ERROR"));
|
||||||
assert!(snapshot.contains("TRANSPORT FAILURE"));
|
assert!(snapshot.contains("TRANSPORT FAILURE"));
|
||||||
@@ -822,7 +834,7 @@ mod tests {
|
|||||||
app.focus = Focus::Operations;
|
app.focus = Focus::Operations;
|
||||||
app.operation_cursor = app.operation_rows().len() - 1;
|
app.operation_cursor = app.operation_rows().len() - 1;
|
||||||
let snapshot = rendered(&app, 100, 30);
|
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 {
|
for index in 0..20 {
|
||||||
app.push_history(HistoryItem {
|
app.push_history(HistoryItem {
|
||||||
@@ -850,13 +862,101 @@ mod tests {
|
|||||||
scope: "/nested".into(),
|
scope: "/nested".into(),
|
||||||
name: "#::操作".into(),
|
name: "#::操作".into(),
|
||||||
};
|
};
|
||||||
assert_eq!(interface_label(&reference), "scope=/nested, name=#::操作");
|
assert_eq!(interface_label(&reference), "/nested::\"#::操作\"");
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
interface_label(&reference),
|
interface_label(&reference),
|
||||||
interface_label(&root_reference(&reference.name))
|
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]
|
#[test]
|
||||||
fn control_characters_and_large_bytes_are_bounded() {
|
fn control_characters_and_large_bytes_are_bounded() {
|
||||||
assert_eq!(sanitize("safe\u{1b}[31m"), "safe�[31m");
|
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,
|
Action, App, ConnectionState, HistoryItem, HistoryKind, InFlightKind, InputKey, Modal,
|
||||||
};
|
};
|
||||||
use crate::config::{Config, ConfigError};
|
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};
|
use crate::terminal::{TerminalGuard, translate_key};
|
||||||
|
|
||||||
/// Stable transport failure category.
|
/// Stable transport failure category.
|
||||||
@@ -205,6 +205,12 @@ pub struct Runtime<T: Transport> {
|
|||||||
sender: mpsc::Sender<WorkerEvent>,
|
sender: mpsc::Sender<WorkerEvent>,
|
||||||
receiver: mpsc::Receiver<WorkerEvent>,
|
receiver: mpsc::Receiver<WorkerEvent>,
|
||||||
ignored: BTreeSet<u64>,
|
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> {
|
impl<T: Transport> Runtime<T> {
|
||||||
@@ -231,6 +237,7 @@ impl<T: Transport> Runtime<T> {
|
|||||||
sender,
|
sender,
|
||||||
receiver,
|
receiver,
|
||||||
ignored: BTreeSet::new(),
|
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.
|
/// Applies a key through the reducer and starts any resulting effects.
|
||||||
pub fn handle_key(&mut self, key: InputKey) -> bool {
|
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);
|
let actions = self.app.reduce(key);
|
||||||
|
self.capture_editor_context();
|
||||||
let mut quit = false;
|
let mut quit = false;
|
||||||
for action in actions {
|
for action in actions {
|
||||||
quit |= self.perform(action);
|
quit |= self.perform(action);
|
||||||
@@ -387,9 +402,14 @@ impl<T: Transport> Runtime<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn dispatch(&mut self, request: PreparedRequest, kind: InFlightKind, operation: bool) {
|
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;
|
let id = request.id;
|
||||||
self.app.request_started(id.get(), kind.clone());
|
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) {
|
let prepared = match self.transport.prepare(request.request) {
|
||||||
Ok(prepared) => prepared,
|
Ok(prepared) => prepared,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -511,6 +531,11 @@ impl<T: Transport> Runtime<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn finish(&mut self, event: WorkerEvent) {
|
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();
|
let kind = self.app.in_flight.get(&event.id.get()).cloned();
|
||||||
if kind.is_none() {
|
if kind.is_none() {
|
||||||
return;
|
return;
|
||||||
@@ -549,7 +574,7 @@ impl<T: Transport> Runtime<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.reconcile_authoritative();
|
self.reconcile_authoritative_with_selection(selected);
|
||||||
self.reconcile_visible_lookahead();
|
self.reconcile_visible_lookahead();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,9 +605,15 @@ impl<T: Transport> Runtime<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Completion::ProtocolFailure(error) => {
|
Completion::ProtocolFailure(error) => {
|
||||||
|
let subject = match kind {
|
||||||
|
Some(InFlightKind::Interface(reference)) => {
|
||||||
|
format!(" for {}", interface_label(reference))
|
||||||
|
}
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
self.app.push_history(HistoryItem {
|
self.app.push_history(HistoryItem {
|
||||||
kind: HistoryKind::DomainFailure,
|
kind: HistoryKind::DomainFailure,
|
||||||
title: format!("Host {:?}", error.code),
|
title: format!("Host {:?}{subject}", error.code),
|
||||||
detail: sanitize(&error.message),
|
detail: sanitize(&error.message),
|
||||||
entry_target: None,
|
entry_target: None,
|
||||||
});
|
});
|
||||||
@@ -605,11 +636,12 @@ impl<T: Transport> Runtime<T> {
|
|||||||
|
|
||||||
fn push_call_history(&mut self, record: &wip_client::CallRecord) {
|
fn push_call_history(&mut self, record: &wip_client::CallRecord) {
|
||||||
let key = format!(
|
let key = format!(
|
||||||
"(scope={}, name={}, {}) on {}",
|
"{} on {}",
|
||||||
sanitize(&record.context.interface().reference.scope),
|
operation_label(
|
||||||
sanitize(&record.context.interface().reference.name),
|
&record.context.interface().reference,
|
||||||
record.context.operation().name,
|
&record.context.operation().name
|
||||||
record.context.object().path
|
),
|
||||||
|
sanitize(&record.context.object().path)
|
||||||
);
|
);
|
||||||
self.app.status = match &record.outcome {
|
self.app.status = match &record.outcome {
|
||||||
CallOutcome::Success(_) => "operation completed".into(),
|
CallOutcome::Success(_) => "operation completed".into(),
|
||||||
@@ -690,12 +722,14 @@ impl<T: Transport> Runtime<T> {
|
|||||||
let title = match kind {
|
let title = match kind {
|
||||||
Some(InFlightKind::Object(path)) => format!("observe {path}"),
|
Some(InFlightKind::Object(path)) => format!("observe {path}"),
|
||||||
Some(InFlightKind::Tree(path)) => format!("tree {path}"),
|
Some(InFlightKind::Tree(path)) => format!("tree {path}"),
|
||||||
Some(InFlightKind::Interface(reference)) => format!(
|
Some(InFlightKind::Interface(reference)) => {
|
||||||
"interface scope={}, name={}",
|
format!("interface {}", interface_label(reference))
|
||||||
sanitize(&reference.scope),
|
}
|
||||||
sanitize(&reference.name)
|
Some(InFlightKind::Operation {
|
||||||
),
|
interface,
|
||||||
Some(InFlightKind::Operation { operation, .. }) => operation.clone(),
|
operation,
|
||||||
|
..
|
||||||
|
}) => operation_label(interface, operation),
|
||||||
None => "request".into(),
|
None => "request".into(),
|
||||||
};
|
};
|
||||||
self.app.push_history(HistoryItem {
|
self.app.push_history(HistoryItem {
|
||||||
@@ -752,16 +786,87 @@ impl<T: Transport> Runtime<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn reconcile_authoritative(&mut self) {
|
fn reconcile_authoritative(&mut self) {
|
||||||
self.sync_all_objects();
|
let selected = self
|
||||||
let evicted_interfaces = self
|
|
||||||
.app
|
.app
|
||||||
.interfaces
|
.operation_rows()
|
||||||
.keys()
|
.get(self.app.operation_cursor)
|
||||||
.filter(|reference| self.client.interface(&self.session, reference).is_none())
|
.cloned();
|
||||||
.cloned()
|
self.reconcile_authoritative_with_selection(selected);
|
||||||
.collect::<Vec<_>>();
|
}
|
||||||
for reference in evicted_interfaces {
|
|
||||||
self.app.interfaces.remove(&reference);
|
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
|
.entries
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, entry)| {
|
.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())
|
.map(|(path, _)| path.clone())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -930,6 +1037,10 @@ impl Display for RuntimeError {
|
|||||||
|
|
||||||
impl Error for RuntimeError {}
|
impl Error for RuntimeError {}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "runtime_scoped_tests.rs"]
|
||||||
|
mod scoped_tests;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::BTreeMap;
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
use wip_protocol::{InterfaceReference, InterfaceTarget};
|
||||||
|
use wip_tui::reference::{format_interface_reference, format_qualified_operation};
|
||||||
|
|
||||||
|
fn reference(scope: &str, name: &str) -> InterfaceReference {
|
||||||
|
InterfaceReference {
|
||||||
|
scope: scope.into(),
|
||||||
|
name: name.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn specification_reference_examples() {
|
||||||
|
for (scope, name, operation, expected) in [
|
||||||
|
("/github", "issue.read", "read", "/github::issue.read#read"),
|
||||||
|
("/", "issue.read", "read", "/::issue.read#read"),
|
||||||
|
(
|
||||||
|
"/github::archive",
|
||||||
|
"issue.read",
|
||||||
|
"read",
|
||||||
|
"\"/github::archive\"::issue.read#read",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/github",
|
||||||
|
"issue#read",
|
||||||
|
"read",
|
||||||
|
"/github::\"issue#read\"#read",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/github",
|
||||||
|
"issue.read",
|
||||||
|
"read#raw",
|
||||||
|
"/github::issue.read#\"read#raw\"",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/開発",
|
||||||
|
"課題.read",
|
||||||
|
"read",
|
||||||
|
"\"/開発\"::\"課題.read\"#read",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let input = reference(scope, name);
|
||||||
|
input.validate().unwrap();
|
||||||
|
let before = input.clone();
|
||||||
|
assert_eq!(
|
||||||
|
format_qualified_operation(&input, operation).unwrap(),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
assert_eq!(input, before);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn root_self_and_same_name_different_scope_stay_distinct() {
|
||||||
|
let root = reference("/", "read");
|
||||||
|
let parent = reference("/a", "read");
|
||||||
|
let self_scope = reference("/a/b", "read");
|
||||||
|
let labels: Vec<_> = [&root, &parent, &self_scope]
|
||||||
|
.into_iter()
|
||||||
|
.map(|input| {
|
||||||
|
input.validate_for_path("/a/b").unwrap();
|
||||||
|
format_interface_reference(input).unwrap()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(labels, ["/::read", "/a::read", "/a/b::read"]);
|
||||||
|
assert_eq!(
|
||||||
|
format_qualified_operation(&self_scope, "read").unwrap(),
|
||||||
|
"/a/b::read#read"
|
||||||
|
);
|
||||||
|
assert_ne!(root, self_scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn components_not_declaration_identifiers_or_signatures() {
|
||||||
|
// Reference components follow their own ASCII rules, not identifier/keyword
|
||||||
|
// backtick quoting or the outer JSON string quoting used by full signatures.
|
||||||
|
let input = reference("/a-b/c_d.1", "1-name.entry");
|
||||||
|
assert_eq!(
|
||||||
|
format_interface_reference(&input).unwrap(),
|
||||||
|
"/a-b/c_d.1::1-name.entry"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
format_qualified_operation(&input, "operation").unwrap(),
|
||||||
|
"/a-b/c_d.1::1-name.entry#operation"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
format_qualified_operation(&input, "a/b").unwrap(),
|
||||||
|
"/a-b/c_d.1::1-name.entry#\"a/b\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
format_qualified_operation(&input, "").unwrap(),
|
||||||
|
"/a-b/c_d.1::1-name.entry#\"\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
format_interface_reference(&reference("/", "name`tick")).unwrap(),
|
||||||
|
"/::\"name`tick\""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delimiters_quote_backslash_whitespace_and_newline_are_component_data() {
|
||||||
|
let input = reference("/a::b#c\"\\\n", "x::y#z\"\\\n");
|
||||||
|
let before = input.clone();
|
||||||
|
assert_eq!(
|
||||||
|
format_interface_reference(&input).unwrap(),
|
||||||
|
"\"/a::b#c\\\"\\\\\\n\"::\"x::y#z\\\"\\\\\\n\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
format_qualified_operation(&input, "read::raw# \"\\\n\t").unwrap(),
|
||||||
|
"\"/a::b#c\\\"\\\\\\n\"::\"x::y#z\\\"\\\\\\n\"#\"read::raw# \\\"\\\\\\n\\t\""
|
||||||
|
);
|
||||||
|
assert_eq!(input, before);
|
||||||
|
assert_eq!(
|
||||||
|
format_interface_reference(&reference("/space here", " name ")).unwrap(),
|
||||||
|
"\"/space here\"::\" name \""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_required_safety_escape_in_each_component() {
|
||||||
|
let controls = (0..=0x1f)
|
||||||
|
.chain(0x7f..=0x9f)
|
||||||
|
.chain([0x061c, 0x200e, 0x200f, 0x2028, 0x2029])
|
||||||
|
.chain(0x202a..=0x202e)
|
||||||
|
.chain(0x2066..=0x2069);
|
||||||
|
for code in controls {
|
||||||
|
let ch = char::from_u32(code).unwrap();
|
||||||
|
let escape = match code {
|
||||||
|
8 => "\\b".into(),
|
||||||
|
9 => "\\t".into(),
|
||||||
|
10 => "\\n".into(),
|
||||||
|
12 => "\\f".into(),
|
||||||
|
13 => "\\r".into(),
|
||||||
|
_ => format!("\\u{code:04x}"),
|
||||||
|
};
|
||||||
|
let input = reference(&format!("/a{ch}z"), &format!("n{ch}z"));
|
||||||
|
let operation = format!("o{ch}z");
|
||||||
|
let before = (input.clone(), operation.clone());
|
||||||
|
let label = format_qualified_operation(&input, &operation).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
label,
|
||||||
|
format!("\"/a{escape}z\"::\"n{escape}z\"#\"o{escape}z\""),
|
||||||
|
"U+{code:04X}"
|
||||||
|
);
|
||||||
|
assert!(!label.contains(ch), "raw U+{code:04X} leaked");
|
||||||
|
assert_eq!((input, operation), before);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unicode_remains_exact_no_normalization_case_folding_or_url_encoding() {
|
||||||
|
for name in ["é", "e\u{301}", "課題", "λ😀", "%23"] {
|
||||||
|
let input = reference(&format!("/{name}"), name);
|
||||||
|
assert_eq!(
|
||||||
|
format_qualified_operation(&input, name).unwrap(),
|
||||||
|
format!("\"/{name}\"::\"{name}\"#\"{name}\"")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_ne!(
|
||||||
|
format_interface_reference(&reference("/", "é")).unwrap(),
|
||||||
|
format_interface_reference(&reference("/", "e\u{301}")).unwrap()
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
format_interface_reference(&reference("/", "Read")).unwrap(),
|
||||||
|
format_interface_reference(&reference("/", "read")).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_invalid_shape_instead_of_inferring_scope() {
|
||||||
|
for scope in [
|
||||||
|
"",
|
||||||
|
"github",
|
||||||
|
"../github",
|
||||||
|
"/a/../b",
|
||||||
|
"/a/./b",
|
||||||
|
"/a//b",
|
||||||
|
"/a/",
|
||||||
|
] {
|
||||||
|
let input = reference(scope, "read");
|
||||||
|
assert_eq!(
|
||||||
|
format_interface_reference(&input).unwrap_err(),
|
||||||
|
input.validate().unwrap_err()
|
||||||
|
);
|
||||||
|
assert!(format_qualified_operation(&input, "read").is_err());
|
||||||
|
}
|
||||||
|
assert!(format_interface_reference(&reference("/", "")).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_metadata_and_call_identity_stay_separate_from_display() {
|
||||||
|
let mut target = InterfaceTarget {
|
||||||
|
reference: reference("/a", "read#raw"),
|
||||||
|
scope_ref: Some("secret-scope-ref".into()),
|
||||||
|
validator: Some(vec![27, 255]),
|
||||||
|
};
|
||||||
|
let before = target.clone();
|
||||||
|
let label = format_qualified_operation(&target.reference, "run").unwrap();
|
||||||
|
assert_eq!(label, "/a::\"read#raw\"#run");
|
||||||
|
assert_eq!(target, before);
|
||||||
|
target.scope_ref = None;
|
||||||
|
target.validator = None;
|
||||||
|
assert_eq!(
|
||||||
|
format_qualified_operation(&target.reference, "run").unwrap(),
|
||||||
|
label
|
||||||
|
);
|
||||||
|
// Selection/call keys are still exact protocol pairs; labels are disposable.
|
||||||
|
let mut selected = std::collections::BTreeMap::new();
|
||||||
|
selected.insert(target.reference.clone(), "run");
|
||||||
|
assert_eq!(selected.get(&before.reference), Some(&"run"));
|
||||||
|
assert!(!selected.contains_key(&reference("/", "read#raw")));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user