Compare commits

..
Author SHA1 Message Date
Hare 3c3edf4c6f chore: prepare 0.2.0 release 2026-10-09 00:35:06 +09:00
Hare 1cbe03b49e feat: expose typed Text View tokens and plain-text conversion
T-28: generate owned semantic tokens directly from Object/Interface structure, including all escaped data and layout. Preserve render APIs through the same token generation and delimiter-free concatenation; preserve input limits, validation order and errors. Document splitting, semantic roles and destination escaping boundaries.

Validation: cargo fmt --all -- --check; cargo check --workspace --all-targets; cargo test --workspace (312 passing tests/doctests, including 16 Text View consumer tests and 4 Text View/README doctests); nix develop --command cargo clippy --workspace --all-targets -- -D warnings; RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps; RUSTDOCFLAGS="-D warnings" cargo test -p wip-text-view -p wip-protocol --doc; git diff --check. All passed. Existing golden files unchanged; every prior Text View case now verifies token/plain/direct equality or exact error equality. No existing crates depend on wip-text-view; full workspace validation covers adjacent consumers.
2026-10-08 18:27:04 +09:00
Hare 26cba3398f feat: add standalone WIP Text View signatures
Render one Object or paired Interface entity without path/context, lookups or wire-schema changes. Preserve declarations, summaries, optional/unit and empty distinctions; validate protocol semantics and bounded input before rendering. Add five spec/nested goldens and 13 external API tests plus a standalone doctest.

Validation: cargo fmt --all -- --check; cargo check --workspace --all-targets; cargo test --workspace --all-targets; cargo test --workspace --doc; nix develop -c bash (cargo clippy --workspace --all-targets -- -D warnings, RUSTDOCFLAGS='-D warnings' cargo doc --workspace --no-deps, cargo run --package wip-public-api-example); git diff --check. All passed. Default shell lacks clippy; declared Nix dev shell supplied it.
2026-10-08 17:50:16 +09:00
Hare d58809bdbc docs: describe scoped consumer API and wire migration
Document FS lifetime attestation and explicit names, structured TUI identity versus qualified display, optional scope refs and real HTTP conformance coverage. Acceptance suite passes including workspace docs with -D warnings.
2026-10-08 04:03:51 +09:00
Hare 84cb5904df feat(tui): reconcile scoped selection and publication lifetimes
Reuse canonical escaped absolute reference labels; retain structured operation selection across loading and row movement. Freeze editor targets and retire confirmation on Client observation invalidation. Mirror retained stale/loading Object, tree and Interface state without ref fabrication or automatic execution.

Validation: 36 TUI unit tests, real filesystem HTTP workflow, 8 formatter and 6 asynchronous runtime tests, and 2 doc-tests pass. Full workspace fmt/check/all-target tests/doc-tests, Nix clippy -D warnings, rustdoc -D warnings, public example and diff check pass. Scoped regressions cover same-name dispatch, stale/ref-less/replaced/deleted scopes, old in-flight contexts and selective typed-entry observation.
2026-10-08 04:03:51 +09:00
Hare fc52d72a01 test(public-api): exercise scoped lifetimes across real HTTP
Construct example calls from fetched structured identity and scope_ref. Add exhaustive public DTO coverage and a transactional external-style provider over TCP to exercise root/self scopes, equal names in distinct scopes, optional and stale refs, deletion/republication, visibility, validator precedence and non-retry of unknown outcomes.

Validation: cargo test -p wip-public-api-example (9 DTO/example tests, 4 real TCP conformance tests); Nix clippy --all-targets -D warnings.
2026-10-08 03:56:24 +09:00
Hare a7186173b4 feat(fs): bind external scopes to current execution lineage
Add fail-closed Composition lifetime attestation for non-root scopes; preserve pinned deployment-root semantics and principal visibility. Fetch and dispatch bind optional published scope refs inside the refreshed backend boundary.

Validation: cargo test -p wip-fs-server (25 unit, 8 HTTP, 31 extension tests and 1 doc-test); Nix clippy for FS and public example with -D warnings; git diff --check.
2026-10-08 03:56:17 +09:00
Hare 827bef8478 feat: integrate approved client scope lifetimes 2026-10-08 03:38:00 +09:00
Hare b2be41d540 fix: retain observed scope bindings through client refresh states
Address MR review sequence 5: retain last accepted Object-ref evidence during loading, ordinary staleness and retrieval failure. Mark confirmed path unavailability separately within bounded Entry records so old lifetime evidence cannot contaminate republished ref-less descriptors. Compare retained Object refs on later replacement even when newer Interface ownership previously suppressed an older scope observation.

Add accepted-descriptor and pending-first-fetch old-to-new/ref-loss matrices during Loading/Error, edge-stale evidence, and confirmed-deletion/republication/reset tracking regressions. Validation: 50 runtime + 13 adapter tests; narrow Protocol/HTTP; workspace check/tests/all-target tests; Nix Clippy -D warnings; warning-free rustdoc; fmt/diff checks.
2026-10-08 03:29:35 +09:00
Hare cc0ec0ccc8 feat: integrate approved Host scope lifetimes 2026-10-08 03:28:07 +09:00
Hare fe6dccda83 fix: propagate scope lifetime evidence across client observations
Address MR review sequence 3: share accepted Scope Object binding evidence across Interface names, supersede older fetches on binding change/loss, and track separately observed Object refs even when Descriptor scope_ref is omitted. Private evidence stays bounded to Interface records and never replaces optional wire/call metadata; corroborated same-lifetime immutability remains enforced.

Add six regressions covering sibling name updates/ref loss, first-fetch stale responses, omitted scope_ref with Object-ref replacement/loss, out-of-order evidence, eviction, immutable in-flight results and same-lifetime metadata changes. Validation: 47 runtime + 13 adapter tests; narrow Protocol/HTTP; workspace check/tests/all-target tests; Nix Clippy -D warnings; warning-free docs; fmt/diff checks.
2026-10-08 03:16:58 +09:00
Hare 8b8041cba1 feat: bind client interface cache to scope lifetimes
Retire lost scope bindings and confirmed deleted scope caches without confusing edge omission or local eviction with deletion. Preserve immutable call snapshots, same-lifetime Descriptor checks, and newer fetch ownership while recovering both observations after InterfaceMismatch.

Validation: 41 runtime + 13 adapter tests; client/protocol/HTTP tests; workspace check, tests and all-target tests; Nix workspace Clippy -D warnings; warning-free workspace rustdoc; format and diff checks.
2026-10-08 02:59:59 +09:00
Hare 4544bc0d8b feat: add scoped reference formatters for the TUI
Follow Text View reference notation with component quoting and complete JSON/control/bidi safety escapes. Keep protocol reference pairs and operation names separate from display labels; screen integration remains T-23.

Validated: cargo fmt --all --check; cargo check -p wip-tui --all-targets; cargo test -p wip-tui -p wip-protocol -p wip-client (99 tests incl. doctests); nix develop --command cargo clippy -p wip-tui --all-targets -- -D warnings; RUSTDOCFLAGS=-Dwarnings cargo doc -p wip-tui --no-deps; git diff --check.
2026-10-08 02:56:26 +09:00
45 changed files with 6625 additions and 193 deletions
+19 -3
View File
@@ -4,6 +4,19 @@ 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
@@ -12,9 +25,12 @@ All notable changes to the published WIP Rust crates are documented here.
local names, with coherent provider-attested scope Object binding for fetch and local names, with coherent provider-attested scope Object binding for fetch and
dispatch. Supplied scope_ref is checked at membership precedence; scope deletion dispatch. Supplied scope_ref is checked at membership precedence; scope deletion
retires retained tokens, while ordinary Object state updates preserve lifetime. retires retained tokens, while ordinary Object state updates preserve lifetime.
- Migrates FS root publication and public-example self-scopes to the Host API, - Integrates scoped references across filesystem and public consumers and the TUI.
including coherent backend observations and explicit built-in names. Non-root External FS scopes bind to visible current connected lineage, not retained
FS semantics and Client cache recovery remain follow-up work. See 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. [MIGRATION.md](MIGRATION.md) for API/wire examples and provider responsibilities.
## 0.1.0 ## 0.1.0
Generated
+18 -9
View File
@@ -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
View File
@@ -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" }
+32 -16
View File
@@ -1,6 +1,6 @@
# Structured Interface reference and scope publication 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.
Host now enforces scope publication lifetimes and optional scope-ref identity Host enforces scope publication lifetimes and optional scope-ref identity
inside the provider boundary (T-21). Client cache-recovery semantics remain inside the provider boundary. Client caches keep the complete structured reference
follow-up work (T-22); do not mistake registry artifact retention for publication. 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
@@ -176,17 +178,31 @@ the latter also validates compatible live scope associations and response shape.
## Downstream status ## Downstream status
The filesystem consumer registers `file.read`, `symlink.inspect` and `file.write` The filesystem default registers local names `file.read`, `symlink.inspect` and
at its deployment-pinned root Scope. It binds that lifetime from the refreshed `file.write` at its deployment-pinned root Scope, displayed as `/::file.read` etc.
retained root lineage inside the backend execution boundary for calls, generic External compositions choose explicit Scope/local names and implement
Object observations and fetch. A textual Linux root rename/replacement does not `Composition::scope` to attest a non-root lifetime from the exact refreshed backend
substitute the pinned root Object. The root has no Object ref, so fetch omits entry in its connected lineage. The engine never independently resolves a scope
scope_ref; the private lifetime check still applies. Non-root FS registrations path during dispatch. Default composition remains root-scoped; exposing non-root
fail closed pending broader FS/TUI semantics integration (T-23). registrations requires the application's current lifetime attestation, not merely
an artifact or matching ref/validator. All bindings are formed inside backend
execution, with current principal lineage visibility and publication metadata.
Retire/recreate Scope handles on deletion, replacement or movement; a pinned leaf
alone is not evidence that a self-scope Object remains published at that path.
The Linux deployment root remains pinned across textual root rename/replacement,
and its ref-less default publication continues to omit scope_ref.
The public API example self-scopes `catalog`, `audit` and `diagnostics` at The public API example self-scopes `catalog`, `audit` and `diagnostics` at
`/article-service`, fetches through its provider, and carries the observed scope `/article-service`, fetches through its provider, and constructs call metadata
ref in calls. Client keys use structured values; scope deletion/replacement from the fetched structured reference and scope_ref. External-style conformance
cache recovery remains T-22. This Host change does not modify TUI formatting; tests also cross real TCP HTTP for root/self-scopes, same names in different
canonical Text View work is tracked separately in T-24. Interface authoring/macros remain scopes, optional refs, stale refs, deletion/republication and validator precedence.
descriptor-only and need no identity fields or declaration changes.
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.
+23 -4
View File
@@ -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
+8 -5
View File
@@ -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.
+20 -2
View File
@@ -6,12 +6,30 @@ 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.
Interface acquisition and call APIs use `wip_protocol::InterfaceReference { scope, name }` directly. Interface caches and Object membership compare the complete structured value, so equal names in different scopes remain distinct. Interface observations retain the optional `scope_ref`, and calls copy it into `InterfaceTarget` unchanged. Scope-identity recovery is not implemented by this mechanical migration. ## Interface identity and scope lifetime
Interface acquisition and call APIs use `wip_protocol::InterfaceReference { scope, name }` directly. Within an endpoint/security-context session, the complete scoped pair is the cache key and the Object membership key: equal names in different scopes remain distinct. A scope is a canonical absolute Worldspace path and must be an ancestor of, or equal to, the call target path. Root scope (`/`) and self scope are valid; clients must not rewrite either into a different reference.
`InterfaceObservation::scope_ref` is optional opaque metadata identifying the scope Object, not a third reference component, a path, or an Interface Descriptor validator. Its absence does not prevent acquisition or calls. Calls copy the exact observed reference, optional `scope_ref`, and optional Interface Descriptor validator into `InterfaceTarget`; the target Object validator is copied separately. The `CallContext` fixes those observations, the descriptor, and the operation declaration before dispatch.
A descriptor without an Interface Descriptor validator is immutable only within the lifetime of its scope Object, not forever at that path. Descriptors with validators are also bound to that scope lifetime. When accepted observations establish that a known scope binding has changed or been lost, dependent cached interface state is invalidated and cannot authorize a new call until explicitly reacquired. A superseded interface response must not restore an observation from the old lifetime. A previously prepared call retains its original context rather than silently adopting a new scope identity or descriptor.
Scope binding evidence is shared across Interface names at the same scope path: a new binding learned by fetching one name retires older descriptors for the other names too. Separately observed Object refs also provide lifetime evidence even when a descriptor's `scope_ref` was omitted. This corroboration is private bounded cache state, not an invented identity, and never fills in missing call metadata. Same-lifetime descriptor validation still applies; a detected ref change or loss retires the old lifetime instead.
Object deletion, entry-edge removal, and local cache eviction are different events. A direct observe `NotFound` confirms unavailability at the requested scope path and removes cached interfaces scoped to that path; it does not guarantee identity-wide Object deletion or unavailability through every alias. Removing one entry edge likewise does not prove that the Object was deleted or invalidate every remaining alias. Cache eviction is local loss of retained knowledge, not evidence of Host deletion. Losing a known binding at an interface's scope path nevertheless ends safe reuse of descriptors dependent on that binding, even if the same Object remains reachable elsewhere. Optional `scope_ref` can provide identity evidence, but clients do not fabricate it when it is absent.
Without Object `ref` or `scope_ref` identity evidence, a replacement at the same scope path can be indistinguishable from the previous Object. The runtime cannot detect every ref-less replacement from path and descriptor contents alone; absence of detectable change is not proof that the scope lifetime continued.
## Explicit recovery and operation outcomes
`ensure_observed` and `ensure_interface` avoid duplicate fresh or loading work; they do not automatically refresh retained stale or failed observations. Use the explicit object/children refresh APIs and `prepare_interface` to recover the observations required for a new call. An operation `InterfaceMismatch` invalidates both sides of the call's assumptions: it marks the target Object observation stale and invalidates the selected cached interface observation, so recovery requires reobserving membership and reacquiring the descriptor. Reobserving an Object and reacquiring its interface are retrievals, not a retry of an operation.
The runtime never automatically retries an operation. Mark a call dispatched when handing it to the transport and report completion or transport failure with its original `RequestId`. A timeout, disconnect, or invalid response after dispatch can leave the outcome unknown: execution or commit cannot be ruled out. Refreshing stale state does not resolve that uncertainty and must not automatically resubmit the call; any new operation is an explicit application decision.
See the [API documentation](https://docs.rs/wip-client) for session, acquisition, cache, and call APIs. See the [API documentation](https://docs.rs/wip-client) for session, acquisition, cache, and call APIs.
+197 -11
View File
@@ -223,14 +223,20 @@ pub struct TreeObservation {
pub state: ObservationState, pub state: ObservationState,
} }
/// Cached interface descriptor observation. /// Cached interface descriptor observation for one structured `(scope, name)` pair.
///
/// A known lost scope binding retires the descriptor rather than replacing only
/// its [`Self::scope_ref`]. A direct scope `observe` returning `NotFound` may remove
/// this cache entry; edge omission and local Object eviction do not prove deletion.
/// Prepared calls retain their own snapshot until result validation completes.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct InterfaceObservation { pub struct InterfaceObservation {
/// Structured interface reference. /// Structured interface reference.
pub reference: InterfaceReference, pub reference: InterfaceReference,
/// Last accepted descriptor, if one has been received. /// Last accepted descriptor, if one has been received.
pub descriptor: Option<InterfaceDescriptor>, pub descriptor: Option<InterfaceDescriptor>,
/// Optional interface validator. Absence makes the reference immutable. /// Optional interface validator. Absence makes the descriptor immutable only
/// while the scope binding remains valid.
pub validator: Option<Vec<u8>>, pub validator: Option<Vec<u8>>,
/// Optional opaque identity of the interface scope Object. /// Optional opaque identity of the interface scope Object.
pub scope_ref: Option<String>, pub scope_ref: Option<String>,
@@ -245,6 +251,7 @@ pub struct CallContext {
object_key: u64, object_key: u64,
object: ObjectObservation, object: ObjectObservation,
interface: InterfaceObservation, interface: InterfaceObservation,
interface_revision: RequestId,
descriptor: InterfaceDescriptor, descriptor: InterfaceDescriptor,
operation: OperationDeclaration, operation: OperationDeclaration,
request: CallOperationRequest, request: CallOperationRequest,
@@ -390,6 +397,9 @@ struct EntryRecord {
object_key: Option<u64>, object_key: Option<u64>,
revision: RequestId, revision: RequestId,
state: ObservationState, state: ObservationState,
// Retrieval state can change without losing the last accepted binding.
// Only confirmed unavailability ends that path's lifetime evidence.
confirmed_missing: bool,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -416,9 +426,28 @@ struct Session {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct InterfaceRecord { struct InterfaceRecord {
observation: InterfaceObservation, observation: InterfaceObservation,
// Ownership of this exact fetch/observation, not a Protocol identity.
revision: RequestId,
// Starting a refresh does not rebind its retained Descriptor. Scope evidence
// must still be able to retire that old lifetime while the refresh loads.
accepted_revision: RequestId,
// Scope evidence is shared across names and can also come from Observe.
// This private corroboration is never substituted for wire scope_ref and is
// retained only with the bounded Interface record, not as a new identity/key.
scope_binding: Option<String>,
binding_revision: RequestId,
touched: u64, touched: u64,
} }
impl InterfaceRecord {
fn retire(&mut self, revision: RequestId) {
self.revision = self.revision.max(revision);
self.observation.descriptor = None;
self.observation.validator = None;
self.observation.state = ObservationState::Stale;
}
}
impl Session { impl Session {
fn new() -> Self { fn new() -> Self {
Self { Self {
@@ -596,7 +625,8 @@ impl Client {
self.sessions.get(session)?.trees.get(path) self.sessions.get(session)?.trees.get(path)
} }
/// Returns the current interface observation. /// Returns the current interface observation, isolated by session and the
/// complete structured reference (never by display text or serialized JSON).
#[must_use] #[must_use]
pub fn interface( pub fn interface(
&self, &self,
@@ -724,6 +754,7 @@ impl Client {
object_key: None, object_key: None,
revision: id, revision: id,
state: ObservationState::Loading(id), state: ObservationState::Loading(id),
confirmed_missing: false,
}); });
entry.revision = id; entry.revision = id;
entry.state = ObservationState::Loading(id); entry.state = ObservationState::Loading(id);
@@ -802,6 +833,8 @@ impl Client {
let maximum = self.limits.interfaces_per_session; let maximum = self.limits.interfaces_per_session;
let state = self.session_mut(session)?; let state = self.session_mut(session)?;
ensure_interface_slot(state, &request.interface, maximum)?; ensure_interface_slot(state, &request.interface, maximum)?;
let (scope_binding, binding_revision) =
latest_scope_binding(state, &request.interface.scope);
let touched = state.tick(); let touched = state.tick();
let record = state let record = state
.interfaces .interfaces
@@ -814,8 +847,13 @@ impl Client {
validator: None, validator: None,
state: ObservationState::Loading(id), state: ObservationState::Loading(id),
}, },
revision: id,
accepted_revision: RequestId(0),
scope_binding,
binding_revision,
touched, touched,
}); });
record.revision = id;
record.observation.state = ObservationState::Loading(id); record.observation.state = ObservationState::Loading(id);
record.touched = touched; record.touched = touched;
self.pending.insert( self.pending.insert(
@@ -833,8 +871,11 @@ impl Client {
/// Constructs an operation from fresh object and interface observations. /// Constructs an operation from fresh object and interface observations.
/// ///
/// Membership, both validators, the descriptor, and the operation declaration /// Membership, scope binding, both validators, the descriptor, and the operation
/// are fixed in the returned request's immutable [`CallContext`]. /// declaration are fixed in the returned request's immutable [`CallContext`].
/// The descriptor's optional `scope_ref` is copied unchanged, never substituted
/// from a separate Object observation. No ancestor fetch is required. Without
/// a scope ref, same-path replacement cannot always be detected.
pub fn prepare_call( pub fn prepare_call(
&mut self, &mut self,
session: &SessionId, session: &SessionId,
@@ -885,6 +926,7 @@ impl Client {
"interface observation is not fresh", "interface observation is not fresh",
)); ));
} }
let interface_revision = state.interfaces[interface].revision;
let descriptor = interface_observation let descriptor = interface_observation
.descriptor .descriptor
.clone() .clone()
@@ -921,6 +963,7 @@ impl Client {
object_key, object_key,
object: object_observation, object: object_observation,
interface: interface_observation, interface: interface_observation,
interface_revision,
descriptor, descriptor,
operation: operation_declaration, operation: operation_declaration,
request, request,
@@ -1138,6 +1181,11 @@ impl Client {
error.to_string(), error.to_string(),
)) ))
}; };
if error.code == ProtocolErrorCode::NotFound {
let state = self.session_mut(session)?;
retire_scope_subtree(state, &request.path, id);
mark_subtree_stale(state, &request.path, id);
}
set_observe_subject_state( set_observe_subject_state(
self.session_mut(session)?, self.session_mut(session)?,
&request, &request,
@@ -1165,8 +1213,37 @@ impl Client {
match decode_fetch_interface_response(&request, &response, self.wire_limits) { match decode_fetch_interface_response(&request, &response, self.wire_limits) {
Ok(DecodedResponse::Success(value)) => { Ok(DecodedResponse::Success(value)) => {
let state = self.session_mut(session)?; let state = self.session_mut(session)?;
let existing = &state.interfaces[&request.interface].observation; // Newer scope evidence (Observe or another Interface name) can
// supersede even the first fetch for this pair.
let (scope_binding, binding_revision) =
latest_scope_binding(state, &request.interface.scope);
if binding_revision > id
&& value.scope_ref.is_some()
&& value.scope_ref != scope_binding
{
state
.interfaces
.get_mut(&request.interface)
.expect("interface exists")
.retire(binding_revision);
return self.reject_stale(session, id);
}
let existing_record = &state.interfaces[&request.interface];
let existing = &existing_record.observation;
let lost_wire_binding = existing.scope_ref.is_some() && value.scope_ref.is_none();
// Omission stays omission in the public observation and call. A
// known Object/sibling binding can still supply private lifetime
// evidence unless this response explicitly lost its prior ref.
let current_binding = if lost_wire_binding || value.scope_ref.is_some() {
value.scope_ref.clone()
} else {
scope_binding
};
let same_lifetime = existing.scope_ref == value.scope_ref
|| (current_binding.is_some()
&& existing_record.scope_binding == current_binding);
if let Some(descriptor) = &existing.descriptor if let Some(descriptor) = &existing.descriptor
&& same_lifetime
&& ((existing.validator.is_none() && ((existing.validator.is_none()
&& (value.validator.is_some() || descriptor != &value.descriptor)) && (value.validator.is_some() || descriptor != &value.descriptor))
|| (existing.validator.is_some() || (existing.validator.is_some()
@@ -1186,6 +1263,14 @@ impl Client {
self.push_failure_diagnostic(session, id, &error)?; self.push_failure_diagnostic(session, id, &error)?;
return Err(error); return Err(error);
} }
update_scope_binding(
state,
&request.interface.scope,
current_binding.clone(),
id,
false,
Some(&request.interface),
);
let touched = state.tick(); let touched = state.tick();
let observation = InterfaceObservation { let observation = InterfaceObservation {
reference: value.interface, reference: value.interface,
@@ -1199,6 +1284,9 @@ impl Client {
.get_mut(&request.interface) .get_mut(&request.interface)
.expect("interface exists"); .expect("interface exists");
record.observation = observation.clone(); record.observation = observation.clone();
record.accepted_revision = id;
record.scope_binding = current_binding;
record.binding_revision = id;
record.touched = touched; record.touched = touched;
Ok(Completion::Interface(observation)) Ok(Completion::Interface(observation))
} }
@@ -1285,14 +1373,28 @@ impl Client {
match error.code { match error.code {
ProtocolErrorCode::ValidatorRequired ProtocolErrorCode::ValidatorRequired
| ProtocolErrorCode::ValidatorMismatch | ProtocolErrorCode::ValidatorMismatch
| ProtocolErrorCode::InterfaceMismatch => { | ProtocolErrorCode::NotFound => {
state.mark_object_stale(context.object_key, id); state.mark_object_stale(context.object_key, id);
} }
ProtocolErrorCode::InterfaceMismatch => {
state.mark_object_stale(context.object_key, id);
if let Some(interface) =
state.interfaces.get_mut(&context.interface.reference)
&& interface.revision == context.interface_revision
{
// The call cannot reuse this observation, but mismatch
// alone does not prove the scope lifetime ended. Retain
// its Descriptor for same-binding consistency checks.
interface.revision = interface.revision.max(id);
interface.observation.state = ObservationState::Stale;
}
}
ProtocolErrorCode::InterfaceValidatorRequired ProtocolErrorCode::InterfaceValidatorRequired
| ProtocolErrorCode::InterfaceValidatorMismatch => { | ProtocolErrorCode::InterfaceValidatorMismatch => {
if let Some(interface) = state if let Some(interface) = state
.interfaces .interfaces
.get_mut(&context.request.interface.reference) .get_mut(&context.request.interface.reference)
&& interface.revision == context.interface_revision
{ {
interface.observation.state = ObservationState::Stale; interface.observation.state = ObservationState::Stale;
} }
@@ -1762,6 +1864,12 @@ fn insert_object(
let existing_path_key = existing_path_entry let existing_path_key = existing_path_entry
.as_ref() .as_ref()
.and_then(|entry| entry.object_key); .and_then(|entry| entry.object_key);
let previous_scope_ref = existing_path_entry
.as_ref()
.filter(|entry| !entry.confirmed_missing)
.and_then(|entry| entry.object_key)
.and_then(|key| state.objects.get(&key))
.and_then(|record| record.object.r#ref.clone());
let replaceable_key = existing_path_key.filter(|key| { let replaceable_key = existing_path_key.filter(|key| {
state state
.objects .objects
@@ -1839,6 +1947,7 @@ fn insert_object(
object_key: Some(key), object_key: Some(key),
revision: ownership_revision, revision: ownership_revision,
state: ObservationState::Stale, state: ObservationState::Stale,
confirmed_missing: false,
}, },
} }
} else { } else {
@@ -1846,9 +1955,15 @@ fn insert_object(
object_key: Some(key), object_key: Some(key),
revision, revision,
state: ObservationState::Fresh, state: ObservationState::Fresh,
confirmed_missing: false,
} }
}; };
state.entries.insert(path.to_owned(), entry); state.entries.insert(path.to_owned(), entry);
if !suppressed {
let scope_ref = state.objects[&key].object.r#ref.clone();
let changed = previous_scope_ref.is_some() && previous_scope_ref != scope_ref;
update_scope_binding(state, path, scope_ref, revision, changed, None);
}
Ok(key) Ok(key)
} }
@@ -1997,15 +2112,86 @@ fn reserve_observation_metadata(
Ok(()) Ok(())
} }
fn mark_subtree_stale(state: &mut Session, root: &str, request_id: RequestId) { // Only retained observations contribute scope evidence. No unbounded scope
let prefix = format!("{root}/"); // tombstones or synthetic identity are needed: evidence is kept with bounded
// Interface records, survives Object eviction, and disappears with cache cleanup.
fn latest_scope_binding(state: &Session, scope: &str) -> (Option<String>, RequestId) {
let object_binding = state
.entries
.get(scope)
.filter(|entry| !entry.confirmed_missing)
.and_then(|entry| entry.object_key)
.and_then(|key| state.objects.get(&key))
.map(|record| (record.object.r#ref.clone(), record.revision));
state
.interfaces
.iter()
.filter(|(reference, _)| reference.scope == scope)
.map(|(_, record)| (record.scope_binding.clone(), record.binding_revision))
.chain(object_binding)
.max_by_key(|(_, revision)| *revision)
.unwrap_or((None, RequestId(0)))
}
fn update_scope_binding(
state: &mut Session,
scope: &str,
scope_ref: Option<String>,
revision: RequestId,
confirmed_change: bool,
except: Option<&InterfaceReference>,
) {
for (reference, interface) in &mut state.interfaces {
if reference.scope != scope
|| except == Some(reference)
|| interface.accepted_revision > revision
|| interface.binding_revision > revision
{
continue;
}
if confirmed_change
|| (interface.scope_binding.is_some() && interface.scope_binding != scope_ref)
{
interface.retire(revision);
}
// Corroboration is not wire metadata: never update observation.scope_ref
// on a retained Descriptor, even when the first known ref is learned.
interface.scope_binding = scope_ref.clone();
interface.binding_revision = revision;
}
}
fn is_subtree_path(root: &str, path: &str) -> bool {
root == "/"
|| path == root
|| path
.strip_prefix(root)
.is_some_and(|suffix| suffix.starts_with('/'))
}
// Direct NotFound confirms that this scope path is no longer available. Edge
// omission and local eviction deliberately do not call this function.
fn retire_scope_subtree(state: &mut Session, root: &str, revision: RequestId) {
for (path, entry) in &mut state.entries { for (path, entry) in &mut state.entries {
if (path == root || path.starts_with(&prefix)) && entry.revision <= request_id { if is_subtree_path(root, path) && entry.revision <= revision {
entry.confirmed_missing = true;
}
}
state.interfaces.retain(|reference, record| {
!is_subtree_path(root, &reference.scope) || record.accepted_revision > revision
});
}
fn mark_subtree_stale(state: &mut Session, root: &str, request_id: RequestId) {
for (path, entry) in &mut state.entries {
if is_subtree_path(root, path) && entry.revision <= request_id {
entry.revision = request_id;
entry.state = ObservationState::Stale; entry.state = ObservationState::Stale;
} }
} }
for (path, tree) in &mut state.trees { for (path, tree) in &mut state.trees {
if (path == root || path.starts_with(&prefix)) && tree.revision <= request_id { if is_subtree_path(root, path) && tree.revision <= request_id {
tree.revision = request_id;
tree.state = ObservationState::Stale; tree.state = ObservationState::Stale;
} }
} }
+352 -5
View File
@@ -13,13 +13,14 @@ use wip_client::{
}; };
use wip_http::http::{Response, StatusCode}; use wip_http::http::{Response, StatusCode};
use wip_http::{ use wip_http::{
Limits, encode_fetch_interface_response, encode_observe_response, Limits, decode_call_operation_request, decode_fetch_interface_request,
encode_protocol_error_response, encode_fetch_interface_response, encode_observe_response, encode_protocol_error_response,
}; };
use wip_protocol::{ use wip_protocol::{
FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1, InterfaceDescriptor, CallOperationRequest, FetchInterfaceRequest, FetchInterfaceResponse, INTERFACE_FORMAT_V1,
Object, ObjectObservation, ObserveRequest, OperationDeclaration, ParameterDeclaration, InterfaceDescriptor, InterfaceReference, InterfaceTarget, Object, ObjectObservation,
ProtocolError, ProtocolErrorCode, ProtocolInteraction, ReturnDeclaration, TypeExpr, Value, ObserveRequest, OperationDeclaration, ParameterDeclaration, ProtocolError, ProtocolErrorCode,
ProtocolInteraction, ReturnDeclaration, Target, TypeExpr, Value,
}; };
fn limits() -> Limits { fn limits() -> Limits {
@@ -74,6 +75,352 @@ fn descriptor(format: &str) -> InterfaceDescriptor {
} }
} }
fn reference(scope: &str, name: &str) -> InterfaceReference {
InterfaceReference {
scope: scope.into(),
name: name.into(),
}
}
fn observe(client: &mut Client, session: &wip_client::SessionId, path: &str, value: Object) {
let request = client.refresh_object(session, path).unwrap();
client
.complete(
request.id,
encode_observe_response(
&ObserveRequest {
path: path.into(),
depth: 0,
},
&ObjectObservation {
object: value,
children: None,
},
limits(),
)
.unwrap(),
)
.unwrap();
}
fn fetch(
client: &mut Client,
session: &wip_client::SessionId,
value: FetchInterfaceResponse,
) -> Completion {
let request = client
.prepare_interface(session, value.interface.clone())
.unwrap();
let logical = FetchInterfaceRequest {
interface: value.interface.clone(),
};
assert_eq!(
decode_fetch_interface_request(request.request.body(), limits()).unwrap(),
logical
);
client
.complete(
request.id,
encode_fetch_interface_response(&logical, &value, limits()).unwrap(),
)
.unwrap()
}
#[test]
fn observe_accepts_same_name_interfaces_in_different_scopes() {
let (mut client, session) = setup();
let request = client.refresh_object(&session, "/item").unwrap();
let completion = client
.complete(
request.id,
response(
StatusCode::OK,
br#"{"object":{"name":"item","interfaces":[{"scope":"/","name":"math"},{"scope":"/item","name":"math"}]}}"#,
),
)
.unwrap();
let expected = vec![root_reference("math"), reference("/item", "math")];
assert!(matches!(
completion,
Completion::Object(observation)
if observation.state == ObservationState::Fresh
&& observation.object.as_ref().unwrap().interfaces == expected
));
let cached = client.object(&session, "/item").unwrap();
assert_eq!(cached.state, ObservationState::Fresh);
assert_eq!(cached.object.unwrap().interfaces, expected);
}
#[test]
fn same_name_different_scopes_have_independent_requests_and_cached_descriptors() {
let (mut client, session) = setup();
let root = root_reference("math");
let scoped = reference("/area", "math");
let root_request = client
.ensure_interface(&session, root.clone())
.unwrap()
.unwrap();
let scoped_request = client
.ensure_interface(&session, scoped.clone())
.unwrap()
.unwrap();
assert_ne!(root_request.id, scoped_request.id);
assert_eq!(
decode_fetch_interface_request(root_request.request.body(), limits()).unwrap(),
FetchInterfaceRequest {
interface: root.clone(),
}
);
assert_eq!(
decode_fetch_interface_request(scoped_request.request.body(), limits()).unwrap(),
FetchInterfaceRequest {
interface: scoped.clone(),
}
);
for selected in [&root, &scoped] {
assert!(
client
.ensure_interface(&session, selected.clone())
.unwrap()
.is_none()
);
}
assert_eq!(client.in_flight_len(), 2);
// Reverse completion order must not supersede the other scope's request.
for (request, selected, documentation, scope_ref, validator) in [
(
scoped_request,
&scoped,
"area math",
Some("area-object"),
Some(b"area-v1".to_vec()),
),
(root_request, &root, "root math", None, None),
] {
let mut expected_descriptor = descriptor(INTERFACE_FORMAT_V1);
expected_descriptor.documentation = Some(wip_protocol::Documentation {
summary: documentation.into(),
details: None,
});
let value = FetchInterfaceResponse {
interface: selected.clone(),
scope_ref: scope_ref.map(str::to_owned),
descriptor: expected_descriptor.clone(),
validator: validator.clone(),
};
let completion = client
.complete(
request.id,
encode_fetch_interface_response(
&FetchInterfaceRequest {
interface: selected.clone(),
},
&value,
limits(),
)
.unwrap(),
)
.unwrap();
assert!(matches!(completion, Completion::Interface(_)));
let cached = client.interface(&session, selected).unwrap();
assert_eq!(cached.reference, *selected);
assert_eq!(cached.descriptor, Some(expected_descriptor));
assert_eq!(cached.scope_ref, value.scope_ref);
assert_eq!(cached.validator, validator);
assert_eq!(cached.state, ObservationState::Fresh);
}
for selected in [&root, &scoped] {
assert!(
client
.ensure_interface(&session, selected.clone())
.unwrap()
.is_none()
);
}
assert_eq!(client.in_flight_len(), 0);
assert_eq!(
client
.interface(&session, &root)
.unwrap()
.descriptor
.as_ref()
.unwrap()
.documentation
.as_ref()
.map(|documentation| documentation.summary.as_str()),
Some("root math")
);
assert_eq!(
client
.interface(&session, &scoped)
.unwrap()
.descriptor
.as_ref()
.unwrap()
.documentation
.as_ref()
.map(|documentation| documentation.summary.as_str()),
Some("area math")
);
}
#[test]
fn interface_completion_retains_optional_scope_ref_independently_of_validator() {
for scope_ref in [None, Some("opaque scope identity / not a path")] {
for validator in [None, Some(b"interface-v1".to_vec())] {
let (mut client, session) = setup();
let selected = reference("/area", "math");
let expected = wip_client::InterfaceObservation {
reference: selected.clone(),
descriptor: Some(descriptor(INTERFACE_FORMAT_V1)),
scope_ref: scope_ref.map(str::to_owned),
validator: validator.clone(),
state: ObservationState::Fresh,
};
let completion = fetch(
&mut client,
&session,
FetchInterfaceResponse {
interface: selected.clone(),
descriptor: expected.descriptor.clone().unwrap(),
scope_ref: expected.scope_ref.clone(),
validator,
},
);
assert_eq!(completion, Completion::Interface(expected.clone()));
assert_eq!(client.interface(&session, &selected), Some(&expected));
}
}
}
#[test]
fn call_membership_requires_the_complete_scope_and_name_pair() {
let (mut client, session) = setup();
let member = reference("/item", "math");
let same_name = root_reference("math");
let mut value = object();
value.interfaces = vec![member.clone()];
observe(&mut client, &session, "/item", value);
for selected in [&member, &same_name] {
fetch(
&mut client,
&session,
FetchInterfaceResponse {
interface: selected.clone(),
descriptor: descriptor(INTERFACE_FORMAT_V1),
scope_ref: None,
validator: None,
},
);
}
let arguments = BTreeMap::from([("value".into(), Value::Integer(2))]);
assert_eq!(
client
.prepare_call(
&session,
"/item",
&same_name,
"calculate",
arguments.clone(),
)
.unwrap_err()
.kind,
ClientErrorKind::InvalidRequest
);
assert_eq!(client.in_flight_len(), 0);
let call = client
.prepare_call(&session, "/item", &member, "calculate", arguments)
.unwrap();
assert_eq!(
decode_call_operation_request(
call.request.body(),
&descriptor(INTERFACE_FORMAT_V1),
limits(),
)
.unwrap()
.interface
.reference,
member
);
}
#[test]
fn calls_copy_exact_observed_references_metadata_and_validators_for_root_ancestor_and_self_scopes()
{
for (path, scope) in [
("/", "/"),
("/item", "/"),
("/item", "/item"),
("/area/item", "/area"),
("/area/item", "/area/item"),
] {
for scope_ref in [None, Some("opaque scope identity / not a path")] {
for interface_validator in [None, Some(b"interface-v1".to_vec())] {
for object_validator in [None, Some(b"object-v1".to_vec())] {
let (mut client, session) = setup();
let selected = reference(scope, "math");
let expected_descriptor = descriptor(INTERFACE_FORMAT_V1);
let mut value = object();
value.name = path.rsplit('/').next().unwrap().into();
value.interfaces = vec![selected.clone()];
value.validator = object_validator.clone();
if path == scope {
value.r#ref = scope_ref.map(str::to_owned);
}
observe(&mut client, &session, path, value.clone());
fetch(
&mut client,
&session,
FetchInterfaceResponse {
interface: selected.clone(),
descriptor: expected_descriptor.clone(),
scope_ref: scope_ref.map(str::to_owned),
validator: interface_validator.clone(),
},
);
let expected_interface = client.interface(&session, &selected).unwrap().clone();
let expected_object = client.object(&session, path).unwrap();
let arguments = BTreeMap::from([("value".into(), Value::Integer(2))]);
let expected = CallOperationRequest {
target: Target {
path: path.into(),
validator: object_validator,
},
interface: InterfaceTarget {
reference: selected.clone(),
scope_ref: scope_ref.map(str::to_owned),
validator: interface_validator.clone(),
},
operation: "calculate".into(),
arguments: arguments.clone(),
};
let call = client
.prepare_call(&session, path, &selected, "calculate", arguments)
.unwrap();
assert_eq!(
decode_call_operation_request(
call.request.body(),
&expected_descriptor,
limits(),
)
.unwrap(),
expected
);
let context = client.pending_call_context(call.id).unwrap();
assert_eq!(context.request(), &expected);
assert_eq!(context.interface(), &expected_interface);
assert_eq!(context.object(), &expected_object);
assert_eq!(context.object().object.as_ref(), Some(&value));
assert_eq!(context.descriptor(), &expected_descriptor);
assert_eq!(context.operation(), &expected_descriptor.operations[0]);
assert_eq!(context.session(), &session);
}
}
}
}
}
#[test] #[test]
fn malformed_json_unknown_fields_duplicates_and_invalid_success_are_invalid_response() { fn malformed_json_unknown_fields_duplicates_and_invalid_success_are_invalid_response() {
let cases: &[&[u8]] = &[ let cases: &[&[u8]] = &[
File diff suppressed because it is too large Load Diff
+34 -7
View File
@@ -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;
@@ -30,9 +32,11 @@ For an external backend/composition:
register generated definitions with `registry.register(&scope, name, register generated definitions with `registry.register(&scope, name,
Definition, validator)`. `DefaultInterfaces::register(&mut registry, &scope)` Definition, validator)`. `DefaultInterfaces::register(&mut registry, &scope)`
optionally supplies reusable built-in tokens named `file.read`, optionally supplies reusable built-in tokens named `file.read`,
`symlink.inspect`, and `file.write`; tokens do not force publication. The `symlink.inspect`, and `file.write`; tokens do not force publication. For a
current engine binds only the root scope and fails closed for non-root non-root scope, create a handle at its canonical WIP path and implement
registrations; broader FS scope semantics remain follow-up work. `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.
@@ -78,6 +82,29 @@ 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 not expose any interfaces. Calls check both the private lifetime and optional
`scope_ref`; normal root state updates do not retire the lifetime. `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 `with_object` (including fetch) and individual tree Object publications also
enter this backend boundary. Generic read callbacks use a `NotStarted` attempt 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 as an adapter to the existing execution API; no typed operation runs and no
+3 -1
View File
@@ -24,7 +24,9 @@ pub struct DefaultInterfaces {
impl DefaultInterfaces { impl DefaultInterfaces {
/// Register reusable descriptors at an explicit publication scope. /// Register reusable descriptors at an explicit publication scope.
/// ///
/// The current FS engine binds only its deployment root scope (`/`). /// Built-in local names are `file.read`, `symlink.inspect`, and `file.write`.
/// The default deployment registers them at `/`; external compositions may
/// register them at non-root scopes attested by `Composition::scope`.
pub fn register( pub fn register(
registry: &mut InterfaceRegistry, registry: &mut InterfaceRegistry,
scope: &Scope, scope: &Scope,
+113 -42
View File
@@ -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, ScopeObservation, WipObject, OperationAttempt, ProviderResult, PublishedObject, Scope, ScopeObservation, WipObject,
}; };
use wip_protocol::{ use wip_protocol::{
FetchInterfaceRequest, FetchInterfaceResponse, ObjectObservation, ObserveRequest, FetchInterfaceRequest, FetchInterfaceResponse, ObjectObservation, ObserveRequest,
@@ -75,8 +75,8 @@ impl<E> ResolvedEntry<E> {
/// The root is a deployment-pinned Object: every resolution/execution lineage /// 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 /// 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 /// replaced. Replacing the deployment root requires a new projection and Scope
/// lifetime. The engine currently binds only `/`; non-root registrations are /// lifetime. The engine binds `/` to this pinned deployment root. Non-root
/// not published until a backend can attest their Object lifetimes. /// 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,
@@ -126,12 +126,39 @@ 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. This hook may also publish the retained root to observe its scope /// backend. This hook may also publish retained scope ancestors to observe
/// metadata while another target is executing. Root publication must be /// their metadata while another target is executing. Scope publication must
/// deterministic within the view and use this same retained capability. /// be deterministic within the view and use this same retained capability.
/// A publication failure is a pre-dispatch rejection, never a /// 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(
@@ -294,22 +321,78 @@ where
fn publication( fn publication(
&self, &self,
entry: B::Entry, resolved: &ResolvedEntry<B::Entry>,
root: &B::Entry,
context: &ExecutionContext, context: &ExecutionContext,
) -> Result<PublishedObject<C::Object>, HostError> { ) -> Result<PublishedObject<C::Object>, HostError> {
let is_root = entry.name().is_empty(); let entry = resolved.target();
let publication = self let publication = self
.projection .projection
.composition .composition
.publish(entry.clone(), context)?; .publish(entry.clone(), context)?;
// Scope refs describe the published root Object, not an independently let mut current_scopes = Vec::new();
// resolved raw path or the backend entry's possibly different metadata. let mut path = String::from("/");
let root_publication = if is_root { // Only this refreshed, visible, connected lineage can attest scopes.
None // Never independently resolve a scope path while a target is executing.
} else { for (index, scope_entry) in resolved
Some(self.projection.composition.publish(root.clone(), context)?) .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
@@ -324,15 +407,18 @@ 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| {
// Non-root lifetimes are not attested by this engine yet. Never let reference_value = reference.protocol_reference();
// publish an artifact merely because the registry retains it. current_scopes.iter().any(|current| {
reference.protocol_reference().scope == "/" self.projection
&& self .registry
.projection .fetch_interface(&reference_value, current)
.policy .is_ok()
.interface_visible(self.principal, reference) }) && self
.projection
.policy
.interface_visible(self.principal, reference)
}); });
let mut publication = publication let mut publication = publication
.project(&selected, |reference, operation| { .project(&selected, |reference, operation| {
@@ -341,19 +427,7 @@ where
.operation_visible(self.principal, reference, operation) .operation_visible(self.principal, reference, operation)
}) })
.map_err(|_| internal())?; .map_err(|_| internal())?;
// Called only inside backend.execute, with its refreshed pinned lineage. for current in current_scopes {
// scopes() may repeat the handle for different names; bind_scope replaces
// the same-path snapshot rather than treating repetition as a collision.
for scope in self
.projection
.registry
.scopes()
.filter(|scope| scope.path() == "/")
{
let object = root_publication
.as_ref()
.map_or(publication.implementation(), |root| root.implementation());
let current = ScopeObservation::new(scope, object);
publication = publication.bind_scope(current); publication = publication.bind_scope(current);
} }
Ok(publication) Ok(publication)
@@ -382,10 +456,9 @@ where
observed = Some(ProviderResult::NotFound); observed = Some(ProviderResult::NotFound);
return OperationAttempt::reject(wip_host::CallErrorKind::NotFound); return OperationAttempt::reject(wip_host::CallErrorKind::NotFound);
} }
let root = resolved.ancestors().first().unwrap_or(resolved.target());
observed = Some( observed = Some(
match preflight(context).and_then(|()| { match preflight(context).and_then(|()| {
self.publication(resolved.target().clone(), root, context) self.publication(&resolved, context)
.map(|publication| operation(&publication)) .map(|publication| operation(&publication))
}) { }) {
Ok(result) => ProviderResult::Found(result), Ok(result) => ProviderResult::Found(result),
@@ -601,8 +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 root = resolved.ancestors().first().unwrap_or(resolved.target());
let entry = resolved.target().clone();
if let Err(error) = preflight(context) { if let Err(error) = preflight(context) {
return OperationAttempt::reject(match error.kind() { return OperationAttempt::reject(match error.kind() {
wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal, wip_host::HostErrorKind::Internal => wip_host::CallErrorKind::Internal,
@@ -611,7 +682,7 @@ where
} }
}); });
} }
match self.publication(entry, root, 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,
+5 -4
View File
@@ -22,10 +22,11 @@
//! Registration uses `registry.register(&scope, name, Definition, validator)`. //! Registration uses `registry.register(&scope, name, Definition, validator)`.
//! [`DefaultInterfaces::register`] takes an explicit [`wip_host::Scope`]; built-ins //! [`DefaultInterfaces::register`] takes an explicit [`wip_host::Scope`]; built-ins
//! use root-local names `file.read`, `symlink.inspect` and `file.write`. The engine //! use root-local names `file.read`, `symlink.inspect` and `file.write`. The engine
//! binds only its deployment-pinned root lifetime, inside the backend execution //! binds its deployment-pinned root lifetime automatically inside the backend
//! boundary, using the current published root Object from the retained lineage. //! execution boundary. External [`Composition::scope`] implementations attest
//! Observation/fetch callbacks enter that same coherent boundary. Non-root //! non-root lifetimes using the refreshed connected lineage in that same boundary.
//! registrations fail closed until their Object lifetimes can be attested. //! 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; //! 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. //! fetch therefore omits `scope_ref` without weakening the private lifetime check.
//! //!
+8 -1
View File
@@ -125,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>,
@@ -289,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;
} }
@@ -2188,8 +2192,11 @@ fn root_scope_replacement_during_call_rejects_the_prelookup() {
); );
} }
#[path = "support/scoped.rs"]
mod scoped;
#[test] #[test]
fn nonroot_registrations_fail_closed_until_the_engine_can_attest_their_lifetimes() { fn nonroot_registrations_fail_closed_without_composition_lifetime_attestation() {
let fixture = Fixture::new(); let fixture = Fixture::new();
let mut registry = InterfaceRegistry::new(); let mut registry = InterfaceRegistry::new();
let root = Scope::new("/").unwrap(); let root = Scope::new("/").unwrap();
@@ -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
);
}
}
+3 -3
View File
@@ -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`.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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.
+4
View File
@@ -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
+27 -15
View File
@@ -10,7 +10,10 @@
//! 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
//! //!
@@ -229,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()),
@@ -243,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,
@@ -256,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(),
@@ -266,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(),
@@ -276,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(),
@@ -288,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(),
@@ -299,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(),
@@ -329,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(),
@@ -351,7 +360,8 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
let mut stale_scope = request( let mut stale_scope = 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(),
@@ -421,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)]),
@@ -445,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>,
@@ -456,8 +468,8 @@ fn request(
validator: Some(OBJECT_VALIDATOR.to_vec()), validator: Some(OBJECT_VALIDATOR.to_vec()),
}, },
interface: InterfaceTarget { interface: InterfaceTarget {
scope_ref: Some("article-service-instance".into()), 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(),
@@ -258,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);
}
+13
View File
@@ -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
+193
View File
@@ -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
```
+221
View File
@@ -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 {}
+125
View File
@@ -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(&parameter.name, &parameter.documentation)?;
budget.expr(&parameter.r#type, 1)?;
}
budget.nodes(1)?; // Return declaration.
budget.doc(&operation.returns.documentation)?;
budget.expr(&operation.returns.r#type, 1)?;
}
Ok(())
}
+334
View File
@@ -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(&parameter.documentation, 2);
self.member(
ParameterName,
&parameter.name,
parameter.required,
&parameter.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("}");
}
}
}
}
+84
View File
@@ -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
+73 -4
View File
@@ -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,
+49 -6
View File
@@ -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
+10
View File
@@ -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);
+9
View File
@@ -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;
+107
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+567
View File
@@ -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
);
}
+211
View File
@@ -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")));
}