diff --git a/CHANGELOG.md b/CHANGELOG.md index 5115fb5c..ec15fd9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed - **Examples now default to shell-forward demos with explicit Glia snippets.** Example READMEs now guide users through daemon + `ww shell` flows (`load glia/register.glia`, plus `serve`/`consume` snippets where relevant), per-example demo wiring moved from `examples/*/etc/init.d/*.glia` into new `examples/*/glia/*.glia` snippet files, and `doc/images.md` now documents init.d boot as deployment guidance rather than the demo default. +- **Init export policy switched to a bare capability map with recursive `attenuate` support (shell + init).** `init.glia` must now return a bare export map (legacy `:export/:caps/:methods` is rejected), kernel boot remains fail-closed with strict unknown-cap errors, and recursive attenuation authored via existing Glia `attenuate` syntax is enforced at the membrane/RPC proxy layer for cap-returning paths (including `host.network`, `runtime.load`, `identity.signer`, and `ipfs.read`). Added init-system docs under `doc/init.md` and migrated bundled/example init scripts to the new policy shape. +- **Dynamic-cap return paths now use typed envelopes (`TypedCap`) with schema nodes, and WW_ROOT path handling is hardened against symlink escapes.** `Process.bootstrap`, `VatClient.dial`, and `VatHandler.serve` now flow through `TypedCap { cap, schema }` (`SchemaBundle` uses `schema.Node` root + deps), recursive attenuation wrappers consume typed schema metadata, and policy/docs/tests were updated to match the hard cutover. `load-file`, kernel `list-dir`, and kernel `path-is-dir` now enforce WW_ROOT containment against both lexical traversal and symlink escape paths. +- **Vat RPC bootstrap now starts directly on the stream (no WWSC preface), with descriptor-CID routing unchanged.** On `/ww/0.1.0/vat/{cid}`, dial/listen now enter Cap'n Proto bootstrap directly on the vat stream. Routing identity remains the descriptor CID of canonical `{wasiCid,schemaCid}`. `VatClient.dial` now sets `TypedCap.schema` only from local `descriptor.schemaCid` lookup and fails closed with an explicit error when `schemaCid` is unresolved/invalid. - **AutoNAT v2 probe observability exposed with bounded history (#512).** Added a ring-buffered `nat_probe_events` surface in runtime `NetworkState` (tested address, probing server peer ID, result, timestamp), wired capture from `AutonatV2` events, and exposed operator JSON at admin `GET /host/nat` alongside existing node-level `nat_status`. - **`system.Ipfs` read API is now stream-only (`read -> ByteStream`).** Removed `Ipfs.readStream` and changed `Ipfs.read` to return `ByteStream`, consolidating capability attenuation to a single read method while retaining chunked daemon-backed transfer semantics for `/ipfs`, `/ipns`, and `/ipld` paths. - **`ww shell --mcp` now runs MCP process-local in the shell path (#508).** Replaced daemon-spawned `std/mcp` WASM execution with a process-local MCP JSON-RPC loop in the CLI that reuses shell dial/login/graft auth flow and local Glia evaluation, while keeping daemon-backed transport/auth/capability dispatch unchanged. @@ -40,6 +43,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Legacy `perform fs` compatibility handler removed.** The deprecated `fs-handler` fallback in `std/caps` has been deleted; callers must use WASI path I/O for reads and the routing write-path API for mutations. - **`std/lib/ww/fs.glia` and `std/lib/ww/routing.glia` — pure-veneer modules.** Both were 1-line pass-throughs over the bound `fs` and `routing` capabilities (`(defn read-str [path] (perform fs :read-str path))` and four analogous `routing/*` shims). Zero internal callers across the repo. The wrappers obscured the canonical `(perform : args)` cap-dispatch idiom without abstracting anything, and the names sustained the fiction that `ww/fs` was "the filesystem module" when `fs` is the underlying capability and the only thing doing work. Also gone: the pure-Glia `ipfs-path` / `ipns-path` constructors (trivial `(str "/ipfs/" cid)` inlines) and the incomplete `cid?` predicate (missed several valid CIDv1 multicodec prefixes — `bafr`, `bagu`, `bagy`, … — and wasn't fs-related to begin with). `doc/capabilities.md` updated: the "Content access" section now shows `(perform fs :read-str path)` directly instead of advertising `fs/read*` wrappers; the env-bindings table entry switches from "Glia functions" to "capabilities" framing. The `fs` and `routing` capabilities themselves are unchanged — still bound at env startup (`std/shell/src/lib.rs:260-274`); only the redundant module veneer is gone. Both modules were introduced earlier in `[Unreleased]` and never shipped in a release, so zero external migration burden. - **`ww shell` "AI agents:" startup hint.** The line `AI agents: ipfs cat /ipns/releases.wetware.run/.agents/prompt.md` is gone from `src/cli/shell.rs`. The hint pointed at a host-shell command (`ipfs cat`) that's awkward to surface from inside a Glia REPL (the user can't paste it), and the obvious Glia-form rewrite — `(perform fs :read-str "/ipns/…")` — fails today because the WASI fs interceptor (`crates/cell/src/fs_intercept.rs:481-520`) only recognizes `ipfs//…` paths (`parse_ipfs_path` at line 72 strips the `ipfs/` prefix; there's no `ipns/` sibling). `/ipfs//…` reads through the cap *do* work — `open_ipfs` lazily materializes content from the pinset cache — so a hint pointing at a stable CID would work today; what's missing is IPNS resolution at the intercept layer (or a sibling cap method that calls Kubo `name/resolve` first, then routes through the existing pinset path). Restoring a pasteable Glia-form hint is the natural reward for that follow-up. +- **Core Farcaster Snap JFS verification plumbing removed from `ww` core.** Deleted `crates/rpc/src/jfs.rs`, removed `X-Snap-Payload` verification in host HTTP dispatch, removed `verified_snap` routing/CGI env injection (`X_SNAP_*`) from core paths, and dropped the core snap listener e2e coverage. Snap-specific behavior remains example-owned in `examples/snap-hello-rs`. ### Fixed - **FS interception IPFS materialization now streams directly to staging files in hot paths.** `open_ipfs` and `ResolvedNode::CidFile` no longer require `Vec` fetch-then-write flows for cache materialization, and instead use direct stream-to-path cache APIs to reduce peak memory pressure under large content reads. @@ -58,7 +62,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **CI `Require changelog entry` skips pure-docs PRs.** `.github/workflows/changelog.yml` now classifies changed files: paths matching top-level `*.md`, `doc/**`, `docs/**`, `.github/**`, or `.agents/**` are docs; anything else is code. CHANGELOG entry is only required when at least one non-docs file changed. Unblocks docs-only follow-up PRs (e.g. #453) without forcing a synthetic CHANGELOG line. - **`ww shell` now connects over a Unix Domain Socket — `ww shell` is finally usable end-to-end.** Replaces the libp2p-based client/server pair with a UDS-only local admin gate at `~/.ww/run/.sock` (FS permissions on the run dir ARE the auth boundary — matching `/var/run/docker.sock`, `~/.ipfs/api`, `~/.podman/podman.sock` convention). New host-side service `src/admin_uds.rs::AdminUdsService` lives alongside `SwarmService` and `EpochService`: own thread, `current_thread` runtime + `LocalSet`, constructs its own `Runtime` client over the daemon's shared backing state (network_state, swarm_cmd_tx, ipfs_client, etc.), pre-loads `shell.wasm` at startup so per-connection spawns skip the wasmtime AOT compile, binds the UDS with stale-socket recovery (probe-then-unlink-then-rebind on `EADDRINUSE` per `ECONNREFUSED`; fail-loudly if another daemon answers the probe), and writes `.json` metadata alongside (peer_id, multiaddrs, started_at RFC 3339, pid, version) for `ww status`/MCP tooling consumers. Per-connection bridge: `tokio::net::UnixStream` (compat-adapted) → existing `handle_vat_connection_spawn` (generic over `AsyncRead + AsyncWrite + 'static`, no transport-specific code needed). The spawned shell cell receives the full graft membrane (`host`, `runtime`, `routing`) — admin scope is exempt from epoch-based capability expiry by design (the shell should remain usable for the daemon's lifetime regardless of stem activity), implemented via a sentinel epoch watch channel with `seq=0` that never advances. Critical wiring detail: `src/launcher.rs:359` selects between `build_membrane_rpc` (exports cell bootstrap) and `build_peer_rpc` (does NOT) based on whether the runtime has an `epoch_rx`; AdminUdsService must pass `Some(epoch_rx)` to `create_runtime_client` or `process.bootstrap_request()` returns "process did not export bootstrap" immediately. Client side: `src/cli/shell.rs` collapses from ~250 lines to ~150 — `discover_socket()` scans run-dir for `.sock` entries (0 → error; 1 → use it; >1 → prompt), `UnixStream::connect` → `tokio_util::compat` → `rpc::vat_dial::connect` (the paved-path helper from #451, which fixes the bootstrap-cap-resolution deadlock and removes the now-unnecessary `when_resolved` await). Forward-stable CLI stubs `ww shell ` and `ww shell --discover` both exit `Error: NOT IMPLEMENTED`; invalid multiaddrs caught by clap's `Option` parser before `run_shell` runs. `--help` documents the future precedence rule (multiaddr beats `--discover`). The `--identity` flag is gone — it was the *client*-side libp2p Noise identity, meaningless under UDS where FS perms authenticate; reintroduce on the client when remote shell ships. `src/discovery.rs` deletes the old lockfile machinery (`write_lockfile`, `remove_lockfile`, text-multiaddr `list_local_nodes`) and replaces it with `socket_path(peer_id)` / `metadata_path(peer_id)` helpers + a `*.sock` scanner that returns `LocalNode { peer_id, socket_path }`. `src/host.rs` no longer writes a multiaddrs lockfile on `NewListenAddr` events — AdminUdsService writes the `.json` snapshot at startup. `std/shell/etc/init.d/50-shell.glia` is deleted (shell is no longer an init.d-registered libp2p vat; when remote-shell over libp2p ships, the operator restores a `:listen` registration with whatever auth wrapping is appropriate at that time, likely the April-2 design's `Terminal(Shell)` gate). `ww perform install` / `ww perform update` no longer write `50-shell.glia` to the user's `~/.ww/etc/init.d/`. Known polish wrinkle: the very first eval after a fresh daemon connect can hit "shell not ready (initialization in progress)" — the cell's `ready.set(true)` fires inside the membrane closure after graft, racing the client's first request by a few milliseconds; user retries, works. Worth a follow-up to either block-on-ready in the cell or retry-once on the client side. Builds on #451 (which fixed #450's RPC handshake timeout via the new `vat_dial::connect` paved-path helper); this PR is the architectural follow-up that makes shell usable on a single-machine workflow without paying any libp2p tax (no Noise, no Yamux, no protocol negotiation for talking to your own daemon). - **Narrative docs aligned with the UDS migration above.** Post-merge doc pass to the `ww shell` over UDS bullet: `doc/shell.md` was rewritten (the old version still described the libp2p connect path with `--identity` and Kubo-DHT discovery, and listed `runtime`/`identity` caps the shell cell doesn't graft today — only `host` and `routing`); `doc/cli.md`'s `ww shell` section updated to the new `[ADDR] [--discover]` signature with NOT-IMPLEMENTED notes on the remote stubs plus a short auth-model paragraph (FS perms on `~/.ww/run/` ARE the boundary, no Noise, no Terminal); `doc/architecture.md` swapped a guest-path-resolution example that referenced the deleted `etc/init.d/50-shell.glia` for `05-status.glia`; `README.md` dropped the `ww shell /dnsaddr/master.wetware.run` example (NOT IMPLEMENTED in shipped code) and the "auto-discovers via Kubo" line (was never accurate — local discovery has always been file-based, just from lockfile to socket now), with a UDS-and-FS-perms paragraph in its place; `.agents/prompt.md` updated the `ww shell` row in the CLI quick-reference table. No code change in this pass — `CHANGELOG.md` and `TODOS.md` already shipped with the feature. -- **Snap v1.5 — interactive "Ping me" button + per-click timestamp.** `examples/snap-hello-rs/` now renders a `stack` containing a `text` greeting + a `button` element ("Ping me", primary variant). Button's `on.press` is a `submit` action whose `params.target` is the cell's own URL (built at runtime from `HTTP_HOST` + `PATH_INFO`, assuming HTTPS termination). On POST, the cell renders `Hello, {viewer} — pinged at UTC (unix)` — `viewer` is `@stranger` for anonymous POSTs and `FID #N` for JFS-verified ones (POST is REQUIRED to be JFS-signed per spec, so real client button-presses always exercise the viewer-aware path). POST responses now use `Cache-Control: private, no-store` (per-viewer + freshness-sensitive); GET stays at `public, max-age=300` for the anonymous render. Why this matters: Farcaster's web + mobile renderers fetch snap GETs server-side without `X-Snap-Payload`, so the v1.0 viewer-aware GET path never fired in practice. The button gives every clicking user a way to trigger a JFS-signed POST end-to-end, which is the only way to actually demo viewer-awareness on first-party Farcaster clients today. Cell tests grew from 9 to 15 (new: stack/button/submit/target shape; anonymous + viewer-aware POST text rendering with timestamp marker; 320-char limit under worst-case FID + timestamp). E2E tests grew from 5 to 6 — added `snap_cell_post_with_verified_jfs_renders_fid_and_timestamp` covering the verified-POST path end-to-end through the full HttpListener dispatch + executor spawn + CGI env passthrough. +- **Snap example UI now includes an interactive "Ping me" button + timestamped POST acknowledgment.** `examples/snap-hello-rs` renders a `stack` with greeting text plus a submit button targeting the same URL. POST renders `Hello, {viewer} — pinged at UTC (unix)` and now uses `Cache-Control: private, no-store` while GET remains `public, max-age=300`. Viewer identity is example metadata (`X_SNAP_FID_CLAIMED`) owned by the demo path, not a core-verified authority signal. ### Fixed - **Deploy workflow stopped referencing the deleted `std/shell/etc/init.d/50-shell.glia`.** PR #452 removed the shell init.d script (shell is now a daemon built-in loaded by `AdminUdsService` at startup, not an init.d-registered vat) but `.github/workflows/rust.yml` still uploaded it as a build artifact and `cp`d it into `wetware/shell/etc/init.d/` during deploy-context assembly — the `cp` exited 1 on master and broke `Deploy to master.wetware.run`. Removed both references plus the now-empty `mkdir -p wetware/shell/etc/init.d`, and updated `Containerfile.deploy`'s image-layout comment to drop the stale `shell/etc/init.d/` line. The staging step at `rust.yml:594` already guards with `2>/dev/null || true`, so no change there. @@ -66,9 +70,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Default tracing filter silenced workspace-crate logs after #444 (#448).** PR #444 moved `src/rpc/`, `src/cell/`, `src/ipfs/` (and friends) into top-level workspace crates, so their `tracing::*` events now emit at targets like `rpc::vat_listener` and `cell::executor` instead of `ww::rpc::*` / `ww::cell::*`. The default filter in `src/config.rs` (`ww=warn`/`ww=info` depending on TTY) only matches the binary crate, so every log line from the split crates was filtered out at runtime — including the load-bearing `Registered vat subprotocol cell` and `registered HTTP route` messages emitted from `crates/rpc/src/{vat,http}_listener.rs`. Registration itself was unaffected (`/status` continued to answer JSON), but the silence made it look like the listeners weren't wiring up, blocked diagnosis of the separate 30s `ww shell` bootstrap timeout, and hid any new `tracing::info!` instrumentation added inside `handle_vat_connection_spawn`. Fix: `src/config.rs::init_tracing_to_stderr` now expands the default to a comma-separated list of all wetware-internal workspace crates (`ww`, `atom`, `cache`, `cell`, `glia`, `ipfs`, `membrane`, `rpc`, `stem`), each set to the same level (`warn` on TTY, `info` otherwise). `RUST_LOG` overrides remain untouched. - **`std/system` `wit_bindgen!` path after workspace split.** PR #444 moved `wit/` -> `crates/cell/wit/` but missed the relative path in `std/system/src/lib.rs:65`'s `wit_bindgen::generate!` macro, which was looking at `/wit/` (gone). Master CI's `Build WASM components` job has been failing on every push since #444 merged ("failed to read path for WIT [...]: No such file or directory"), blocking IPFS publish + deploy — so master.wetware.run is still running the pre-#444 image and #445's snap v1 deploy hasn't happened. Fix: `path: "../../wit"` -> `path: "../../crates/cell/wit"`. The `Build WASM components` job is conditional on the PR-level `Detect changes` matrix, so this kind of cross-crate path breakage only fails on master push, not on the originating PR — worth a follow-up to broaden coverage. -### Added -- **Farcaster Snap v1.0 — JFS-verified viewer awareness + POST.** New `crates/rpc/src/jfs.rs` module verifies the `X-Snap-Payload` header per the JSON Farcaster Signatures spec (https://github.com/farcasterxyz/protocol/discussions/208 + https://docs.farcaster.xyz/snap/auth): JWT-style compact serialization (`BASE64URL(header) . BASE64URL(payload) . BASE64URL(signature)`) split + decode, EdDSA signature verify against the embedded `app_key` (32-byte Ed25519 hex pubkey), `header.fid == payload.fid` consistency check, audience match against the server's expected origin, and ±5 min timestamp window (default per spec, configurable). 16 unit tests cover happy path, inputs/b64url passthrough, audience mismatch, expired/future-skew rejection, within-skew accept, tampered payload + tampered header rejection, FID mismatch rejection, non-`app_key` type rejection, malformed compact, pubkey 0x prefix and wrong-length handling. The HTTP listener's host-bin handler (`src/dispatcher/server.rs`) now calls `rpc::jfs::verify` on incoming `X-Snap-Payload` headers before constructing `CgiRequest` (whose new `verified_snap: Option` field — added to `crates/rpc/src/dispatch.rs::CgiRequest` — carries the result downstream); verified payloads flow through to cells as new CGI env vars `X_SNAP_FID_CLAIMED`, `X_SNAP_TIMESTAMP`, `X_SNAP_AUDIENCE`, `X_SNAP_PAYLOAD_B64URL` (emitted by `crates/rpc/src/wagi.rs::build_cgi_env` when `verified_snap` is `Some`). The `examples/snap-hello-rs/` cell now reads `X_SNAP_FID_CLAIMED` and renders `Hello, FID #N` when present, falling back to `Hello, @stranger` when absent. POST requests are acknowledged with the same UI tree per the snap spec's submit-action contract. Two new e2e tests in `tests/snap_hello_rs_http_listener_e2e.rs` cover the JFS-verified viewer-aware GET path and the POST ack (test file now serializes its 5 tests via a `Mutex` guard since each test spins up its own Runtime + executor pool + libp2p stack and parallel runs collide on shared resources). **NOT in v1.0 (deferred to v1.1):** Hub round-trip to confirm the embedded key is currently registered to the claimed FID. Without that step, the FID is *cryptographically signed* against the embedded key, but the key↔FID binding is *not Hub-verified* — an attacker can sign a payload claiming any FID with their own keypair and the signature verifies. The CGI env var name `X_SNAP_FID_CLAIMED` makes this explicit. Cells that grant authority based on FID identity SHOULD wait for v1.1. The listener is also currently permissive on verification failure (logs warn, treats as anonymous) rather than spec-strict (`MUST 4xx on malformed/expired/invalid`); v1.1 will tighten this in lockstep with the Hub check. - ### Changed - **Cargo workspace split: `crates/cell`, `crates/ipfs`, `crates/rpc`.** The root `ww` crate was a 20k-LOC kitchen sink with `wasmtime` + `libp2p` (kitchen-sink feature list) + `capnp-rpc` + `axum` + `reqwest` all bolted onto one Cargo.toml — every edit to `cli/main.rs` invalidated translation units that pulled `cranelift-codegen` (304s baseline self-time), `wasmtime` (183s), and `wasmtime-wasi` (109s). Split the bulk into three workspace crates along natural seams: `crates/cell` for WASM execution (`wasmtime` + `wasmtime-wasi` + `wasmtime-wasi-io` + `cap-std`; absorbs `cell/`, `vfs.rs`, `mount.rs`, `sched.rs`, `epoch.rs`, `loaders.rs`, `image.rs`, `fs_intercept.rs`, plus `wit/` for `bindgen!`), `crates/rpc` for libp2p + capnp-rpc protocol (absorbs `rpc/`, `keys.rs`, plus the WAGI dispatch types and CGI parsing), `crates/ipfs` for the Kubo HTTP client (`reqwest` + `tar`). Result: editing `cli/main.rs` rebuilds in ~10s instead of ~30s+, and edits to `cell` no longer invalidate `rpc` (and vice versa). Required breaking a `rpc <-> cell` cycle first by lifting two wiring files (`RuntimeImpl`/`ExecutorImpl` capnp Server impls, and `cell/executor.rs`) out of both subtrees and into the bin layer where they belong (`src/launcher.rs`, `src/executor.rs`). Also: renamed `runtime.rs` to `services.rs` (it's the `Service` trait + supervisor, never the WASM runtime), and renamed the rpc submodule `membrane` to `graft` to stop shadowing the workspace `membrane` crate. No behavior change. `pub use cell;`, `pub use ipfs;`, `pub use rpc;` re-exports in `src/lib.rs` keep external paths (`ww::cell::*`, `ww::ipfs::*`, `ww::rpc::*`) stable. @@ -599,6 +600,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 14 unit tests covering host lifecycle, executor pool scheduling, round-robin distribution, panic handling, exit code piping, and bounded channel backpressure ### Changed +- `Process.bootstrap` now requires `schema: Data` and enforces non-empty schema payloads. +- Recursive export attenuation now enforces `AnyPointer` return edges at RPC proxy boundaries for `vat-client.dial.cap` and `process.bootstrap.cap`. - `spawn_rpc_inner` and child cell spawn paths use ambient `LocalSet` instead of nested `LocalSet`, enabling proper M:N cooperative scheduling across cells on the same worker thread - `SwarmService` and `EpochService` now respect shutdown signal via `tokio::select!` - `ExecutorPool` stores worker `JoinHandle`s and joins them on drop for clean shutdown diff --git a/Cargo.lock b/Cargo.lock index 17684220..24a1b617 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1481,20 +1481,6 @@ dependencies = [ "wasip2", ] -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - [[package]] name = "ciborium" version = "0.2.2" @@ -5401,7 +5387,6 @@ dependencies = [ "atom", "auth", "base58", - "base64", "blake3", "bytes", "capnp", @@ -8155,7 +8140,6 @@ dependencies = [ "capnpc", "caps", "cell", - "chrono", "cid", "clap", "criterion", diff --git a/Cargo.toml b/Cargo.toml index 74315465..2da61ddd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,6 @@ base64 = "0.22.1" serde_json = "1.0.145" serde = { version = "1.0.228", features = ["derive"] } futures = "0.3" -chrono = { version = "0.4.42", features = ["serde"] } tracing = "0.1" tokio-util = { version = "0.7.17", features = ["codec", "compat"] } bytes = "1.11.0" diff --git a/README.md b/README.md index 22e4ce50..a9ab1a14 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ curl http://localhost:2080/status } ``` -The second command hit a WebAssembly cell running inside the daemon. The cell can't read your filesystem, reach the network, or see your environment variables. The only thing it can do is what the membrane handed it; in this case, the `host` capability, so it can report your peer ID and connected peers. The wiring that hands the `host` capability (and nothing else) to the HTTP handler cell lives at `~/.ww/etc/init.d/05-status.glia`: +The second command hit a WebAssembly cell running inside the daemon. The cell can't read your filesystem, reach the network, or see your environment variables. The only thing it can do is what the membrane handed it; in this case, the `host` capability, so it can report your peer ID and connected peers. The wiring that hands the `host` capability (and nothing else) to the HTTP handler cell lives in `~/.ww/etc/init.d/05-status.glia` (orchestrated by `~/.ww/etc/init.glia`): ```clojure (perform host :listen (cell (load "bin/status.wasm")) "/status") diff --git a/capnp/system.capnp b/capnp/system.capnp index 85a50c18..00c5581c 100644 --- a/capnp/system.capnp +++ b/capnp/system.capnp @@ -8,6 +8,22 @@ @0xbf5147b78c0e6a2f; using MembraneSchema = import "membrane.capnp"; +using Schema = import "/capnp/schema.capnp"; + +struct SchemaBundle { + root @0 :Schema.Node; + deps @1 :List(Schema.Node); +} + +struct TypedCap { + cap @0 :Capability; + schema @1 :SchemaBundle; +} + +struct VatDescriptor { + wasiCid @0 :Data; + schemaCid @1 :Data; +} struct PeerInfo { peerId @0 :Data; # libp2p peer ID, serialized. @@ -128,9 +144,9 @@ interface Process { wait @3 () -> (exitCode :Int32); # Block until the process exits and return its exit code. - bootstrap @4 () -> (cap :AnyPointer); - # Return the capability exported by the guest via system::serve(). - # The cap is type-erased — cast to the expected interface on the guest side. + bootstrap @4 () -> (typed :TypedCap); + # Return the capability exported by the guest via system::serve() with + # producer-attached schema metadata required for recursive attenuation. # Errors if the guest didn't export a capability. kill @5 () -> (); @@ -141,16 +157,16 @@ struct VatHandler { union { spawn @0 :Executor; # Stateless: spawn a fresh cell per connection. - serve @1 :AnyPointer; + serve @1 :TypedCap; # Stateful: bootstrap all connections with this persistent capability. } } interface VatListener { - listen @0 (handler :VatHandler, schema :Data, + listen @0 (handler :VatHandler, descriptor :VatDescriptor, caps :List(MembraneSchema.Export)) -> (); # Accept incoming Cap'n Proto RPC connections on /ww/0.1.0/vat/{cid} - # where cid = CIDv1(raw, BLAKE3(schema)). + # where cid = CIDv1(raw, BLAKE3(canonical VatDescriptor)). # # handler.spawn: for each connection, spawn a cell via the Executor. # The cell calls system::serve() to export a bootstrap capability. @@ -158,7 +174,8 @@ interface VatListener { # handler.serve: bootstrap each connection with the provided capability. # No cell spawning — one persistent capability serves all connections. # - # Schema param is authoritative. WASM custom sections are optional hints. + # Routing identity is descriptor-authoritative; recursive attenuation + # authority comes from producer-returned TypedCap.schema. # # caps: optional named capabilities from the init.d `with` block. # Forwarded into spawned cells' membranes as graft extras. @@ -166,15 +183,13 @@ interface VatListener { } interface VatClient { - dial @0 (peer :Data, schema :Data) -> (cap :AnyPointer); + dial @0 (peer :Data, descriptor :VatDescriptor) -> (typed :TypedCap); # Open a Cap'n Proto RPC connection to peer on /ww/0.1.0/vat/{cid} - # where cid = CIDv1(raw, BLAKE3(schema)). - # The schema is the canonical Cap'n Proto encoding of a schema.Node. + # where cid = CIDv1(raw, BLAKE3(canonical VatDescriptor)). # Bootstraps a Cap'n Proto vat over the stream and returns the remote # cell's bootstrap capability. # - # The returned cap is type-erased (AnyPointer) — cast it to the expected - # interface type on the guest side. + # Returns a capability plus schema metadata for recursive attenuation. } interface ByteStream { diff --git a/crates/glia/src/eval.rs b/crates/glia/src/eval.rs index ec9c56cc..3353c13f 100644 --- a/crates/glia/src/eval.rs +++ b/crates/glia/src/eval.rs @@ -18,13 +18,15 @@ use core::pin::Pin; use core::sync::atomic::{AtomicU64, Ordering}; use core::task::Poll; use std::cell::RefCell; -use std::collections::{BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::rc::Rc; use crate::effect::{self, HandlerStack}; use crate::error; use crate::expr::FnBody; -use crate::{make_cap, oneshot, AttenuatedCapInner, FnArity, GliaCapInner, Val, ValMap}; +use crate::{ + make_cap, oneshot, AttenuatedCapInner, AttenuationPolicy, FnArity, GliaCapInner, Val, ValMap, +}; /// Monotonic counter for `gensym`. static GENSYM_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -46,6 +48,7 @@ static GENSYM_COUNTER: AtomicU64 = AtomicU64::new(0); pub struct Env { frames: Vec, handler_stack: HandlerStack, + attenuate_self_scope_depth: usize, } impl Default for Env { @@ -57,12 +60,63 @@ impl Default for Env { type Frame = std::collections::HashMap; +fn resolve_guest_fs_path(path: &str) -> Result { + if let Ok(root) = std::env::var("WW_ROOT") { + let root = root.trim_end_matches('/'); + let root_path = std::path::Path::new(root); + let canonical_root = std::fs::canonicalize(root_path) + .map_err(|e| format!("load-file: WW_ROOT '{root}' is not accessible: {e}"))?; + let mut rel = std::path::PathBuf::new(); + for component in std::path::Path::new(path).components() { + use std::path::Component; + match component { + Component::Prefix(_) => { + return Err(format!("load-file: invalid path '{path}'")); + } + Component::RootDir | Component::CurDir => {} + Component::ParentDir => { + if !rel.pop() { + return Err(format!( + "load-file: path escapes WW_ROOT via parent traversal: '{path}'" + )); + } + } + Component::Normal(seg) => rel.push(seg), + } + } + let resolved = root_path.join(rel); + + // Prevent WW_ROOT escape through symlink traversal by requiring the + // nearest existing ancestor of the requested path to stay under WW_ROOT. + let mut probe = resolved.as_path(); + while !probe.exists() { + probe = probe + .parent() + .ok_or_else(|| format!("load-file: failed to resolve parent for path '{path}'"))?; + } + let canonical_probe = std::fs::canonicalize(probe) + .map_err(|e| format!("load-file: failed to canonicalize '{path}': {e}"))?; + if !canonical_probe.starts_with(&canonical_root) { + return Err(format!( + "load-file: path escapes WW_ROOT via symlink traversal: '{path}'" + )); + } + + return Ok(resolved.to_string_lossy().to_string()); + } + if path.starts_with('/') { + return Ok(path.to_string()); + } + Ok(format!("/{path}")) +} + impl Env { /// Create a new, empty environment with a single root frame. pub fn new() -> Self { Self { frames: vec![Frame::new()], handler_stack: effect::new_handler_stack(), + attenuate_self_scope_depth: 0, } } @@ -151,6 +205,7 @@ impl Env { // Keep the current stack on snapshots; invocation still routes through // the caller's handler stack via `Env::for_call`. handler_stack: self.handler_stack.clone(), + attenuate_self_scope_depth: self.attenuate_self_scope_depth, } } @@ -167,6 +222,7 @@ impl Env { Self { frames: vec![filtered], handler_stack: self.handler_stack.clone(), + attenuate_self_scope_depth: self.attenuate_self_scope_depth, } } @@ -189,8 +245,23 @@ impl Env { Self { frames: vec![root, Frame::new()], // root + param frame handler_stack: caller_hs.clone(), + attenuate_self_scope_depth: 0, + } + } + + fn enter_attenuate_self_scope(&mut self) { + self.attenuate_self_scope_depth += 1; + } + + fn exit_attenuate_self_scope(&mut self) { + if self.attenuate_self_scope_depth > 0 { + self.attenuate_self_scope_depth -= 1; } } + + fn allows_attenuate_self(&self) -> bool { + self.attenuate_self_scope_depth > 0 + } } // --------------------------------------------------------------------------- @@ -235,6 +306,35 @@ fn cap_descriptor_bytes(name: &str, schema_cid: &str, methods: &BTreeSet .into_bytes() } +fn parse_policy_name(value: &Val, context: &'static str) -> Result { + match value { + Val::Keyword(name) | Val::Sym(name) | Val::Str(name) => Ok(name.clone()), + other => Err(error::type_mismatch( + context, + "keyword/symbol/string", + other, + )), + } +} + +fn canonical_member_name(name: &str) -> String { + let mut out = String::new(); + let mut upper_next = false; + for ch in name.chars() { + if ch == '-' || ch == '_' { + upper_next = true; + continue; + } + if upper_next { + out.extend(ch.to_uppercase()); + upper_next = false; + } else { + out.push(ch); + } + } + out +} + fn parse_allow_methods(value: &Val) -> Result, Val> { let items = match value { Val::Vector(v) | Val::List(v) => v, @@ -249,14 +349,125 @@ fn parse_allow_methods(value: &Val) -> Result, Val> { let mut allow = BTreeSet::new(); for item in items { - match item { - Val::Keyword(k) => { - allow.insert(k.clone()); + let parsed = parse_policy_name(item, "attenuate method")?; + allow.insert(canonical_member_name(&parsed)); + } + Ok(allow) +} + +fn parse_self_return_policy(value: &Val) -> Result { + let Val::Cap { inner, .. } = value else { + return Err(error::type_mismatch( + "attenuate :returns field policy", + "attenuated :self capability", + value, + )); + }; + let Some(att) = inner.downcast_ref::() else { + return Err(error::type_mismatch( + "attenuate :returns field policy", + "attenuated :self capability", + value, + )); + }; + match &att.base { + Val::Keyword(k) if k == "self" => Ok(att.policy.clone()), + other => Err(error::type_mismatch( + "attenuate :returns field policy base", + ":self", + other, + )), + } +} + +fn parse_returns_policy( + value: &Val, +) -> Result>, Val> { + let methods = match value { + Val::Map(m) => m, + other => return Err(error::type_mismatch("attenuate :returns", "map", other)), + }; + + let mut out = BTreeMap::new(); + for (method_key, fields_val) in methods.iter() { + let method_name = canonical_member_name(&parse_policy_name( + method_key, + "attenuate :returns method key", + )?); + if out.contains_key(&method_name) { + return Err(error::internal( + "attenuate", + format!("duplicate :returns method key after canonicalization: {method_name}"), + )); + } + let fields = match fields_val { + Val::Map(m) => m, + other => { + return Err(error::type_mismatch( + "attenuate :returns method value", + "map", + other, + )) } - other => return Err(error::type_mismatch("attenuate method", "keyword", other)), + }; + let mut parsed_fields = BTreeMap::new(); + for (field_key, field_policy_val) in fields.iter() { + let field_name = canonical_member_name(&parse_policy_name( + field_key, + "attenuate :returns field key", + )?); + if parsed_fields.contains_key(&field_name) { + return Err(error::internal( + "attenuate", + format!( + "duplicate :returns field key after canonicalization for method {method_name}: {field_name}" + ), + )); + } + let field_policy = parse_self_return_policy(field_policy_val)?; + parsed_fields.insert(field_name, field_policy); } + out.insert(method_name, parsed_fields); + } + Ok(out) +} + +fn intersect_return_policies( + existing: &BTreeMap>, + incoming: &BTreeMap>, +) -> BTreeMap> { + let mut out = existing.clone(); + for (method, incoming_fields) in incoming { + if let Some(existing_fields) = out.get_mut(method) { + for (field, incoming_policy) in incoming_fields { + if let Some(existing_policy) = existing_fields.get_mut(field) { + *existing_policy = + intersect_attenuation_policy(existing_policy, incoming_policy); + } else { + existing_fields.insert(field.clone(), incoming_policy.clone()); + } + } + } else { + out.insert(method.clone(), incoming_fields.clone()); + } + } + out +} + +fn intersect_attenuation_policy( + existing: &AttenuationPolicy, + incoming: &AttenuationPolicy, +) -> AttenuationPolicy { + let allow_methods = existing + .allow_methods + .intersection(&incoming.allow_methods) + .cloned() + .collect(); + let returns = intersect_return_policies(&existing.returns, &incoming.returns); + AttenuationPolicy { + allow_methods, + returns, } - Ok(allow) } fn is_authority_free(value: &Val) -> bool { @@ -2063,20 +2274,20 @@ pub fn eval_expr<'a, D: Dispatch>( return Ok(cap); } - // Special form: (attenuate cap [:method ...]) + // Special form: + // (attenuate cap [:method ...]) + // (attenuate cap :allow [:method ...] :returns {...}) if head == "attenuate" { - if args.len() != 2 { - return Err(error::arity("attenuate", "2", args.len())); + if args.len() < 2 { + return Err(error::arity("attenuate", "at least 2", args.len())); } let cap_val = eval_expr(&args[0], env, dispatch).await?; - let allow_val = eval_expr(&args[1], env, dispatch).await?; - let mut allow_methods = parse_allow_methods(&allow_val)?; - let (name, schema_cid, base, nested_allow): ( + let (name, schema_cid, base, existing_policy): ( String, String, Val, - Option>, + Option, ) = match &cap_val { Val::Cap { name, @@ -2085,32 +2296,130 @@ pub fn eval_expr<'a, D: Dispatch>( .. } => { if let Some(inner_att) = inner.downcast_ref::() { + if matches!(&inner_att.base, Val::Keyword(k) if k == "self") + && !env.allows_attenuate_self() + { + return Err(error::internal( + "attenuate", + ":self is only valid inside attenuate :returns", + )); + } ( name.clone(), schema_cid.clone(), inner_att.base.clone(), - Some(inner_att.allow_methods.clone()), + Some(inner_att.policy.clone()), ) } else { (name.clone(), schema_cid.clone(), cap_val.clone(), None) } } + Val::Keyword(k) if k == "self" => { + if !env.allows_attenuate_self() { + return Err(error::internal( + "attenuate", + ":self is only valid inside attenuate :returns", + )); + } + ( + "self".into(), + "glia:self:v1".into(), + Val::Keyword("self".into()), + None, + ) + } other => { return Err(error::type_mismatch("attenuate first arg", "cap", other)) } }; - if let Some(existing) = nested_allow { - allow_methods = allow_methods.intersection(&existing).cloned().collect(); - } + let incoming_policy = if args.len() == 2 { + let allow_val = eval_expr(&args[1], env, dispatch).await?; + AttenuationPolicy { + allow_methods: parse_allow_methods(&allow_val)?, + returns: BTreeMap::new(), + } + } else { + if (args.len() - 1) % 2 != 0 { + return Err(error::internal( + "attenuate", + "keyword form expects :allow/:returns key-value pairs", + )); + } + let mut allow_methods: Option> = None; + let mut saw_returns = false; + let mut returns = + BTreeMap::>::new(); + for pair in args[1..].chunks(2) { + let key = eval_expr(&pair[0], env, dispatch).await?; + let key = match key { + Val::Keyword(k) => k, + other => { + return Err(error::type_mismatch( + "attenuate option key", + "keyword", + &other, + )) + } + }; + match key.as_str() { + "allow" => { + if allow_methods.is_some() { + return Err(error::internal( + "attenuate", + "duplicate :allow option", + )); + } + let allow_val = eval_expr(&pair[1], env, dispatch).await?; + allow_methods = Some(parse_allow_methods(&allow_val)?); + } + "returns" => { + if saw_returns { + return Err(error::internal( + "attenuate", + "duplicate :returns option", + )); + } + saw_returns = true; + env.enter_attenuate_self_scope(); + let returns_result = eval_expr(&pair[1], env, dispatch).await; + env.exit_attenuate_self_scope(); + let returns_val = returns_result?; + returns = parse_returns_policy(&returns_val)?; + } + other => { + return Err(error::internal( + "attenuate", + format!( + "unknown option :{other}; expected :allow and optional :returns" + ), + )) + } + } + } + let allow_methods = allow_methods.ok_or_else(|| { + error::internal("attenuate", "keyword form requires :allow option") + })?; + AttenuationPolicy { + allow_methods, + returns, + } + }; + + let policy = if let Some(existing) = existing_policy { + intersect_attenuation_policy(&existing, &incoming_policy) + } else { + incoming_policy + }; - let descriptor = cap_descriptor_bytes(&name, &schema_cid, &allow_methods); + let descriptor = + cap_descriptor_bytes(&name, &schema_cid, &policy.allow_methods); return Ok(make_cap( name, schema_cid, Rc::new(AttenuatedCapInner { base, - allow_methods, + policy, descriptor, }), )); @@ -2252,6 +2561,60 @@ pub fn eval_expr<'a, D: Dispatch>( return eval_expr(&body_expr, &mut isolate_env, &restricted).await; } + // Special form: (eval "
" | " ...") + // + // Parses one or more forms from a string and evaluates them + // in the current environment. Returns the last result. + if head == "eval" { + if args.len() != 1 { + return Err(error::arity("eval", "1", args.len())); + } + let code_val = eval_expr(&args[0], env, dispatch).await?; + let code = match code_val { + Val::Str(s) => s, + other => return Err(error::type_mismatch("eval", "string", &other)), + }; + let forms = + crate::read_many(&code).map_err(|e| error::parse(None, e.to_string()))?; + let mut last = Val::Nil; + for form in forms { + let analyzed = expr::analyze(&form)?; + last = eval_expr(&analyzed, env, dispatch).await?; + } + return Ok(last); + } + + // Special form: (load-file "") + // + // Reads a glia source file, parses all forms, and evaluates + // them in the current environment. Returns the last result. + if head == "load-file" { + if args.len() != 1 { + return Err(error::arity("load-file", "1", args.len())); + } + let path_val = eval_expr(&args[0], env, dispatch).await?; + let path = match path_val { + Val::Str(s) => s, + other => { + return Err(error::type_mismatch("load-file path", "string", &other)) + } + }; + let resolved = resolve_guest_fs_path(&path) + .map_err(|e| error::internal("load-file", e))?; + let bytes = std::fs::read(&resolved) + .map_err(|e| error::internal("load-file", format!("{resolved}: {e}")))?; + let source = std::str::from_utf8(&bytes) + .map_err(|e| error::internal("load-file", format!("{resolved}: {e}")))?; + let forms = crate::read_many(source) + .map_err(|e| error::parse(None, format!("{resolved}: {e}")))?; + let mut last = Val::Nil; + for form in forms { + let analyzed = expr::analyze(&form)?; + last = eval_expr(&analyzed, env, dispatch).await?; + } + return Ok(last); + } + // 1. Check for macro expansion if let Some(Val::Macro { arities, @@ -2529,7 +2892,8 @@ async fn perform_cap_value<'a, D: Dispatch>( if let Some(attenuated) = inner.downcast_ref::() { let (method, _) = cap_method_and_args(&payload, "perform (attenuated cap)")?; - if !attenuated.allow_methods.contains(&method) { + let canonical_method = canonical_member_name(&method); + if !attenuated.policy.allow_methods.contains(&canonical_method) { return Err(error::permission_denied( &format!("method :{method} denied by attenuation policy on '{name}'"), None, @@ -2898,6 +3262,7 @@ async fn perform_dispatch( #[cfg(test)] mod tests { use super::*; + static WW_ROOT_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); /// A trivial dispatcher that records calls and returns nil. /// Uses RefCell for interior mutability (Dispatch takes &self). @@ -2967,6 +3332,71 @@ mod tests { } } + #[test] + fn resolve_guest_fs_path_honors_ww_root() { + let _guard = WW_ROOT_TEST_LOCK.lock().unwrap(); + let ww_root = std::env::temp_dir().join(format!( + "glia-ww-root-{}-{}", + std::process::id(), + GENSYM_COUNTER.fetch_add(1, Ordering::SeqCst) + )); + std::fs::create_dir_all(&ww_root).unwrap(); + std::env::set_var("WW_ROOT", &ww_root); + let resolved = resolve_guest_fs_path("/lib/init/default.glia").unwrap(); + std::env::remove_var("WW_ROOT"); + let expected = ww_root.join("lib/init/default.glia"); + assert_eq!(resolved, expected.to_string_lossy().to_string()); + let _ = std::fs::remove_dir_all(&ww_root); + } + + #[test] + fn resolve_guest_fs_path_defaults_to_rooted_path_without_ww_root() { + let _guard = WW_ROOT_TEST_LOCK.lock().unwrap(); + std::env::remove_var("WW_ROOT"); + assert_eq!( + resolve_guest_fs_path("lib/init/default.glia").unwrap(), + "/lib/init/default.glia".to_string() + ); + } + + #[test] + fn resolve_guest_fs_path_rejects_ww_root_escape() { + let _guard = WW_ROOT_TEST_LOCK.lock().unwrap(); + let ww_root = std::env::temp_dir().join(format!( + "glia-ww-root-escape-{}-{}", + std::process::id(), + GENSYM_COUNTER.fetch_add(1, Ordering::SeqCst) + )); + std::fs::create_dir_all(&ww_root).unwrap(); + std::env::set_var("WW_ROOT", &ww_root); + let err = resolve_guest_fs_path("/../../etc/shadow").unwrap_err(); + std::env::remove_var("WW_ROOT"); + assert!(err.contains("path escapes WW_ROOT"), "got: {err}"); + let _ = std::fs::remove_dir_all(&ww_root); + } + + #[cfg(unix)] + #[test] + fn resolve_guest_fs_path_rejects_symlink_escape() { + let _guard = WW_ROOT_TEST_LOCK.lock().unwrap(); + let ww_root = std::env::temp_dir().join(format!( + "glia-ww-root-symlink-{}-{}", + std::process::id(), + GENSYM_COUNTER.fetch_add(1, Ordering::SeqCst) + )); + std::fs::create_dir_all(&ww_root).unwrap(); + let link = ww_root.join("escape"); + std::os::unix::fs::symlink("/etc", &link).unwrap(); + + std::env::set_var("WW_ROOT", &ww_root); + let err = resolve_guest_fs_path("/escape/passwd").unwrap_err(); + std::env::remove_var("WW_ROOT"); + + assert!(err.contains("symlink traversal"), "got: {err}"); + let _ = std::fs::remove_file(&link); + let _ = std::fs::remove_dir_all(&ww_root); + } + // --- Env tests --- #[test] @@ -6622,6 +7052,31 @@ mod tests { assert!(err_contains(&denied.unwrap_err(), "denied")); } + #[test] + fn attenuate_allow_matches_canonicalized_method_names() { + let mut env = Env::new(); + let d = RecordingDispatch::new(); + env.set("svc".into(), make_test_cap("svc", 1)); + + let kebab_policy_camel_call = eval_str( + "(with-effect-handler svc (fn [data] :ok) + (let [svc-ro (attenuate svc [:stream-dialer])] + (perform svc-ro :streamDialer 1)))", + &mut env, + &d, + ); + assert_eq!(kebab_policy_camel_call, Ok(Val::Keyword("ok".into()))); + + let snake_policy_kebab_call = eval_str( + "(with-effect-handler svc (fn [data] :ok) + (let [svc-ro (attenuate svc [:stream_dialer])] + (perform svc-ro :stream-dialer 1)))", + &mut env, + &d, + ); + assert_eq!(snake_policy_kebab_call, Ok(Val::Keyword("ok".into()))); + } + #[test] fn attenuate_nested_intersection() { let mut env = Env::new(); @@ -6640,6 +7095,180 @@ mod tests { assert!(err_contains(&denied.unwrap_err(), "denied")); } + #[test] + fn attenuate_keyword_form_parses_recursive_returns() { + let mut env = Env::new(); + let d = RecordingDispatch::new(); + env.set("svc".into(), make_test_cap("svc", 1)); + let result = eval_str( + "(attenuate svc + :allow [:network] + :returns {:network {:streamDialer (attenuate :self [:dial])}})", + &mut env, + &d, + ) + .expect("attenuate keyword form should evaluate"); + let Val::Cap { inner, .. } = result else { + panic!("expected cap"); + }; + let att = inner + .downcast_ref::() + .expect("expected attenuated cap"); + assert!(att.policy.allow_methods.contains("network")); + let network_fields = att.policy.returns.get("network").expect("network policy"); + let stream_dialer = network_fields + .get("streamDialer") + .expect("streamDialer return policy"); + assert!(stream_dialer.allow_methods.contains("dial")); + } + + #[test] + fn attenuate_self_rejected_outside_returns_scope() { + let mut env = Env::new(); + let d = RecordingDispatch::new(); + let err = eval_str("(attenuate :self [:dial])", &mut env, &d).unwrap_err(); + assert!(err_contains( + &err, + ":self is only valid inside attenuate :returns" + )); + } + + #[test] + fn attenuate_self_cannot_escape_returns_scope_via_binding() { + let mut env = Env::new(); + let d = RecordingDispatch::new(); + env.set("svc".into(), make_test_cap("svc", 1)); + eval_str( + "(attenuate svc + :allow [:network] + :returns (do + (def leaked-self (attenuate :self [:dial])) + {:network {:streamDialer leaked-self}}))", + &mut env, + &d, + ) + .expect("setup attenuate should succeed"); + let err = eval_str("(attenuate leaked-self [:dial])", &mut env, &d).unwrap_err(); + assert!(err_contains( + &err, + ":self is only valid inside attenuate :returns" + )); + } + + #[test] + fn attenuate_empty_allow_is_explicit_deny_all() { + let mut env = Env::new(); + let d = RecordingDispatch::new(); + let cap = make_test_cap("svc", 1); + env.set("svc".into(), cap); + let denied = eval_str( + "(with-effect-handler svc (fn [data] :ok) + (let [svc-none (attenuate svc [])] + (perform svc-none :run 1)))", + &mut env, + &d, + ); + assert!(denied.is_err()); + assert!(err_contains(&denied.unwrap_err(), "denied")); + } + + #[test] + fn attenuate_recursive_intersection_does_not_widen() { + let mut env = Env::new(); + let d = RecordingDispatch::new(); + env.set("svc".into(), make_test_cap("svc", 1)); + let result = eval_str( + "(let [a1 (attenuate svc + :allow [:network] + :returns {:network {:streamDialer (attenuate :self [:dial :close])}}) + a2 (attenuate a1 + :allow [:network :id] + :returns {:network {:streamDialer (attenuate :self [:dial]) + :vatClient (attenuate :self [:dial])}})] + a2)", + &mut env, + &d, + ) + .expect("nested attenuate should evaluate"); + let Val::Cap { inner, .. } = result else { + panic!("expected cap"); + }; + let att = inner + .downcast_ref::() + .expect("expected attenuated cap"); + assert!(att.policy.allow_methods.contains("network")); + assert!(!att.policy.allow_methods.contains("id")); + let network_fields = att.policy.returns.get("network").expect("network policy"); + assert!(network_fields.contains_key("streamDialer")); + assert!(network_fields.contains_key("vatClient")); + let stream_dialer = network_fields + .get("streamDialer") + .expect("streamDialer return policy"); + assert!(stream_dialer.allow_methods.contains("dial")); + assert!(!stream_dialer.allow_methods.contains("close")); + let vat_client = network_fields + .get("vatClient") + .expect("vatClient return policy"); + assert!(vat_client.allow_methods.contains("dial")); + } + + #[test] + fn attenuate_rejects_duplicate_returns_even_when_first_empty() { + let mut env = Env::new(); + let d = RecordingDispatch::new(); + env.set("svc".into(), make_test_cap("svc", 1)); + let err = eval_str( + "(attenuate svc + :allow [:network] + :returns {} + :returns {:network {:streamDialer (attenuate :self [:dial])}})", + &mut env, + &d, + ) + .unwrap_err(); + assert!(err_contains(&err, "duplicate :returns option")); + } + + #[test] + fn attenuate_rejects_duplicate_returns_method_keys_after_canonicalization() { + let mut env = Env::new(); + let d = RecordingDispatch::new(); + env.set("svc".into(), make_test_cap("svc", 1)); + let err = eval_str( + "(attenuate svc + :allow [:network] + :returns {:stream_dialer {:cap (attenuate :self [:dial])} + :stream-dialer {:cap (attenuate :self [:dial])}})", + &mut env, + &d, + ) + .unwrap_err(); + assert!(err_contains( + &err, + "duplicate :returns method key after canonicalization" + )); + } + + #[test] + fn attenuate_rejects_duplicate_returns_field_keys_after_canonicalization() { + let mut env = Env::new(); + let d = RecordingDispatch::new(); + env.set("svc".into(), make_test_cap("svc", 1)); + let err = eval_str( + "(attenuate svc + :allow [:network] + :returns {:network {:stream_dialer (attenuate :self [:dial]) + :stream-dialer (attenuate :self [:dial])}})", + &mut env, + &d, + ) + .unwrap_err(); + assert!(err_contains( + &err, + "duplicate :returns field key after canonicalization" + )); + } + #[test] fn isolate_blocks_ambient_dispatch() { let mut env = Env::new(); diff --git a/crates/glia/src/lib.rs b/crates/glia/src/lib.rs index fac25ca5..33699636 100644 --- a/crates/glia/src/lib.rs +++ b/crates/glia/src/lib.rs @@ -29,7 +29,7 @@ pub mod oneshot; pub mod pattern; pub mod valmap; -use std::collections::{BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::rc::Rc; use std::sync::atomic::{AtomicU64, Ordering}; pub use valmap::ValMap; @@ -61,11 +61,18 @@ pub struct GliaCapInner { pub descriptor: Vec, } +/// Internal representation for attenuated capabilities created by `attenuate`. +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct AttenuationPolicy { + pub allow_methods: BTreeSet, + pub returns: BTreeMap>, +} + /// Internal representation for attenuated capabilities created by `attenuate`. #[derive(Clone)] pub struct AttenuatedCapInner { pub base: Val, - pub allow_methods: BTreeSet, + pub policy: AttenuationPolicy, pub descriptor: Vec, } diff --git a/crates/rpc/Cargo.toml b/crates/rpc/Cargo.toml index 3efe9677..a9b73a5f 100644 --- a/crates/rpc/Cargo.toml +++ b/crates/rpc/Cargo.toml @@ -7,7 +7,6 @@ edition = "2021" anyhow = "1" async-trait = "0.1" base58 = "0.2" -base64 = "0.22" blake3 = "1" bytes = "1" capnp = "0.25.3" diff --git a/crates/rpc/src/dispatch.rs b/crates/rpc/src/dispatch.rs index c7111df9..a5a58d80 100644 --- a/crates/rpc/src/dispatch.rs +++ b/crates/rpc/src/dispatch.rs @@ -17,12 +17,6 @@ pub struct CgiRequest { pub query: String, pub headers: Vec<(String, String)>, pub body: Vec, - /// JFS-verified `X-Snap-Payload` data, if the request carried a - /// valid one. `None` means no header, or verification failed - /// (currently logged-warn-and-drop in v1.0; v1.1 will return 4xx - /// per spec). Cells consume this through CGI env vars emitted by - /// `crate::wagi::build_cgi_env`. - pub verified_snap: Option, pub response_tx: oneshot::Sender, } diff --git a/crates/rpc/src/http_listener.rs b/crates/rpc/src/http_listener.rs index ad6dc3df..8f472cb7 100644 --- a/crates/rpc/src/http_listener.rs +++ b/crates/rpc/src/http_listener.rs @@ -162,7 +162,6 @@ async fn spawn_and_run( &req.headers, &server_name, server_port, - req.verified_snap.as_ref(), ); let mut spawn_req = executor.spawn_request(); diff --git a/crates/rpc/src/jfs.rs b/crates/rpc/src/jfs.rs deleted file mode 100644 index 75c8637e..00000000 --- a/crates/rpc/src/jfs.rs +++ /dev/null @@ -1,523 +0,0 @@ -//! JSON Farcaster Signatures (JFS) verification for Snap requests. -//! -//! JFS spec: https://github.com/farcasterxyz/protocol/discussions/208 -//! Snap auth: https://docs.farcaster.xyz/snap/auth -//! -//! Wire format is JWT-style compact serialization: -//! -//! BASE64URL(header) . BASE64URL(payload) . BASE64URL(signature) -//! -//! sent in the `X-Snap-Payload` HTTP header. The signing input is -//! -//! ASCII(BASE64URL(header) || '.' || BASE64URL(payload)) -//! -//! signed with EdDSA. The header carries `{fid, type, key}`; the payload -//! carries `{fid, inputs, audience, timestamp, user, surface}`. Servers -//! MUST reject expired (>5 min skew by default) or audience-mismatched -//! payloads. POST requests REQUIRE a valid header; GET treats it as -//! best-effort viewer identity. -//! -//! What this module does in v1.0: -//! 1. Parse the compact serialization (split on `.`, base64url-decode). -//! 2. Reconstruct the signing input and verify the EdDSA signature -//! against the embedded `key` (32-byte Ed25519 pubkey, hex). -//! 3. Check the timestamp is within ±5 min of now (configurable). -//! 4. Check the audience matches the server's expected origin. -//! -//! What this module does NOT do in v1.0 (logged as v1.1 follow-up): -//! - Query a Farcaster Hub to confirm the embedded key is currently -//! registered to the claimed FID. Without that, an attacker can -//! sign a payload claiming to be FID 1 with their own keypair and -//! the signature verifies. The FID is therefore CLAIMED, not -//! Hub-verified. Cells SHOULD treat the FID as untrusted identity -//! until v1.1 ships Hub verification. - -use anyhow::{anyhow, bail, Context, Result}; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use base64::Engine; -use ed25519_dalek::{Signature, Verifier, VerifyingKey}; -use serde::Deserialize; - -/// Default replay window: ±5 minutes per spec. -pub const DEFAULT_TIMESTAMP_SKEW_SECS: i64 = 5 * 60; - -/// JFS header (decoded from BASE64URL(header)). Carries the signing key -/// metadata the server uses to verify the signature. -#[derive(Debug, Deserialize)] -struct JfsHeader { - /// Claimed Farcaster ID of the signer. NOT Hub-verified in v1.0. - fid: u64, - /// Key type. Snaps use `app_key` with EdDSA. We accept only this. - #[serde(rename = "type")] - key_type: String, - /// Hex-encoded 32-byte Ed25519 public key. - key: String, -} - -/// JFS payload (decoded from BASE64URL(payload)). What the snap cell -/// receives as verified-context input. -/// -/// Field naming matches the spec exactly. Using `serde_json::Value` for -/// `inputs` and `surface` lets us pass them through to cells without -/// constraining their shape. -#[derive(Debug, Deserialize, Clone)] -pub struct JfsPayload { - /// FID claimed in the payload. MUST match the header's `fid`. - pub fid: u64, - /// Form inputs / button-press values from the user. - #[serde(default)] - pub inputs: serde_json::Value, - /// Server origin this payload is intended for (scheme + host + port). - pub audience: String, - /// Unix epoch seconds when the payload was signed. - pub timestamp: i64, - /// Viewer identity. `{ "fid": }`, possibly with extra fields. - #[serde(default)] - pub user: serde_json::Value, - /// Render context (e.g. `{"type": "standalone"}`). - #[serde(default)] - pub surface: serde_json::Value, -} - -/// Successful JFS verification result. Returned to the listener so it -/// can plumb verified-context CGI env vars into the cell. -#[derive(Debug, Clone)] -pub struct VerifiedJfs { - pub payload: JfsPayload, - /// The original BASE64URL(payload) string, kept verbatim so the - /// listener can pass it through to the cell unmangled. - pub payload_b64url: String, -} - -/// Split a JFS compact serialization on `.` into its three parts. -/// -/// Pure parsing — does no crypto. Separated for unit testability. -fn split_compact(s: &str) -> Result<(&str, &str, &str)> { - let parts: Vec<&str> = s.split('.').collect(); - if parts.len() != 3 { - bail!( - "JFS compact serialization must have exactly 3 dot-separated parts, got {}", - parts.len() - ); - } - if parts.iter().any(|p| p.is_empty()) { - bail!("JFS compact serialization parts must be non-empty"); - } - Ok((parts[0], parts[1], parts[2])) -} - -/// Decode a 32-byte Ed25519 public key from a hex string. -/// -/// Accepts an optional `0x` prefix. -fn parse_pubkey(hex_str: &str) -> Result { - let hex_str = hex_str.strip_prefix("0x").unwrap_or(hex_str); - let bytes = hex::decode(hex_str).context("pubkey is not valid hex")?; - let arr: [u8; 32] = bytes - .as_slice() - .try_into() - .map_err(|_| anyhow!("pubkey must be 32 bytes, got {}", bytes.len()))?; - VerifyingKey::from_bytes(&arr).context("pubkey bytes are not a valid Ed25519 point") -} - -/// Verify a JFS compact-serialized payload from `X-Snap-Payload`. -/// -/// Returns the decoded payload + its base64url string for passthrough. -/// -/// `expected_audience` is the server origin the request was intended for -/// (e.g. `"https://master.wetware.run"`); audience mismatch is rejected. -/// `now_unix_secs` is the current time; payloads outside `±skew_secs` -/// are rejected. Inject the clock to keep verification testable. -pub fn verify( - compact: &str, - expected_audience: &str, - now_unix_secs: i64, - skew_secs: i64, -) -> Result { - // 1. Split into parts. - let (header_b64, payload_b64, sig_b64) = split_compact(compact)?; - - // 2. Decode the header. Establishes the verifying key. - let header_bytes = URL_SAFE_NO_PAD - .decode(header_b64) - .context("header is not valid base64url")?; - let header: JfsHeader = - serde_json::from_slice(&header_bytes).context("header is not valid JSON")?; - if header.key_type != "app_key" { - bail!( - "JFS key type must be 'app_key' for snap auth, got {:?}", - header.key_type - ); - } - let pubkey = parse_pubkey(&header.key)?; - - // 3. Decode the signature. - let sig_bytes = URL_SAFE_NO_PAD - .decode(sig_b64) - .context("signature is not valid base64url")?; - let sig_arr: [u8; 64] = sig_bytes - .as_slice() - .try_into() - .map_err(|_| anyhow!("signature must be 64 bytes, got {}", sig_bytes.len()))?; - let signature = Signature::from_bytes(&sig_arr); - - // 4. Verify signature over `BASE64URL(header) || '.' || BASE64URL(payload)`. - // Per JWS-style compact serialization (and the JFS spec), the - // signing input is the ASCII bytes of the b64url header + `.` + - // the b64url payload — we DO NOT decode payload before verifying. - let mut signing_input = Vec::with_capacity(header_b64.len() + 1 + payload_b64.len()); - signing_input.extend_from_slice(header_b64.as_bytes()); - signing_input.push(b'.'); - signing_input.extend_from_slice(payload_b64.as_bytes()); - pubkey - .verify(&signing_input, &signature) - .context("JFS signature verification failed")?; - - // 5. Decode the payload (after sig verify, so we don't waste work - // on tampered payloads). - let payload_bytes = URL_SAFE_NO_PAD - .decode(payload_b64) - .context("payload is not valid base64url")?; - let payload: JfsPayload = - serde_json::from_slice(&payload_bytes).context("payload is not valid JSON")?; - - // 6. Header.fid MUST match payload.fid. Spec consistency check - // (also closes a confused-deputy hole where the header claims - // one FID but the payload carries another). - if header.fid != payload.fid { - bail!( - "JFS header.fid ({}) does not match payload.fid ({})", - header.fid, - payload.fid - ); - } - - // 7. Audience check: prevents a payload signed for snap A from - // being replayed against snap B. Spec MUST. - if payload.audience != expected_audience { - bail!( - "audience mismatch: payload audience {:?} but server expected {:?}", - payload.audience, - expected_audience - ); - } - - // 8. Timestamp window check: replay protection. Spec default 5 min. - let age = now_unix_secs - payload.timestamp; - if age.abs() > skew_secs { - bail!( - "timestamp outside skew window: payload age {}s exceeds limit {}s", - age, - skew_secs - ); - } - - Ok(VerifiedJfs { - payload, - payload_b64url: payload_b64.to_string(), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use ed25519_dalek::{Signer, SigningKey}; - - fn b64u(bytes: &[u8]) -> String { - URL_SAFE_NO_PAD.encode(bytes) - } - - /// Generate a fresh signing key for tests. Uses the OS CSPRNG via - /// `try_fill_bytes` rather than `SigningKey::generate(&mut OsRng)` - /// because the host workspace pulls multiple `rand_core` versions - /// and `OsRng` doesn't implement ed25519-dalek's `CryptoRngCore` - /// directly (same workaround as `src/keys.rs::generate`). - fn generate_test_key() -> SigningKey { - use rand::TryRngCore; - let mut secret_bytes = [0u8; 32]; - rand::rngs::OsRng - .try_fill_bytes(&mut secret_bytes) - .expect("OS CSPRNG"); - SigningKey::from_bytes(&secret_bytes) - } - - /// Build a valid JFS compact serialization for tests. Returns - /// (compact, signing_key, fid, audience, timestamp). - fn make_jfs( - fid: u64, - audience: &str, - timestamp: i64, - inputs: serde_json::Value, - ) -> (String, SigningKey, u64, String, i64) { - let signing_key = generate_test_key(); - let pubkey_hex = hex::encode(signing_key.verifying_key().to_bytes()); - - let header_json = serde_json::json!({ - "fid": fid, - "type": "app_key", - "key": pubkey_hex, - }); - let payload_json = serde_json::json!({ - "fid": fid, - "inputs": inputs, - "audience": audience, - "timestamp": timestamp, - "user": { "fid": fid }, - "surface": { "type": "standalone" }, - }); - - let header_b64 = b64u(serde_json::to_string(&header_json).unwrap().as_bytes()); - let payload_b64 = b64u(serde_json::to_string(&payload_json).unwrap().as_bytes()); - let signing_input = format!("{header_b64}.{payload_b64}"); - let sig = signing_key.sign(signing_input.as_bytes()); - let sig_b64 = b64u(&sig.to_bytes()); - - ( - format!("{header_b64}.{payload_b64}.{sig_b64}"), - signing_key, - fid, - audience.to_string(), - timestamp, - ) - } - - #[test] - fn verify_happy_path() { - let now = 1_700_000_000; - let (compact, _sk, fid, audience, _ts) = make_jfs( - 12345, - "https://snap.example.com", - now, - serde_json::json!({}), - ); - let verified = - verify(&compact, &audience, now, DEFAULT_TIMESTAMP_SKEW_SECS).expect("verify"); - assert_eq!(verified.payload.fid, fid); - assert_eq!(verified.payload.audience, audience); - } - - #[test] - fn verify_inputs_passed_through() { - let now = 1_700_000_000; - let (compact, _, _, audience, _) = make_jfs( - 42, - "https://x.example", - now, - serde_json::json!({"button": "yes", "name": "alice"}), - ); - let verified = verify(&compact, &audience, now, DEFAULT_TIMESTAMP_SKEW_SECS).unwrap(); - assert_eq!(verified.payload.inputs["button"], "yes"); - assert_eq!(verified.payload.inputs["name"], "alice"); - } - - #[test] - fn verify_split_rejects_wrong_part_count() { - let err = verify( - "onlyone", - "https://x.example", - 1_700_000_000, - DEFAULT_TIMESTAMP_SKEW_SECS, - ) - .unwrap_err(); - assert!(err.to_string().contains("3 dot-separated parts")); - } - - #[test] - fn verify_split_rejects_empty_parts() { - let err = verify( - "a..c", - "https://x.example", - 1_700_000_000, - DEFAULT_TIMESTAMP_SKEW_SECS, - ) - .unwrap_err(); - assert!(err.to_string().contains("non-empty")); - } - - #[test] - fn verify_rejects_wrong_audience() { - let now = 1_700_000_000; - let (compact, _, _, _, _) = - make_jfs(1, "https://right.example", now, serde_json::json!({})); - let err = verify( - &compact, - "https://wrong.example", - now, - DEFAULT_TIMESTAMP_SKEW_SECS, - ) - .unwrap_err(); - assert!(err.to_string().contains("audience mismatch")); - } - - #[test] - fn verify_rejects_expired() { - let signed_at = 1_700_000_000; - let now = signed_at + 600; // 10 min later, > 5 min skew - let (compact, _, _, audience, _) = - make_jfs(1, "https://x.example", signed_at, serde_json::json!({})); - let err = verify(&compact, &audience, now, DEFAULT_TIMESTAMP_SKEW_SECS).unwrap_err(); - assert!(err.to_string().contains("timestamp outside skew window")); - } - - #[test] - fn verify_rejects_future_timestamp_outside_skew() { - // Symmetric: payload from the "future" outside skew is also rejected - // (clock skew between client and server, or simply a forged - // timestamp). - let now = 1_700_000_000; - let signed_at = now + 600; // 10 min ahead - let (compact, _, _, audience, _) = - make_jfs(1, "https://x.example", signed_at, serde_json::json!({})); - let err = verify(&compact, &audience, now, DEFAULT_TIMESTAMP_SKEW_SECS).unwrap_err(); - assert!(err.to_string().contains("timestamp outside skew window")); - } - - #[test] - fn verify_accepts_within_skew_window() { - let signed_at = 1_700_000_000; - let now = signed_at + 60; // 1 min later, well within 5 min skew - let (compact, _, _, audience, _) = - make_jfs(1, "https://x.example", signed_at, serde_json::json!({})); - verify(&compact, &audience, now, DEFAULT_TIMESTAMP_SKEW_SECS).expect("within skew"); - } - - #[test] - fn verify_rejects_tampered_payload() { - // Build a valid JFS, then swap in a different payload (signed - // for the original) — signature should fail to verify. - let now = 1_700_000_000; - let (compact, _, _, _, _) = make_jfs(1, "https://x.example", now, serde_json::json!({})); - let parts: Vec<&str> = compact.split('.').collect(); - let evil_payload = - b64u(br#"{"fid":1,"inputs":{},"audience":"https://x.example","timestamp":1700000000,"user":{"fid":1},"surface":{"type":"standalone"},"injected":"evil"}"#); - let tampered = format!("{}.{}.{}", parts[0], evil_payload, parts[2]); - let err = verify( - &tampered, - "https://x.example", - now, - DEFAULT_TIMESTAMP_SKEW_SECS, - ) - .unwrap_err(); - assert!(err.to_string().contains("signature verification failed")); - } - - #[test] - fn verify_rejects_tampered_header() { - let now = 1_700_000_000; - let (compact, _, _, _, _) = make_jfs(1, "https://x.example", now, serde_json::json!({})); - let parts: Vec<&str> = compact.split('.').collect(); - // Re-encode a header with a different FID but same key. - let header_decoded = URL_SAFE_NO_PAD.decode(parts[0]).unwrap(); - let mut header: serde_json::Value = serde_json::from_slice(&header_decoded).unwrap(); - header["fid"] = serde_json::json!(99); - let evil_header = b64u(serde_json::to_string(&header).unwrap().as_bytes()); - let tampered = format!("{}.{}.{}", evil_header, parts[1], parts[2]); - let err = verify( - &tampered, - "https://x.example", - now, - DEFAULT_TIMESTAMP_SKEW_SECS, - ) - .unwrap_err(); - // Tampering the header changes the signing input → signature fails. - assert!(err.to_string().contains("signature verification failed")); - } - - #[test] - fn verify_rejects_header_payload_fid_mismatch() { - // Forge a JFS where header.fid != payload.fid but signature is - // valid (signed with our own key over the mismatched pair). - // Should be rejected on the consistency check at step 6. - let signing_key = generate_test_key(); - let pubkey_hex = hex::encode(signing_key.verifying_key().to_bytes()); - - let header_json = serde_json::json!({ - "fid": 1, - "type": "app_key", - "key": pubkey_hex, - }); - let payload_json = serde_json::json!({ - "fid": 999, - "inputs": {}, - "audience": "https://x.example", - "timestamp": 1_700_000_000, - "user": {"fid": 999}, - "surface": {"type": "standalone"}, - }); - let header_b64 = b64u(serde_json::to_string(&header_json).unwrap().as_bytes()); - let payload_b64 = b64u(serde_json::to_string(&payload_json).unwrap().as_bytes()); - let signing_input = format!("{header_b64}.{payload_b64}"); - let sig = signing_key.sign(signing_input.as_bytes()); - let sig_b64 = b64u(&sig.to_bytes()); - let compact = format!("{header_b64}.{payload_b64}.{sig_b64}"); - - let err = verify( - &compact, - "https://x.example", - 1_700_000_000, - DEFAULT_TIMESTAMP_SKEW_SECS, - ) - .unwrap_err(); - assert!(err.to_string().contains("does not match payload.fid")); - } - - #[test] - fn verify_rejects_non_app_key_type() { - // Build a header with type="custody" instead of "app_key". - let signing_key = generate_test_key(); - let pubkey_hex = hex::encode(signing_key.verifying_key().to_bytes()); - let header_json = serde_json::json!({ - "fid": 1, - "type": "custody", - "key": pubkey_hex, - }); - let payload_json = serde_json::json!({ - "fid": 1, "inputs": {}, "audience": "https://x.example", - "timestamp": 1_700_000_000, "user": {"fid":1}, - "surface": {"type":"standalone"}, - }); - let h = b64u(serde_json::to_string(&header_json).unwrap().as_bytes()); - let p = b64u(serde_json::to_string(&payload_json).unwrap().as_bytes()); - let sig = signing_key.sign(format!("{h}.{p}").as_bytes()); - let s = b64u(&sig.to_bytes()); - let compact = format!("{h}.{p}.{s}"); - - let err = verify( - &compact, - "https://x.example", - 1_700_000_000, - DEFAULT_TIMESTAMP_SKEW_SECS, - ) - .unwrap_err(); - assert!(err.to_string().contains("must be 'app_key'")); - } - - #[test] - fn parse_pubkey_accepts_0x_prefix() { - let raw = generate_test_key().verifying_key(); - let with_prefix = format!("0x{}", hex::encode(raw.to_bytes())); - let parsed = parse_pubkey(&with_prefix).unwrap(); - assert_eq!(parsed.to_bytes(), raw.to_bytes()); - } - - #[test] - fn parse_pubkey_rejects_wrong_length() { - let err = parse_pubkey("aabb").unwrap_err(); - assert!(err.to_string().contains("32 bytes")); - } - - #[test] - fn split_compact_three_parts() { - let (a, b, c) = split_compact("aaa.bbb.ccc").unwrap(); - assert_eq!((a, b, c), ("aaa", "bbb", "ccc")); - } - - #[test] - fn payload_b64url_passthrough_preserved() { - let now = 1_700_000_000; - let (compact, _, _, audience, _) = - make_jfs(7, "https://x.example", now, serde_json::json!({})); - let original_payload_b64 = compact.split('.').nth(1).unwrap().to_string(); - let verified = verify(&compact, &audience, now, DEFAULT_TIMESTAMP_SKEW_SECS).unwrap(); - assert_eq!(verified.payload_b64url, original_payload_b64); - } -} diff --git a/crates/rpc/src/lib.rs b/crates/rpc/src/lib.rs index 458ff868..6cb068dd 100644 --- a/crates/rpc/src/lib.rs +++ b/crates/rpc/src/lib.rs @@ -8,7 +8,6 @@ pub mod dispatch; pub mod graft; pub mod http_client; pub mod http_listener; -pub mod jfs; pub mod keys; pub mod routing; pub mod stream_dialer; @@ -73,12 +72,32 @@ pub fn schema_cid(schema_bytes: &[u8]) -> String { cid::Cid::new_v1(0x55, mh).to_string() } +/// Derive CIDv1(raw, BLAKE3(canonical VatDescriptor bytes)). +pub fn descriptor_cid(descriptor_bytes: &[u8]) -> String { + schema_cid(descriptor_bytes) +} + /// Build a `StreamProtocol` from a schema CID string. pub fn schema_protocol(cid: &str) -> Result { StreamProtocol::try_from_owned(format!("/ww/0.1.0/vat/{cid}")) .map_err(|e| capnp::Error::failed(format!("invalid protocol from schema CID: {e}"))) } +/// Canonicalize a VatDescriptor reader into raw single-segment bytes. +pub fn canonicalize_vat_descriptor( + descriptor: system_capnp::vat_descriptor::Reader<'_>, +) -> Result, capnp::Error> { + let mut msg = capnp::message::Builder::new_default(); + msg.set_root_canonical(descriptor)?; + let segments = msg.get_segments_for_output(); + if segments.len() != 1 { + return Err(capnp::Error::failed( + "descriptor canonicalization produced unexpected segment layout".into(), + )); + } + Ok(segments[0].to_vec()) +} + /// Re-canonicalize a `Schema.Node` reader into raw single-segment bytes. /// /// Mirrors `crates/schema-id::canonicalize_node` and the build-time @@ -96,6 +115,33 @@ pub fn canonicalize_schema_node(node: capnp::schema_capnp::node::Reader<'_>) -> Some(segments[0].to_vec()) } +/// Canonicalize raw schema bytes expected to encode a `schema.Node`. +/// +/// This normalizes non-canonical but equivalent encodings so downstream CID +/// derivation and policy enforcement operate on deterministic bytes. +pub fn canonicalize_schema_bytes(schema_bytes: &[u8]) -> Result, capnp::Error> { + if schema_bytes.is_empty() { + return Err(capnp::Error::failed( + "schema bytes must not be empty".into(), + )); + } + + let word_count = schema_bytes.len().div_ceil(8); + let mut words = vec![capnp::word(0, 0, 0, 0, 0, 0, 0, 0); word_count]; + capnp::Word::words_to_bytes_mut(&mut words)[..schema_bytes.len()].copy_from_slice(schema_bytes); + let segments: &[&[u8]] = &[capnp::Word::words_to_bytes(&words)]; + let segment_array = capnp::message::SegmentArray::new(segments); + let reader = capnp::message::Reader::new(segment_array, capnp::message::ReaderOptions::new()); + let node: capnp::schema_capnp::node::Reader<'_> = reader + .get_root() + .map_err(|e| capnp::Error::failed(format!("invalid schema bytes: {e}")))?; + canonicalize_schema_node(node).ok_or_else(|| { + capnp::Error::failed( + "invalid schema bytes: canonicalization produced unexpected segment layout".into(), + ) + }) +} + /// Extract a custom section from a WASM binary (component or module). /// /// Returns the section data if found, or `None` if the section doesn't exist. @@ -126,7 +172,7 @@ pub(crate) fn extract_wasm_custom_section<'a>( /// is not yet implemented (see TODOS.md: FastCGI / HttpListener). #[derive(Debug)] #[allow(dead_code)] -pub(crate) enum CellType { +pub enum CellType { /// Raw libp2p stream with protocol ID. Raw(String), /// HTTP/WAGI cell with path prefix. @@ -142,7 +188,7 @@ pub(crate) enum CellType { /// section data is malformed. /// Used by tooling. Listeners use explicit params; custom sections are optional hints. #[allow(dead_code)] -pub(crate) fn decode_cell_section(wasm_bytes: &[u8]) -> Result, capnp::Error> { +pub fn decode_cell_section(wasm_bytes: &[u8]) -> Result, capnp::Error> { let section_data = match extract_wasm_custom_section(wasm_bytes, "cell.capnp")? { Some(data) if !data.is_empty() => data, Some(_) => { @@ -428,6 +474,7 @@ pub struct ProcessImpl { stderr: system_capnp::byte_stream::Client, exit_rx: Arc>>>, bootstrap_cap: Option, + bootstrap_schema: Option>, kill_tx: Arc>, } @@ -445,6 +492,7 @@ impl ProcessImpl { stderr, exit_rx: Arc::new(Mutex::new(Some(exit_rx))), bootstrap_cap: None, + bootstrap_schema: None, kill_tx: Arc::new(kill_tx), } } @@ -455,6 +503,7 @@ impl ProcessImpl { stderr: system_capnp::byte_stream::Client, exit_rx: tokio::sync::oneshot::Receiver, bootstrap_cap: capnp::capability::Client, + bootstrap_schema: Vec, kill_tx: tokio::sync::watch::Sender, ) -> Self { Self { @@ -463,6 +512,7 @@ impl ProcessImpl { stderr, exit_rx: Arc::new(Mutex::new(Some(exit_rx))), bootstrap_cap: Some(bootstrap_cap), + bootstrap_schema: Some(bootstrap_schema), kill_tx: Arc::new(kill_tx), } } @@ -519,13 +569,30 @@ impl system_capnp::process::Server for ProcessImpl { mut results: system_capnp::process::BootstrapResults, ) -> impl std::future::Future> + 'static { let cap = self.bootstrap_cap.clone(); + let schema_bytes = self.bootstrap_schema.clone(); Promise::from_future(async move { let cap = cap.ok_or_else(|| { capnp::Error::failed( "process did not export a bootstrap capability via system::serve()".into(), ) })?; - results.get().init_cap().set_as_capability(cap.hook); + let schema_bytes = schema_bytes.ok_or_else(|| { + capnp::Error::failed( + "process did not export bootstrap schema metadata via system::serve()".into(), + ) + })?; + let canonical_schema = canonicalize_schema_bytes(&schema_bytes)?; + let aligned = crate::graft::bytes_to_aligned_words(&canonical_schema); + let segments: &[&[u8]] = &[capnp::Word::words_to_bytes(&aligned)]; + let segment_array = capnp::message::SegmentArray::new(segments); + let reader = + capnp::message::Reader::new(segment_array, capnp::message::ReaderOptions::new()); + let schema_node: capnp::schema_capnp::node::Reader<'_> = reader.get_root()?; + let mut typed = results.get().init_typed(); + typed.reborrow().init_cap().set_as_capability(cap.hook); + let mut out_schema = typed.reborrow().init_schema(); + out_schema.set_root(schema_node)?; + out_schema.init_deps(0); Ok(()) }) } @@ -1055,13 +1122,19 @@ mod tests { stderr, exit_rx, host.client.clone(), + membrane::schema_registry::HOST_SCHEMA.to_vec(), kill_tx, ); let process = setup_process_rpc(process_impl); // Call bootstrap() — should return the stored cap. - let resp = process.bootstrap_request().send().promise.await.unwrap(); - let cap = resp.get().unwrap().get_cap(); + let resp = { + let req = process.bootstrap_request(); + req.send().promise + } + .await + .unwrap(); + let cap = resp.get().unwrap().get_typed().unwrap().get_cap(); // Cast it back to a Host and verify it works. let host2: system_capnp::host::Client = cap.get_as_capability().unwrap(); @@ -1082,11 +1155,15 @@ mod tests { let process = setup_process_rpc(process_impl); // Call bootstrap() without a stored cap — should error. - let result = process.bootstrap_request().send().promise.await; + let result = { + let req = process.bootstrap_request(); + req.send().promise + } + .await; assert!( result.is_err() || { let resp = result.unwrap(); - // The error may come from get_cap() trying to read a missing cap, + // The error may come from get_typed() trying to read a missing cap, // or from the server returning an error in the response. resp.get().is_err() } @@ -1109,14 +1186,20 @@ mod tests { stderr, exit_rx, host.client.clone(), + membrane::schema_registry::HOST_SCHEMA.to_vec(), kill_tx, ); let process = setup_process_rpc(process_impl); // Call bootstrap() twice — both should return working caps. for _ in 0..2 { - let resp = process.bootstrap_request().send().promise.await.unwrap(); - let cap = resp.get().unwrap().get_cap(); + let resp = { + let req = process.bootstrap_request(); + req.send().promise + } + .await + .unwrap(); + let cap = resp.get().unwrap().get_typed().unwrap().get_cap(); let host2: system_capnp::host::Client = cap.get_as_capability().unwrap(); let id_resp = host2.id_request().send().promise.await.unwrap(); let peer_id = id_resp.get().unwrap().get_peer_id().unwrap(); @@ -1154,13 +1237,19 @@ mod tests { stderr, exit_rx, delayed_host.client.clone(), + membrane::schema_registry::HOST_SCHEMA.to_vec(), kill_tx, ); let process = setup_process_rpc(process_impl); // Call bootstrap() immediately — the cap hasn't resolved yet. - let resp = process.bootstrap_request().send().promise.await.unwrap(); - let cap = resp.get().unwrap().get_cap(); + let resp = { + let req = process.bootstrap_request(); + req.send().promise + } + .await + .unwrap(); + let cap = resp.get().unwrap().get_typed().unwrap().get_cap(); let host2: system_capnp::host::Client = cap.get_as_capability().unwrap(); // Use the cap — should block until the delayed future resolves. @@ -1312,14 +1401,26 @@ mod tests { stderr, exit_rx, host.client.clone(), + membrane::schema_registry::HOST_SCHEMA.to_vec(), kill_tx, ); let process = setup_process_rpc(process_impl); // 3. Call Process.bootstrap() to get the cap (what handle_rpc_connection does). - let resp = process.bootstrap_request().send().promise.await.unwrap(); - let bootstrap_cap: capnp::capability::Client = - resp.get().unwrap().get_cap().get_as_capability().unwrap(); + let resp = { + let req = process.bootstrap_request(); + req.send().promise + } + .await + .unwrap(); + let bootstrap_cap: capnp::capability::Client = resp + .get() + .unwrap() + .get_typed() + .unwrap() + .get_cap() + .get_as_capability() + .unwrap(); // 4. Bridge: serve it over a duplex (simulates the libp2p stream bridge). let (remote_host, _bridge): (system_capnp::host::Client, _) = @@ -1348,13 +1449,25 @@ mod tests { stderr, exit_rx, host.client.clone(), + membrane::schema_registry::HOST_SCHEMA.to_vec(), kill_tx, ); let process = setup_process_rpc(process_impl); - let resp = process.bootstrap_request().send().promise.await.unwrap(); - let bootstrap_cap: capnp::capability::Client = - resp.get().unwrap().get_cap().get_as_capability().unwrap(); + let resp = { + let req = process.bootstrap_request(); + req.send().promise + } + .await + .unwrap(); + let bootstrap_cap: capnp::capability::Client = resp + .get() + .unwrap() + .get_typed() + .unwrap() + .get_cap() + .get_as_capability() + .unwrap(); let (remote_host, _bridge): (system_capnp::host::Client, _) = setup_bridge(bootstrap_cap); @@ -1384,13 +1497,25 @@ mod tests { stderr, exit_rx, host.client.clone(), + membrane::schema_registry::HOST_SCHEMA.to_vec(), kill_tx, ); let process = setup_process_rpc(process_impl); - let resp = process.bootstrap_request().send().promise.await.unwrap(); - let bootstrap_cap: capnp::capability::Client = - resp.get().unwrap().get_cap().get_as_capability().unwrap(); + let resp = { + let req = process.bootstrap_request(); + req.send().promise + } + .await + .unwrap(); + let bootstrap_cap: capnp::capability::Client = resp + .get() + .unwrap() + .get_typed() + .unwrap() + .get_cap() + .get_as_capability() + .unwrap(); let (remote_host, _bridge): (system_capnp::host::Client, _) = setup_bridge(bootstrap_cap); @@ -1429,13 +1554,25 @@ mod tests { stderr, exit_rx, host.client.clone(), + membrane::schema_registry::HOST_SCHEMA.to_vec(), kill_tx, ); let process = setup_process_rpc(process_impl); - let resp = process.bootstrap_request().send().promise.await.unwrap(); - let cap: capnp::capability::Client = - resp.get().unwrap().get_cap().get_as_capability().unwrap(); + let resp = { + let req = process.bootstrap_request(); + req.send().promise + } + .await + .unwrap(); + let cap: capnp::capability::Client = resp + .get() + .unwrap() + .get_typed() + .unwrap() + .get_cap() + .get_as_capability() + .unwrap(); let (remote, _bridge): (system_capnp::host::Client, _) = setup_bridge(cap); remote_hosts.push(remote); @@ -1495,6 +1632,15 @@ mod tests { capnp_rpc::new_client(StubExecutor) } + fn init_test_descriptor( + mut descriptor: system_capnp::vat_descriptor::Builder<'_>, + schema: &[u8], + ) { + descriptor.set_wasi_cid(b"test-wasi-cid"); + let schema_cid = super::schema_cid(schema); + descriptor.set_schema_cid(schema_cid.as_bytes()); + } + /// Build a minimal WASM component with an optional custom section. /// Returns bytes that wasmparser can parse (valid WASM component header). fn wasm_with_custom_section(section_name: &str, data: &[u8]) -> Vec { @@ -1538,7 +1684,11 @@ mod tests { let mut handler = req.get().init_handler(); handler.set_spawn(executor); } - req.get().set_schema(&[]); // empty schema + { + let mut descriptor = req.get().init_descriptor(); + descriptor.set_wasi_cid(b"test-wasi-cid"); + descriptor.set_schema_cid(b""); + } let result = req.send().promise.await; assert!(result.is_err(), "empty schema param should error"); @@ -1563,7 +1713,11 @@ mod tests { let keypair = libp2p::identity::Keypair::generate_ed25519(); let peer_id = libp2p::PeerId::from_public_key(&keypair.public()); req.get().set_peer(&peer_id.to_bytes()); - req.get().set_schema(&[]); // empty schema + { + let mut descriptor = req.get().init_descriptor(); + descriptor.set_wasi_cid(b"test-wasi-cid"); + descriptor.set_schema_cid(b""); + } let result = req.send().promise.await; assert!(result.is_err(), "empty schema should error"); @@ -1582,14 +1736,52 @@ mod tests { let mut req = dialer.dial_request(); req.get().set_peer(&[0xFF, 0xFF, 0xFF]); // garbage peer ID - req.get().set_schema(b"valid schema bytes"); - + { + let mut descriptor = req.get().init_descriptor(); + init_test_descriptor( + descriptor.reborrow(), + membrane::schema_registry::HOST_SCHEMA, + ); + } let result = req.send().promise.await; assert!(result.is_err(), "invalid peer ID should error"); }) .await; } + #[tokio::test] + async fn test_vat_client_unresolved_schema_cid_errors() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (_tx, guard) = test_epoch_guard(1); + let dialer_impl = vat_client::VatClientImpl::new(dummy_stream_control(), guard); + let dialer: system_capnp::vat_client::Client = capnp_rpc::new_client(dialer_impl); + + let mut req = dialer.dial_request(); + // Valid peer ID (Ed25519 public key) + let keypair = libp2p::identity::Keypair::generate_ed25519(); + let peer_id = libp2p::PeerId::from_public_key(&keypair.public()); + req.get().set_peer(&peer_id.to_bytes()); + { + let mut descriptor = req.get().init_descriptor(); + descriptor.set_wasi_cid(b"test-wasi-cid"); + descriptor.set_schema_cid(b"bafkr4iunknowncid"); + } + + let err = match req.send().promise.await { + Ok(_) => panic!("unresolved schema CID should fail"), + Err(e) => e, + }; + let msg = format!("{err}"); + assert!( + msg.contains("unresolved in local schema registry"), + "unexpected error: {msg}" + ); + }) + .await; + } + #[tokio::test] async fn test_vat_listener_stale_epoch_errors() { let local = tokio::task::LocalSet::new(); @@ -1616,8 +1808,6 @@ mod tests { let mut handler = req.get().init_handler(); handler.set_spawn(executor); } - req.get().set_schema(b"some schema"); - let result = req.send().promise.await; assert!(result.is_err(), "stale epoch should error"); }) @@ -1646,8 +1836,6 @@ mod tests { let mut req = dialer.dial_request(); req.get().set_peer(&peer_id.to_bytes()); - req.get().set_schema(b"some schema"); - let result = req.send().promise.await; assert!(result.is_err(), "stale epoch should error"); }) @@ -1674,7 +1862,7 @@ mod tests { let executor = stub_executor(); // Both registrations use the same schema → same protocol CID. - let schema = b"some schema bytes"; + let schema = membrane::schema_registry::HOST_SCHEMA; // First registration should succeed. let mut req1 = client1.listen_request(); @@ -1682,7 +1870,10 @@ mod tests { let mut handler = req1.get().init_handler(); handler.set_spawn(executor.clone()); } - req1.get().set_schema(schema); + { + let mut descriptor = req1.get().init_descriptor(); + init_test_descriptor(descriptor.reborrow(), schema); + } req1.send() .promise .await @@ -1694,7 +1885,10 @@ mod tests { let mut handler = req2.get().init_handler(); handler.set_spawn(executor); } - req2.get().set_schema(schema); + { + let mut descriptor = req2.get().init_descriptor(); + init_test_descriptor(descriptor.reborrow(), schema); + } let result = req2.send().promise.await; assert!( result.is_err(), @@ -1769,8 +1963,13 @@ mod tests { let mut handler = req.get().init_handler(); handler.set_spawn(executor); } - req.get().set_schema(b"valid schema bytes"); - + { + let mut descriptor = req.get().init_descriptor(); + init_test_descriptor( + descriptor.reborrow(), + membrane::schema_registry::HOST_SCHEMA, + ); + } let result = req.send().promise.await; assert!( result.is_ok(), diff --git a/crates/rpc/src/vat_client.rs b/crates/rpc/src/vat_client.rs index 1dc0fdbe..41a38294 100644 --- a/crates/rpc/src/vat_client.rs +++ b/crates/rpc/src/vat_client.rs @@ -19,6 +19,59 @@ use membrane::system_capnp; /// Timeout for establishing the libp2p stream to a remote peer. const DIAL_TIMEOUT: Duration = Duration::from_secs(30); +pub(crate) fn schema_bytes_for_descriptor_cid(schema_cid: &str) -> Option<&'static [u8]> { + if schema_cid == membrane::schema_registry::HOST_CID { + return Some(membrane::schema_registry::HOST_SCHEMA); + } + if schema_cid == membrane::schema_registry::RUNTIME_CID { + return Some(membrane::schema_registry::RUNTIME_SCHEMA); + } + if schema_cid == membrane::schema_registry::ROUTING_CID { + return Some(membrane::schema_registry::ROUTING_SCHEMA); + } + if schema_cid == membrane::schema_registry::IDENTITY_CID { + return Some(membrane::schema_registry::IDENTITY_SCHEMA); + } + if schema_cid == membrane::schema_registry::HTTP_CLIENT_CID { + return Some(membrane::schema_registry::HTTP_CLIENT_SCHEMA); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn descriptor_schema_lookup_resolves_known_cid() { + let bytes = schema_bytes_for_descriptor_cid(membrane::schema_registry::HOST_CID) + .expect("HOST_CID should resolve"); + assert_eq!(bytes, membrane::schema_registry::HOST_SCHEMA); + } + + #[test] + fn descriptor_schema_lookup_rejects_unknown_cid() { + assert!( + schema_bytes_for_descriptor_cid("bafkr4iunknowncid").is_none(), + "unknown schema CID must not resolve" + ); + } + + #[test] + fn vat_client_dial_schema_lookup_bytes_decode_as_schema_node() { + let bytes = schema_bytes_for_descriptor_cid(membrane::schema_registry::HOST_CID) + .expect("HOST_CID should resolve"); + let aligned = crate::graft::bytes_to_aligned_words(bytes); + let segments: &[&[u8]] = &[capnp::Word::words_to_bytes(&aligned)]; + let segment_array = capnp::message::SegmentArray::new(segments); + let reader = + capnp::message::Reader::new(segment_array, capnp::message::ReaderOptions::new()); + let _node: capnp::schema_capnp::node::Reader<'_> = reader + .get_root() + .expect("lookup bytes should decode as schema.Node"); + } +} + pub struct VatClientImpl { stream_control: libp2p_stream::Control, guard: EpochGuard, @@ -44,16 +97,38 @@ impl system_capnp::vat_client::Server for VatClientImpl { let params = pry!(params.get()); let peer_bytes = pry!(params.get_peer()).to_vec(); - let schema_bytes = pry!(params.get_schema()).to_vec(); - - if schema_bytes.is_empty() { - return Promise::err(capnp::Error::failed("schema must not be empty".into())); + let descriptor = pry!(params.get_descriptor()); + let descriptor_schema_cid_bytes = pry!(descriptor.get_schema_cid()).to_vec(); + let descriptor_schema_cid = match std::str::from_utf8(&descriptor_schema_cid_bytes) { + Ok(s) if !s.is_empty() => s.to_string(), + Ok(_) => { + return Promise::err(capnp::Error::failed( + "descriptor.schemaCid must not be empty".into(), + )) + } + Err(e) => { + return Promise::err(capnp::Error::failed(format!( + "descriptor.schemaCid is not utf8: {e}" + ))) + } + }; + let descriptor_bytes = pry!(super::canonicalize_vat_descriptor(descriptor)); + if descriptor_bytes.is_empty() { + return Promise::err(capnp::Error::failed("descriptor must not be empty".into())); } + let schema_bytes = + if let Some(bytes) = schema_bytes_for_descriptor_cid(&descriptor_schema_cid) { + bytes.to_vec() + } else { + return Promise::err(capnp::Error::failed(format!( + "descriptor.schemaCid unresolved in local schema registry: {descriptor_schema_cid}" + ))); + }; let peer_id = pry!(PeerId::from_bytes(&peer_bytes) .map_err(|e| capnp::Error::failed(format!("invalid peer ID: {e}")))); - let protocol_cid = super::schema_cid(&schema_bytes); + let protocol_cid = super::descriptor_cid(&descriptor_bytes); let stream_protocol = pry!(super::schema_protocol(&protocol_cid)); let mut control = self.stream_control.clone(); @@ -82,16 +157,7 @@ impl system_capnp::vat_client::Server for VatClientImpl { )) })?; - // Bootstrap Cap'n Proto RPC over the libp2p stream via the - // paved-path helper, which spawns the RpcSystem driver before - // returning. The driver flushes Bootstrap and receives the - // remote Return on its own. - // - // We don't await an explicit handshake check: `when_resolved()` - // on a bootstrap pipeline client doesn't fire reliably in - // capnp-rpc-rust 0.25 (see vat_dial docs). The guest's first - // method call through the returned cap observes any remote - // failure via that call's own response timeout. + // Start Cap'n Proto RPC directly on the stream. let super::vat_dial::VatDial { bootstrap, driver } = super::vat_dial::connect::<_, capnp::capability::Client>(stream); @@ -121,7 +187,20 @@ impl system_capnp::vat_client::Server for VatClientImpl { } }); - results.get().init_cap().set_as_capability(bootstrap.hook); + let mut typed = results.get().init_typed(); + typed + .reborrow() + .init_cap() + .set_as_capability(bootstrap.hook); + let aligned = crate::graft::bytes_to_aligned_words(&schema_bytes); + let segments: &[&[u8]] = &[capnp::Word::words_to_bytes(&aligned)]; + let segment_array = capnp::message::SegmentArray::new(segments); + let reader = + capnp::message::Reader::new(segment_array, capnp::message::ReaderOptions::new()); + let schema_node: capnp::schema_capnp::node::Reader<'_> = reader.get_root()?; + let mut out_schema = typed.reborrow().init_schema(); + out_schema.set_root(schema_node)?; + out_schema.init_deps(0); Ok(()) }) diff --git a/crates/rpc/src/vat_listener.rs b/crates/rpc/src/vat_listener.rs index 12bf6092..9446f21b 100644 --- a/crates/rpc/src/vat_listener.rs +++ b/crates/rpc/src/vat_listener.rs @@ -52,15 +52,30 @@ impl system_capnp::vat_listener::Server for VatListenerImpl { let params = pry!(params.get()); - // Read schema bytes from the explicit param. - let schema_bytes: Vec = pry!(params.get_schema()).to_vec(); - if schema_bytes.is_empty() { + // Read descriptor metadata from the explicit param. + let descriptor = pry!(params.get_descriptor()); + let descriptor_schema_cid_bytes = pry!(descriptor.get_schema_cid()).to_vec(); + let descriptor_schema_cid = match std::str::from_utf8(&descriptor_schema_cid_bytes) { + Ok(s) if !s.is_empty() => s.to_string(), + Ok(_) => { + return Promise::err(capnp::Error::failed( + "descriptor.schemaCid must not be empty".into(), + )) + } + Err(e) => { + return Promise::err(capnp::Error::failed(format!( + "descriptor.schemaCid is not utf8: {e}" + ))) + } + }; + let descriptor_bytes = pry!(super::canonicalize_vat_descriptor(descriptor)); + if descriptor_bytes.is_empty() { return Promise::err(capnp::Error::failed( - "schema bytes must not be empty".into(), + "descriptor bytes must not be empty".into(), )); } - let protocol_cid = super::schema_cid(&schema_bytes); + let protocol_cid = super::descriptor_cid(&descriptor_bytes); let stream_protocol = pry!(super::schema_protocol(&protocol_cid)); let mut control = self.stream_control.clone(); @@ -85,16 +100,33 @@ impl system_capnp::vat_listener::Server for VatListenerImpl { let mut caps_vec = Vec::new(); if let Ok(caps_reader) = params.get_caps() { for entry in caps_reader.iter() { - if let (Ok(name), Ok(cap)) = ( - entry.get_name().map(|n| n.to_string().unwrap_or_default()), - entry.get_cap().get_as_capability(), - ) { - let schema_bytes = match entry.get_schema() { - Ok(node) => super::canonicalize_schema_node(node).unwrap_or_default(), - Err(_) => Vec::new(), - }; - caps_vec.push((name, cap, schema_bytes)); - } + let name = match entry.get_name() { + Ok(n) => match n.to_str() { + Ok(s) => s.to_string(), + Err(e) => { + return Promise::err(capnp::Error::failed(format!( + "invalid utf8 cap name: {e}" + ))) + } + }, + Err(e) => return Promise::err(e), + }; + let cap = match entry.get_cap().get_as_capability() { + Ok(v) => v, + Err(e) => return Promise::err(e), + }; + let schema_bytes = match entry.get_schema() { + Ok(node) => match super::canonicalize_schema_node(node) { + Some(bytes) => bytes, + None => { + return Promise::err(capnp::Error::failed( + "invalid cap schema: canonicalization failed".into(), + )) + } + }, + Err(_) => Vec::new(), + }; + caps_vec.push((name, cap, schema_bytes)); } } caps_vec @@ -124,6 +156,7 @@ impl system_capnp::vat_listener::Server for VatListenerImpl { tracing::debug!("Incoming vat connection"); let executor = executor.clone(); let protocol_cid = protocol_cid.clone(); + let descriptor_schema_cid = descriptor_schema_cid.clone(); let caps = extra_caps.clone(); tokio::task::spawn_local(async move { let _handle_span = tracing::info_span!( @@ -131,7 +164,14 @@ impl system_capnp::vat_listener::Server for VatListenerImpl { protocol = protocol_cid, ).entered(); if let Err(e) = - handle_vat_connection_spawn(executor, caps, stream, &protocol_cid).await + handle_vat_connection_spawn( + executor, + caps, + stream, + &protocol_cid, + &descriptor_schema_cid, + ) + .await { tracing::error!("Vat cell connection error: {e}"); } @@ -151,7 +191,34 @@ impl system_capnp::vat_listener::Server for VatListenerImpl { }); } system_capnp::vat_handler::Which::Serve(cap_ptr) => { - let bootstrap_cap: capnp::capability::Client = pry!(cap_ptr.get_as_capability()); + let typed = pry!(cap_ptr); + let bootstrap_cap: capnp::capability::Client = + match typed.get_cap().get_as_capability() { + Ok(v) => v, + Err(e) => return Promise::err(e), + }; + let served_schema = match typed.get_schema() { + Ok(schema) => schema, + Err(e) => return Promise::err(e), + }; + let served_root = match served_schema.get_root() { + Ok(root) => root, + Err(e) => return Promise::err(e), + }; + let served_schema_bytes = match super::canonicalize_schema_node(served_root) { + Some(bytes) => bytes, + None => { + return Promise::err(capnp::Error::failed( + "invalid serve schema: canonicalization failed".into(), + )) + } + }; + let served_schema_cid = super::schema_cid(&served_schema_bytes); + if served_schema_cid != descriptor_schema_cid { + return Promise::err(capnp::Error::failed( + "vat-listener.listen descriptor.schemaCid must match handler.serve typed schema CID".into(), + )); + } // Accept loop: for each incoming connection, bootstrap with the persistent cap. let mut epoch_rx = self.guard.receiver.clone(); @@ -173,13 +240,20 @@ impl system_capnp::vat_listener::Server for VatListenerImpl { tracing::debug!("Incoming vat connection"); let cap = bootstrap_cap.clone(); let protocol_cid = protocol_cid.clone(); + let served_schema_bytes = served_schema_bytes.clone(); tokio::task::spawn_local(async move { let _handle_span = tracing::info_span!( "vat.handle", protocol = protocol_cid, ).entered(); if let Err(e) = - handle_vat_connection_serve(cap, stream, &protocol_cid).await + handle_vat_connection_serve( + cap, + stream, + &protocol_cid, + &served_schema_bytes, + ) + .await { tracing::error!("Vat serve connection error: {e}"); } @@ -222,8 +296,9 @@ impl system_capnp::vat_listener::Server for VatListenerImpl { pub async fn handle_vat_connection_spawn( executor: system_capnp::executor::Client, caps: Vec<(String, capnp::capability::Client, Vec)>, - stream: impl AsyncRead + AsyncWrite + 'static, + stream: impl AsyncRead + AsyncWrite + Unpin + 'static, protocol_cid: &str, + expected_schema_cid: &str, ) -> Result<(), capnp::Error> { // 1. Spawn cell process via Executor.spawn(), forwarding caps with // their canonical Schema.Node bytes so the spawned cell's graft @@ -260,10 +335,10 @@ pub async fn handle_vat_connection_spawn( // 3. Get the cell's exported bootstrap capability. // Timeout guards against cells that never call system::serve(). // On failure, close stdin to clean up the orphaned cell process. - let bootstrap_resp = match tokio::time::timeout( - std::time::Duration::from_secs(10), - process.bootstrap_request().send().promise, - ) + let bootstrap_resp = match tokio::time::timeout(std::time::Duration::from_secs(10), { + let req = process.bootstrap_request(); + req.send().promise + }) .await { Ok(Ok(resp)) => resp, @@ -280,16 +355,33 @@ pub async fn handle_vat_connection_spawn( )); } }; - let bootstrap_cap: capnp::capability::Client = match bootstrap_resp - .get() - .and_then(|r| r.get_cap().get_as_capability()) - { - Ok(cap) => cap, - Err(e) => { - let _ = stdin.close_request().send().promise.await; - return Err(e); - } - }; + let (bootstrap_cap, served_schema_bytes): (capnp::capability::Client, Vec) = + match bootstrap_resp.get().and_then(|r| { + let typed = r.get_typed()?; + let cap = typed.get_cap().get_as_capability()?; + let schema = typed.get_schema()?; + let root = schema.get_root()?; + let schema_bytes = super::canonicalize_schema_node(root).ok_or_else(|| { + capnp::Error::failed( + "invalid bootstrap typed schema: canonicalization failed".into(), + ) + })?; + Ok((cap, schema_bytes)) + }) { + Ok(v) => v, + Err(e) => { + let _ = stdin.close_request().send().promise.await; + return Err(e); + } + }; + + let served_schema_cid = super::schema_cid(&served_schema_bytes); + if served_schema_cid != expected_schema_cid { + let _ = stdin.close_request().send().promise.await; + return Err(capnp::Error::failed(format!( + "process.bootstrap returned schema CID {served_schema_cid}, expected {expected_schema_cid}" + ))); + } // 4. Bridge: serve the cell's cap to the remote peer over the libp2p stream. let (reader, writer) = Box::pin(stream).split(); @@ -330,8 +422,9 @@ pub async fn handle_vat_connection_spawn( /// Generic over stream type for testability. pub async fn handle_vat_connection_serve( bootstrap_cap: capnp::capability::Client, - stream: impl AsyncRead + AsyncWrite + 'static, + stream: impl AsyncRead + AsyncWrite + Unpin + 'static, protocol_cid: &str, + _schema_bytes: &[u8], ) -> Result<(), capnp::Error> { let (reader, writer) = Box::pin(stream).split(); let network = VatNetwork::new(reader, writer, Side::Server, Default::default()); diff --git a/crates/rpc/src/wagi.rs b/crates/rpc/src/wagi.rs index 9e6d379b..2d13a754 100644 --- a/crates/rpc/src/wagi.rs +++ b/crates/rpc/src/wagi.rs @@ -47,22 +47,6 @@ impl std::error::Error for WagiError {} /// Returns a `Vec` of `KEY=VALUE` pairs suitable for passing to /// `ProcBuilder::with_env()` or `Executor.bind()`. /// -/// `verified_snap`, when `Some`, adds Snap-specific env vars carrying -/// the JFS-verified viewer context. Cells consume these to render -/// viewer-aware UIs: -/// - `X_SNAP_FID_CLAIMED` — the FID from the verified payload. -/// Naming is explicit: cryptographically signed against the -/// embedded key, but the key↔FID binding is NOT Hub-verified in -/// v1.0. Cells that grant authority based on FID identity SHOULD -/// wait for v1.1. -/// - `X_SNAP_TIMESTAMP` — Unix-seconds the payload was signed. -/// - `X_SNAP_AUDIENCE` — server origin the client signed for -/// (already verified to match this server). -/// - `X_SNAP_PAYLOAD_B64URL` — verbatim BASE64URL of the original -/// payload bytes, so cells that want the inputs/user/surface -/// fields can decode + parse them directly without re-walking -/// header validation. -/// /// Header values are converted with `to_string_lossy()` to handle non-UTF8. pub fn build_cgi_env( method: &str, @@ -71,9 +55,8 @@ pub fn build_cgi_env( headers: &[(String, String)], server_name: &str, server_port: u16, - verified_snap: Option<&crate::jfs::VerifiedJfs>, ) -> Vec { - let mut env = Vec::with_capacity(8 + headers.len() + verified_snap.map_or(0, |_| 4)); + let mut env = Vec::with_capacity(8 + headers.len()); env.push(format!("REQUEST_METHOD={method}")); env.push(format!("PATH_INFO={path}")); @@ -92,13 +75,6 @@ pub fn build_cgi_env( } } - if let Some(v) = verified_snap { - env.push(format!("X_SNAP_FID_CLAIMED={}", v.payload.fid)); - env.push(format!("X_SNAP_TIMESTAMP={}", v.payload.timestamp)); - env.push(format!("X_SNAP_AUDIENCE={}", v.payload.audience)); - env.push(format!("X_SNAP_PAYLOAD_B64URL={}", v.payload_b64url)); - } - env } @@ -215,7 +191,7 @@ mod tests { #[test] fn cgi_env_basic() { - let env = build_cgi_env("GET", "/counter", "", &[], "localhost", 8080, None); + let env = build_cgi_env("GET", "/counter", "", &[], "localhost", 8080); assert!(env.contains(&"REQUEST_METHOD=GET".to_string())); assert!(env.contains(&"PATH_INFO=/counter".to_string())); assert!(env.contains(&"QUERY_STRING=".to_string())); @@ -227,15 +203,7 @@ mod tests { #[test] fn cgi_env_with_query_string() { - let env = build_cgi_env( - "GET", - "/search", - "q=hello&page=1", - &[], - "localhost", - 0, - None, - ); + let env = build_cgi_env("GET", "/search", "q=hello&page=1", &[], "localhost", 0); assert!(env.contains(&"QUERY_STRING=q=hello&page=1".to_string())); } @@ -246,7 +214,7 @@ mod tests { ("Host".to_string(), "example.com".to_string()), ("X-Custom-Header".to_string(), "value".to_string()), ]; - let env = build_cgi_env("POST", "/api", "", &headers, "localhost", 8080, None); + let env = build_cgi_env("POST", "/api", "", &headers, "localhost", 8080); assert!(env.contains(&"HTTP_ACCEPT=text/html".to_string())); assert!(env.contains(&"HTTP_HOST=example.com".to_string())); assert!(env.contains(&"HTTP_X_CUSTOM_HEADER=value".to_string())); @@ -258,7 +226,7 @@ mod tests { ("Content-Type".to_string(), "application/json".to_string()), ("Content-Length".to_string(), "42".to_string()), ]; - let env = build_cgi_env("POST", "/api", "", &headers, "localhost", 8080, None); + let env = build_cgi_env("POST", "/api", "", &headers, "localhost", 8080); assert!(env.contains(&"CONTENT_TYPE=application/json".to_string())); assert!(env.contains(&"CONTENT_LENGTH=42".to_string())); // Should NOT have HTTP_ prefix @@ -266,32 +234,6 @@ mod tests { assert!(!env.iter().any(|e| e.starts_with("HTTP_CONTENT_LENGTH"))); } - #[test] - fn cgi_env_with_verified_snap_emits_snap_envs() { - let verified = crate::jfs::VerifiedJfs { - payload: crate::jfs::JfsPayload { - fid: 12345, - inputs: serde_json::json!({"button": "yes"}), - audience: "https://master.wetware.run".to_string(), - timestamp: 1_700_000_000, - user: serde_json::json!({"fid": 12345}), - surface: serde_json::json!({"type": "standalone"}), - }, - payload_b64url: "abcDEF123".to_string(), - }; - let env = build_cgi_env("POST", "/snap", "", &[], "localhost", 8080, Some(&verified)); - assert!(env.contains(&"X_SNAP_FID_CLAIMED=12345".to_string())); - assert!(env.contains(&"X_SNAP_TIMESTAMP=1700000000".to_string())); - assert!(env.contains(&"X_SNAP_AUDIENCE=https://master.wetware.run".to_string())); - assert!(env.contains(&"X_SNAP_PAYLOAD_B64URL=abcDEF123".to_string())); - } - - #[test] - fn cgi_env_without_verified_snap_omits_snap_envs() { - let env = build_cgi_env("GET", "/snap", "", &[], "localhost", 8080, None); - assert!(!env.iter().any(|e| e.starts_with("X_SNAP_"))); - } - // ===== parse_cgi_response tests ===== #[test] diff --git a/doc/api/wasm-guest.md b/doc/api/wasm-guest.md index b0f950ac..aee55bb8 100644 --- a/doc/api/wasm-guest.md +++ b/doc/api/wasm-guest.md @@ -196,7 +196,7 @@ Full interface reference for the capabilities available to guests. | `stdout` | `() -> (stream: ByteStream)` | Readable stream from guest's stdout. | | `stderr` | `() -> (stream: ByteStream)` | Readable stream from guest's stderr. | | `wait` | `() -> (exitCode: Int32)` | Block until process exits. | -| `bootstrap` | `() -> (cap: AnyPointer)` | Get the capability exported by the guest via `system::serve()`. Type-erased. | +| `bootstrap` | `() -> (typed: TypedCap)` | Get the capability exported by the guest via `system::serve()` with producer-attached schema metadata. | ### ByteStream @@ -222,13 +222,13 @@ Full interface reference for the capabilities available to guests. | Method | Signature | Description | |--------|-----------|-------------| -| `listen` | `(handler: VatHandler, schema: Data) -> ()` | Accept connections on `/ww/0.1.0/vat/{cid}` where cid = CIDv1(raw, BLAKE3(schema)). VatHandler is a union: `spawn` (Executor) for stateless per-connection cells, or `serve` (AnyPointer) for a persistent bootstrap capability. | +| `listen` | `(handler: VatHandler, descriptor: VatDescriptor, caps: List(Export)) -> ()` | Accept connections on `/ww/0.1.0/vat/{cid}` where cid = CIDv1(raw, BLAKE3(canonical VatDescriptor)). VatHandler is a union: `spawn` (Executor) for stateless per-connection cells, or `serve` (TypedCap) for a persistent bootstrap capability. | ### VatClient (capability mode) | Method | Signature | Description | |--------|-----------|-------------| -| `dial` | `(peer: Data, schema: Data) -> (cap: AnyPointer)` | Open connection to peer on `/ww/0.1.0/vat/{cid}`. Bootstrap RPC, return remote's capability. Type-erased. | +| `dial` | `(peer: Data, descriptor: VatDescriptor) -> (typed: TypedCap)` | Open connection to peer on `/ww/0.1.0/vat/{cid}` keyed by descriptor CID. Bootstrap RPC and return remote capability plus schema metadata. | ## WASM Custom Sections @@ -242,7 +242,7 @@ inspects before instantiation. **Cell variants:** - `Cell::raw(Text)` — registers a libp2p stream protocol at `/ww/0.1.0/stream/{name}`. stdin/stdout carry raw bytes. - `Cell::http(Text)` — registers at HTTP path prefix. stdin/stdout carry FastCGI records. -- `Cell::capnp(Schema.Node)` — registers vat protocol at `/ww/0.1.0/vat/{cid}`. CID = `CIDv1(raw, BLAKE3(canonical schema bytes))`. +- `Cell::capnp(VatDescriptor)` — registers vat protocol at `/ww/0.1.0/vat/{cid}`. CID = `CIDv1(raw, BLAKE3(canonical descriptor bytes))`. **Absence**: If `cell.capnp` is not present, the binary is a pid0 process (kernel/WIT mode). It is not a service cell and cannot be passed to any listener. diff --git a/doc/architecture/invariants.md b/doc/architecture/invariants.md new file mode 100644 index 00000000..7f3de171 --- /dev/null +++ b/doc/architecture/invariants.md @@ -0,0 +1,25 @@ +# Producer-Authoritative Recursion Invariants + +## Core invariants + +1. Recursive attenuation authority is producer-sourced only. +2. `process.bootstrap()` takes no schema input and returns `TypedCap`. +3. `vat-client.dial(peer, descriptor)` and `vat-listener.listen(handler, descriptor, caps)` route by descriptor CID. +4. `TypedCap.schema` (`root` + `deps`) is the authority source for dynamic method-policy enforcement. +5. Unknown or malformed dynamic policy/schema cases fail closed. + +## Descriptor identity + +`VatDescriptor` is canonicalized as a Cap'n Proto message and hashed as: + +`CIDv1(raw, BLAKE3(canonical VatDescriptor bytes))` + +Current descriptor shape: + +- `wasiCid: Data` +- `schemaCid: Data` + +## Explicit non-goals in this cycle + +- No caller-side schema assertion API surface (no `expect-cid`/`expect-schema`). +- No fallback from producer schema to caller hints. diff --git a/doc/architecture/producer-authority.mmd b/doc/architecture/producer-authority.mmd new file mode 100644 index 00000000..fba253cc --- /dev/null +++ b/doc/architecture/producer-authority.mmd @@ -0,0 +1,6 @@ +flowchart LR + A[Guest calls system::serve(cap)] --> B[Host stores TypedCap schema bundle] + B --> C[Process.bootstrap()] + C --> D[Kernel wrapper receives TypedCap] + D --> E[Dynamic policy gate built from TypedCap.schema.root] + E --> F[Returned cap wrapped with fail-closed method filter] diff --git a/doc/architecture/vat-descriptor-routing.mmd b/doc/architecture/vat-descriptor-routing.mmd new file mode 100644 index 00000000..a7373579 --- /dev/null +++ b/doc/architecture/vat-descriptor-routing.mmd @@ -0,0 +1,10 @@ +flowchart TB + A[VatDescriptor{wasiCid,schemaCid}] --> B[Canonical Capnp Encoding] + B --> C[CIDv1(raw, BLAKE3(...))] + C --> D[/ww/0.1.0/vat/{cid}] + + E[vat-listener.listen(handler, descriptor, caps)] --> D + F[vat-client.dial(peer, descriptor)] --> D + + D --> G[Cap'n Proto RPC bootstrap] + G --> H[TypedCap returned to caller] diff --git a/doc/images.md b/doc/images.md index 1e4ffc8e..1b4d4f0b 100644 --- a/doc/images.md +++ b/doc/images.md @@ -8,12 +8,51 @@ Each wetware image follows a minimal FHS convention: main.wasm # agent entrypoint (required) svc/ # nested service images (spawned by pid0) etc/ # configuration (consumed by pid0) - init.d/ # boot scripts evaluated by the kernel + init.glia # top-level boot orchestration + export policy + init.d/ # boot scripts discovered/evaluated by init.glia ``` Only `bin/main.wasm` is required. Everything else is convention between the image author and the kernel (pid0). +<<<<<<< HEAD +Boot is fail-closed: `etc/init.glia` is required, and any parse/eval error in +`init.glia` or scripts it loads (including `init.d`) aborts boot. + +`init.d` is optional. If present, ordering is lexical; use numeric prefixes for +deterministic intent, for example `00-setup.glia`, `10-http.glia`, `20-worker.glia`. + +## Minimal export policy + +A strict posture can export nothing: + +```clojure +(load-file "/lib/init/default.glia") +{} +``` + +Export selected capabilities with a bare map from cap name to cap value: + +```clojure +(load-file "/lib/init/default.glia") + +{:host host + :runtime runtime} +``` + +Recursive attenuation uses normal `attenuate` syntax on map values: + +```clojure +(load-file "/lib/init/default.glia") + +{:host + (attenuate host + :allow [:id :network] + :returns {:network + {:stream-dialer (attenuate :self :allow [:dial]) + :stream-listener (attenuate :self :allow [:listen])}})} +``` + ## Demo vs deployment boot flow - **Demo default:** run a node process, attach with `ww shell`, then diff --git a/doc/init.md b/doc/init.md new file mode 100644 index 00000000..8e00d49c --- /dev/null +++ b/doc/init.md @@ -0,0 +1,60 @@ +# Init and Export Policy + +`/etc/init.glia` is required at boot. + +- Boot is fail-closed. +- Any parse/eval/policy error aborts boot. +- Legacy `{:export {:caps ... :methods ...}}` is rejected. + +## Return Contract + +`init.glia` must return a **bare export map**: + +```clojure +{:host host + :runtime runtime} +``` + +Keys are exported cap names. Values are capability values, including attenuated caps. + +Export nothing: + +```clojure +{} +``` + +## Orchestration + +`/lib/init/default.glia` is orchestration-only (`init.d` discovery/eval). Policy stays image-local: + +```clojure +(load-file "/lib/init/default.glia") +{:host host} +``` + +## Recursive Attenuation + +Use existing `attenuate` syntax in both shell and init scripts. + +Vector form: + +```clojure +(attenuate host [:id :network]) +``` + +Keyword form with recursive returns: + +```clojure +(attenuate host + :allow [:id :network] + :returns {:network + {:stream-dialer (attenuate :self :allow [:dial]) + :vat-client (attenuate :self :allow [:dial])}}) +``` + +Notes: + +- `:self` is only valid inside `:returns`. +- Policy validation is strict: unknown cap names, methods, and return fields fail boot. +- Enforcement is at kernel/RPC proxy boundaries (including returned sub-caps), not evaluator-local only. +- Dynamic return edges use typed envelopes (`TypedCap`) with producer-attached `SchemaBundle`; recursive allowlists are enforced schema-aware at RPC proxy boundaries. diff --git a/examples/auction/etc/init.glia b/examples/auction/etc/init.glia new file mode 100644 index 00000000..c9c23178 --- /dev/null +++ b/examples/auction/etc/init.glia @@ -0,0 +1,8 @@ +(load-file "/lib/init/default.glia") + +{:identity identity + :host host + :runtime runtime + :routing routing + :ipfs ipfs + :http-client http-client} diff --git a/examples/chess/etc/init.glia b/examples/chess/etc/init.glia new file mode 100644 index 00000000..c9c23178 --- /dev/null +++ b/examples/chess/etc/init.glia @@ -0,0 +1,8 @@ +(load-file "/lib/init/default.glia") + +{:identity identity + :host host + :runtime runtime + :routing routing + :ipfs ipfs + :http-client http-client} diff --git a/examples/chess/src/lib.rs b/examples/chess/src/lib.rs index 02c1ebc8..84a3aa82 100644 --- a/examples/chess/src/lib.rs +++ b/examples/chess/src/lib.rs @@ -99,6 +99,11 @@ fn short_id(peer_id: &[u8]) -> String { } } +fn init_chess_descriptor(mut descriptor: system_capnp::vat_descriptor::Builder<'_>) { + descriptor.set_wasi_cid(CHESS_ENGINE_CID.as_bytes()); + descriptor.set_schema_cid(CHESS_ENGINE_CID.as_bytes()); +} + // --------------------------------------------------------------------------- // Logging (WASI stderr, same pattern as kernel) // --------------------------------------------------------------------------- @@ -345,9 +350,10 @@ async fn play_rpc_against_peer( // Dial peer via VatClient — returns a typed ChessEngine capability. let mut req = vat_client.dial_request(); req.get().set_peer(peer_id); - req.get().set_schema(CHESS_ENGINE_SCHEMA); + init_chess_descriptor(req.get().init_descriptor()); let resp = req.send().promise.await?; - let engine: chess_capnp::chess_engine::Client = resp.get()?.get_cap().get_as_capability()?; + let engine: chess_capnp::chess_engine::Client = + resp.get()?.get_typed()?.get_cap().get_as_capability()?; log::info!("game {us} vs {them}: started (RPC)"); play_rpc_game(&engine, &us, &them).await diff --git a/examples/counter/etc/init.glia b/examples/counter/etc/init.glia new file mode 100644 index 00000000..c9c23178 --- /dev/null +++ b/examples/counter/etc/init.glia @@ -0,0 +1,8 @@ +(load-file "/lib/init/default.glia") + +{:identity identity + :host host + :runtime runtime + :routing routing + :ipfs ipfs + :http-client http-client} diff --git a/examples/discovery/etc/init.glia b/examples/discovery/etc/init.glia new file mode 100644 index 00000000..c9c23178 --- /dev/null +++ b/examples/discovery/etc/init.glia @@ -0,0 +1,8 @@ +(load-file "/lib/init/default.glia") + +{:identity identity + :host host + :runtime runtime + :routing routing + :ipfs ipfs + :http-client http-client} diff --git a/examples/discovery/src/lib.rs b/examples/discovery/src/lib.rs index 98d1a06e..9285c329 100644 --- a/examples/discovery/src/lib.rs +++ b/examples/discovery/src/lib.rs @@ -96,6 +96,11 @@ fn short_id(peer_id: &[u8]) -> String { } } +fn init_greeter_descriptor(mut descriptor: system_capnp::vat_descriptor::Builder<'_>) { + descriptor.set_wasi_cid(GREETER_CID.as_bytes()); + descriptor.set_schema_cid(GREETER_CID.as_bytes()); +} + // --------------------------------------------------------------------------- // Logging (WASI stderr) // --------------------------------------------------------------------------- @@ -228,9 +233,10 @@ async fn greet_peer( let mut req = vat_client.dial_request(); req.get().set_peer(peer_id); - req.get().set_schema(GREETER_SCHEMA); + init_greeter_descriptor(req.get().init_descriptor()); let resp = req.send().promise.await?; - let greeter: greeter_capnp::greeter::Client = resp.get()?.get_cap().get_as_capability()?; + let greeter: greeter_capnp::greeter::Client = + resp.get()?.get_typed()?.get_cap().get_as_capability()?; let mut greet_req = greeter.greet_request(); greet_req.get().set_name(format!("peer {us}")); diff --git a/examples/echo/etc/init.glia b/examples/echo/etc/init.glia new file mode 100644 index 00000000..c9c23178 --- /dev/null +++ b/examples/echo/etc/init.glia @@ -0,0 +1,8 @@ +(load-file "/lib/init/default.glia") + +{:identity identity + :host host + :runtime runtime + :routing routing + :ipfs ipfs + :http-client http-client} diff --git a/examples/mindshare/etc/init.glia b/examples/mindshare/etc/init.glia new file mode 100644 index 00000000..c9c23178 --- /dev/null +++ b/examples/mindshare/etc/init.glia @@ -0,0 +1,8 @@ +(load-file "/lib/init/default.glia") + +{:identity identity + :host host + :runtime runtime + :routing routing + :ipfs ipfs + :http-client http-client} diff --git a/examples/oracle/etc/init.glia b/examples/oracle/etc/init.glia new file mode 100644 index 00000000..c9c23178 --- /dev/null +++ b/examples/oracle/etc/init.glia @@ -0,0 +1,8 @@ +(load-file "/lib/init/default.glia") + +{:identity identity + :host host + :runtime runtime + :routing routing + :ipfs ipfs + :http-client http-client} diff --git a/examples/oracle/src/lib.rs b/examples/oracle/src/lib.rs index 5109fd59..51957142 100644 --- a/examples/oracle/src/lib.rs +++ b/examples/oracle/src/lib.rs @@ -92,6 +92,11 @@ fn short_id(peer_id: &[u8]) -> String { } } +fn init_oracle_descriptor(mut descriptor: system_capnp::vat_descriptor::Builder<'_>) { + descriptor.set_wasi_cid(PRICE_ORACLE_CID.as_bytes()); + descriptor.set_schema_cid(PRICE_ORACLE_CID.as_bytes()); +} + // --------------------------------------------------------------------------- // Logging (WASI stderr) // --------------------------------------------------------------------------- @@ -407,9 +412,13 @@ async fn query_oracle( // Dial the oracle peer. let mut req = vat_client.dial_request(); req.get().set_peer(peer_id); - req.get().set_schema(PRICE_ORACLE_SCHEMA); + init_oracle_descriptor(req.get().init_descriptor()); let resp = req.send().promise.await?; - let oracle: oracle_capnp::price_oracle::Client = resp.get()?.get_cap().get_as_capability()?; + let oracle: oracle_capnp::price_oracle::Client = resp + .get()? + .get_typed()? + .get_cap() + .get_as_capability()?; // Query available pairs. let pairs_resp = oracle.get_pairs_request().send().promise.await?; diff --git a/examples/snap-hello-rs/etc/init.glia b/examples/snap-hello-rs/etc/init.glia new file mode 100644 index 00000000..c9c23178 --- /dev/null +++ b/examples/snap-hello-rs/etc/init.glia @@ -0,0 +1,8 @@ +(load-file "/lib/init/default.glia") + +{:identity identity + :host host + :runtime runtime + :routing routing + :ipfs ipfs + :http-client http-client} diff --git a/examples/snap-hello-rs/src/lib.rs b/examples/snap-hello-rs/src/lib.rs index ed4a5a26..184e9068 100644 --- a/examples/snap-hello-rs/src/lib.rs +++ b/examples/snap-hello-rs/src/lib.rs @@ -21,10 +21,8 @@ //! //! Stateless. Fresh cell per request. No graft caps used. //! -//! IMPORTANT — FID trust model: `X_SNAP_FID_CLAIMED` is -//! cryptographically signed by the embedded JFS key, but the -//! key↔FID binding is NOT Hub-verified in v1.0 of the wetware -//! listener (see `crates/rpc/src/jfs.rs` module docs). +//! IMPORTANT — FID trust model: `X_SNAP_FID_CLAIMED` is demo metadata +//! supplied by the Farcaster snap example flow. use std::time::{SystemTime, UNIX_EPOCH}; @@ -33,8 +31,8 @@ use wasip2::exports::cli::run::Guest; const SNAP_TYPE: &str = "application/vnd.farcaster.snap+json"; -/// Render the viewer's greeting from JFS-verified env vars set by the -/// listener. Returns `"FID #"` when present, else `"@stranger"`. +/// Render the viewer's greeting from snap-example env vars. +/// Returns `"FID #"` when present, else `"@stranger"`. fn viewer_greeting() -> String { match std::env::var("X_SNAP_FID_CLAIMED") { Ok(fid) if !fid.is_empty() => format!("FID #{fid}"), diff --git a/src/dispatcher/server.rs b/src/dispatcher/server.rs index 3c651d08..728230d8 100644 --- a/src/dispatcher/server.rs +++ b/src/dispatcher/server.rs @@ -98,13 +98,6 @@ async fn handle_request(State(registry): State, request: Request< .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) .collect(); - // Best-effort JFS verification of `X-Snap-Payload`. v1.0 is - // permissive: success populates `verified_snap`; failure logs a - // warning and treats the request as anonymous. v1.1 will follow - // the spec strictly (`MUST reject malformed/expired/invalid with - // 4xx`) once Hub key verification ships alongside. - let verified_snap = verify_snap_payload(&headers); - // Read the request body. let body_bytes = match axum::body::to_bytes(request.into_body(), MAX_REQUEST_BYTES).await { Ok(b) => b.to_vec(), @@ -119,7 +112,6 @@ async fn handle_request(State(registry): State, request: Request< query, headers, body: body_bytes, - verified_snap, response_tx, }; @@ -174,70 +166,6 @@ fn error_response(status: StatusCode, msg: &str) -> Response { pub use rpc::dispatch::extract_server_info; -/// Find a header by name (case-insensitive). Returns the first match. -fn find_header<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> { - headers - .iter() - .find(|(k, _)| k.eq_ignore_ascii_case(name)) - .map(|(_, v)| v.as_str()) -} - -/// Build the audience string this server expects in the JFS payload. -/// -/// `audience` per spec is the server origin: `scheme + host + port` -/// (with port omitted if it's the default for the scheme). We derive -/// it from the `Host` header + `X-Forwarded-Proto` (Traefik / any TLS -/// terminator in front sets this); if `X-Forwarded-Proto` is absent -/// we default to `https` because the production listener is intended -/// to live behind TLS termination. -/// -/// Returns `None` if there's no `Host` header (request wasn't HTTP/1.1 -/// or HTTP/2 in any normal sense — bail). -fn derive_audience(headers: &[(String, String)]) -> Option { - let host = find_header(headers, "host")?; - let scheme = find_header(headers, "x-forwarded-proto").unwrap_or("https"); - Some(format!("{scheme}://{host}")) -} - -/// Best-effort JFS verification of an `X-Snap-Payload` header. -/// -/// v1.0 contract: success → `Some(VerifiedJfs)`; absent header → `None`; -/// any verification failure → `None` + `WARN` log. v1.1 will follow -/// the spec's `MUST reject 4xx on malformed/expired/invalid` once Hub -/// key verification ships in lockstep. -fn verify_snap_payload(headers: &[(String, String)]) -> Option { - let payload = find_header(headers, "x-snap-payload")?; - let audience = match derive_audience(headers) { - Some(a) => a, - None => { - tracing::warn!("X-Snap-Payload present but Host header missing — skipping JFS verify"); - return None; - } - }; - let now = chrono::Utc::now().timestamp(); - match rpc::jfs::verify( - payload, - &audience, - now, - rpc::jfs::DEFAULT_TIMESTAMP_SKEW_SECS, - ) { - Ok(v) => { - tracing::debug!( - fid = v.payload.fid, - "X-Snap-Payload verified (FID claimed, NOT Hub-verified)" - ); - Some(v) - } - Err(e) => { - tracing::warn!( - error = %e, - "X-Snap-Payload failed verification; treating request as anonymous (v1.0 permissive; v1.1 will reject 4xx per spec)" - ); - None - } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/executor.rs b/src/executor.rs index 6a3c68bf..64273eca 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; use capnp_rpc::rpc_twoparty_capnp::Side; use capnp_rpc::twoparty::VatNetwork; use capnp_rpc::RpcSystem; @@ -8,6 +8,7 @@ use libp2p::StreamProtocol; use membrane::{Epoch, Provenance}; use std::io::IsTerminal; use std::sync::Arc; +use std::time::{Duration, Instant}; use tokio::io::{stderr, stdout, AsyncWriteExt}; use tokio::sync::{mpsc, watch}; use tokio::task::JoinHandle; @@ -607,6 +608,14 @@ impl Cell { // other cells on the same thread. tokio::task::spawn_local(rpc_system.map(|_| ())); + if stream_control.is_some() { + let timeout = export_policy_ready_timeout(); + if let Err(e) = wait_for_export_policy_ready(&guest_membrane, timeout).await { + join.abort(); + return Err(anyhow!("kernel export policy did not become ready: {e}")); + } + } + if let Some(control) = stream_control { let membrane = guest_membrane.clone(); match terminal_signing_key { @@ -647,6 +656,75 @@ impl Cell { } } +async fn wait_for_export_policy_ready( + membrane: &GuestMembrane, + timeout: Duration, +) -> std::result::Result<(), String> { + let started = Instant::now(); + let mut retry_delay = Duration::from_millis(100); + let max_retry_delay = Duration::from_secs(2); + loop { + match membrane.graft_request().send().promise.await { + Ok(_) => return Ok(()), + Err(e) => { + let msg = e.to_string(); + if !is_bootstrap_not_ready_error(&msg) { + return Err(msg); + } + if started.elapsed() >= timeout { + return Err(format!( + "timeout waiting for export policy readiness: {msg}" + )); + } + } + } + tokio::time::sleep(retry_delay).await; + retry_delay = std::cmp::min(retry_delay * 2, max_retry_delay); + } +} + +fn export_policy_ready_timeout() -> Duration { + let raw = std::env::var("WW_EXPORT_POLICY_READY_TIMEOUT_SECS").ok(); + parse_export_policy_ready_timeout(raw.as_deref()) +} + +fn parse_export_policy_ready_timeout(raw: Option<&str>) -> Duration { + const DEFAULT_SECS: u64 = 120; + match raw { + Some(raw) => match raw.parse::() { + Ok(secs) if secs > 0 => Duration::from_secs(secs), + _ => Duration::from_secs(DEFAULT_SECS), + }, + None => Duration::from_secs(DEFAULT_SECS), + } +} + +fn is_bootstrap_not_ready_error(msg: &str) -> bool { + has_exact_error_code(msg, "INIT_MEMBRANE_NOT_READY") + || has_exact_error_code(msg, "INIT_POLICY_NOT_READY") +} + +fn has_exact_error_code(msg: &str, code: &str) -> bool { + let mut search_start = 0usize; + while let Some(rel_idx) = msg[search_start..].find(code) { + let idx = search_start + rel_idx; + let end = idx + code.len(); + + let before_ok = idx == 0 || !is_error_code_word_char(msg.as_bytes()[idx - 1]); + let after_ok = end == msg.len() || !is_error_code_word_char(msg.as_bytes()[end]); + if before_ok && after_ok { + return true; + } + + search_start = idx + 1; + } + false +} + +fn is_error_code_word_char(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' +} + /// Accept incoming libp2p streams for the capnp protocol and serve each with /// the guest's exported membrane. Runs inside the cell's `LocalSet` so that /// `spawn_local` is available for per-connection tasks. @@ -770,4 +848,54 @@ mod tests { "error message should point at the architecture docs, got: {msg}", ); } + + #[test] + fn bootstrap_not_ready_error_matching_is_explicit() { + assert!(is_bootstrap_not_ready_error( + "rpc failure: INIT_POLICY_NOT_READY: kernel export policy not ready", + )); + assert!(is_bootstrap_not_ready_error( + "rpc failure: INIT_MEMBRANE_NOT_READY: kernel bootstrap membrane not ready", + )); + assert!( + !is_bootstrap_not_ready_error("rpc failure: stream not ready"), + "must not retry generic 'not ready' errors" + ); + assert!( + !is_bootstrap_not_ready_error( + "rpc failure: XINIT_POLICY_NOT_READY: malformed prefixed token", + ), + "must not retry on partial-token prefix matches" + ); + assert!( + !is_bootstrap_not_ready_error( + "rpc failure: INIT_POLICY_NOT_READYX: malformed suffixed token", + ), + "must not retry on partial-token suffix matches" + ); + } + + #[test] + fn export_policy_ready_timeout_prefers_valid_env_value() { + assert_eq!( + parse_export_policy_ready_timeout(Some("7")), + Duration::from_secs(7) + ); + } + + #[test] + fn export_policy_ready_timeout_falls_back_on_invalid_env_value() { + assert_eq!( + parse_export_policy_ready_timeout(Some("0")), + Duration::from_secs(120) + ); + assert_eq!( + parse_export_policy_ready_timeout(Some("abc")), + Duration::from_secs(120) + ); + assert_eq!( + parse_export_policy_ready_timeout(None), + Duration::from_secs(120) + ); + } } diff --git a/src/launcher.rs b/src/launcher.rs index 5b2fcdc3..bd93174c 100644 --- a/src/launcher.rs +++ b/src/launcher.rs @@ -381,6 +381,10 @@ impl system_capnp::executor::Server for ExecutorImpl { }; let bytecode = self.bytecode.clone(); + let bootstrap_schema = match rpc::decode_cell_section(&bytecode) { + Ok(Some(rpc::CellType::Capnp(schema))) => schema, + _ => Vec::new(), + }; let component = self.component.clone(); let engine = self.engine.clone(); let wasm_debug = self.wasm_debug; @@ -518,7 +522,15 @@ impl system_capnp::executor::Server for ExecutorImpl { capnp_rpc::new_client(ByteStreamImpl::new(dummy_stderr, StreamMode::ReadOnly)); let process_impl = if let Some(cap) = bootstrap_cap { - ProcessImpl::with_bootstrap(stdin, stdout, stderr, exit_rx, cap, kill_tx) + ProcessImpl::with_bootstrap( + stdin, + stdout, + stderr, + exit_rx, + cap, + bootstrap_schema.clone(), + kill_tx, + ) } else { ProcessImpl::new(stdin, stdout, stderr, exit_rx, kill_tx) }; diff --git a/std/kernel/Cargo.toml b/std/kernel/Cargo.toml index 3f1f54c5..c14054af 100644 --- a/std/kernel/Cargo.toml +++ b/std/kernel/Cargo.toml @@ -16,6 +16,7 @@ hex = "0.4" glia = { path = "../../crates/glia" } system = { path = "../system" } caps = { path = "../caps" } +schema-id = { path = "../../crates/schema-id" } [dev-dependencies] tokio = { version = "1", features = ["rt", "macros"] } diff --git a/std/kernel/src/lib.rs b/std/kernel/src/lib.rs index 1216690f..78137951 100644 --- a/std/kernel/src/lib.rs +++ b/std/kernel/src/lib.rs @@ -1,11 +1,14 @@ use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::future::Future; use std::pin::Pin; use caps::{make_import_cap, make_import_handler}; use glia::eval::{self, Dispatch, Env}; -use glia::{extract_method, make_cap, read, read_many, AttenuatedCapInner, GliaCapInner, Val}; +use glia::{ + extract_method, make_cap, read, read_many, AttenuatedCapInner, AttenuationPolicy, GliaCapInner, + Val, +}; use std::rc::Rc; @@ -61,6 +64,2416 @@ type Membrane = membrane_capnp::membrane::Client; /// has stored it. struct KernelBootstrap { membrane: Rc>>, + policy: Rc>>, +} + +const INIT_MEMBRANE_NOT_READY: &str = "INIT_MEMBRANE_NOT_READY"; +const INIT_POLICY_NOT_READY: &str = "INIT_POLICY_NOT_READY"; + +#[derive(Debug, Clone, Default)] +struct ExportPolicy { + caps: BTreeMap, +} + +#[derive(Debug, Clone, Default)] +struct ExportCapPolicy { + allow_methods: Option>, + returns: BTreeMap>, +} + +#[derive(Copy, Clone)] +enum MethodFilterCap { + Host, + Runtime, + Routing, + Identity, + Ipfs, + HttpClient, + StreamListener, + StreamDialer, + VatListener, + VatClient, + HttpListener, + Executor, + Process, + Signer, + ByteStream, + DynamicAny, +} + +fn method_filter_cap(cap_name: &str) -> Option { + match cap_name { + "host" => Some(MethodFilterCap::Host), + "runtime" => Some(MethodFilterCap::Runtime), + "routing" => Some(MethodFilterCap::Routing), + "identity" => Some(MethodFilterCap::Identity), + "ipfs" => Some(MethodFilterCap::Ipfs), + "http-client" => Some(MethodFilterCap::HttpClient), + _ => None, + } +} + +fn deny_method(interface: &str, method: &str) -> capnp::Error { + capnp::Error::failed(format!( + "permission denied: {interface}.{method} blocked by export policy" + )) +} + +fn allow_method( + policy: &ExportCapPolicy, + interface: &str, + method: &str, +) -> Result<(), capnp::Error> { + let Some(allow) = &policy.allow_methods else { + return Ok(()); + }; + if allow.contains(method) { + return Ok(()); + } + Err(deny_method(interface, method)) +} + +fn return_policy<'a>( + policy: &'a ExportCapPolicy, + method: &str, + field: &str, +) -> Option<&'a ExportCapPolicy> { + policy + .returns + .get(method) + .and_then(|fields| fields.get(field)) +} + +#[derive(Clone, Default)] +struct DynamicMethodPolicy { + interface_id: u64, + methods_by_id: BTreeMap, + allowed_ids: Option>, +} + +fn parse_interface_methods_from_schema( + schema_bytes: &[u8], +) -> Result<(u64, BTreeMap, BTreeMap), capnp::Error> { + if schema_bytes.is_empty() { + return Err(capnp::Error::failed( + "schema must not be empty for AnyPointer attenuation".into(), + )); + } + + let words = bytes_to_aligned_words(schema_bytes); + let segments: &[&[u8]] = &[capnp::Word::words_to_bytes(&words)]; + let segment_array = capnp::message::SegmentArray::new(segments); + let reader = capnp::message::Reader::new(segment_array, capnp::message::ReaderOptions::new()); + let node: capnp::schema_capnp::node::Reader<'_> = reader.get_root()?; + let iface = match node.which()? { + capnp::schema_capnp::node::Which::Interface(i) => i, + _ => { + return Err(capnp::Error::failed( + "schema must decode to a capnp interface node".into(), + )) + } + }; + + let mut by_name = BTreeMap::new(); + let mut by_id = BTreeMap::new(); + for (wire_ordinal, method) in iface.get_methods()?.iter().enumerate() { + let name = method + .get_name()? + .to_str() + .map_err(|e| capnp::Error::failed(e.to_string()))? + .to_string(); + // Cap'n Proto dispatch IDs are wire ordinals (method list index), not + // schema `codeOrder` (source declaration ordering metadata). + let id = u16::try_from(wire_ordinal) + .map_err(|_| capnp::Error::failed("method ordinal exceeds u16 range".into()))?; + by_name.insert(name.clone(), id); + by_id.insert(id, name); + } + Ok((node.get_id(), by_name, by_id)) +} + +fn bytes_to_aligned_words(bytes: &[u8]) -> Vec { + let word_count = bytes.len().div_ceil(8); + let mut words = vec![capnp::word(0, 0, 0, 0, 0, 0, 0, 0); word_count]; + capnp::Word::words_to_bytes_mut(&mut words)[..bytes.len()].copy_from_slice(bytes); + words +} + +fn canonicalize_schema_node_bytes( + node: capnp::schema_capnp::node::Reader<'_>, +) -> Result, capnp::Error> { + let mut msg = capnp::message::Builder::new_default(); + msg.set_root_canonical(node)?; + let segments = msg.get_segments_for_output(); + if segments.len() != 1 { + return Err(capnp::Error::failed( + "schema node canonicalization produced unexpected segment layout".into(), + )); + } + Ok(segments[0].to_vec()) +} + +fn build_dynamic_method_policy( + interface: &str, + method: &str, + field: &str, + policy: &ExportCapPolicy, + schema_bytes: &[u8], +) -> Result { + if !policy.returns.is_empty() { + return Err(capnp::Error::failed(format!( + "export policy {interface}.{method}.{field}: recursive :returns for unknown dynamic schema is not supported; use a known typed interface schema or omit nested :returns" + ))); + } + + let (interface_id, by_name, by_id) = parse_interface_methods_from_schema(schema_bytes)?; + let allowed_ids = match &policy.allow_methods { + None => None, + Some(allow_names) => { + let mut ids = BTreeSet::new(); + for name in allow_names { + let Some(id) = by_name.get(name) else { + return Err(capnp::Error::failed(format!( + "export policy {interface}.{method}.{field}: unknown method '{name}' for schema interface id 0x{interface_id:x}" + ))); + }; + ids.insert(*id); + } + Some(ids) + } + }; + + Ok(DynamicMethodPolicy { + interface_id, + methods_by_id: by_id, + allowed_ids, + }) +} + +fn known_cap_kind_for_schema(schema_bytes: &[u8]) -> Option { + if schema_bytes == schema_ids::HOST_SCHEMA { + return Some(MethodFilterCap::Host); + } + if schema_bytes == schema_ids::RUNTIME_SCHEMA { + return Some(MethodFilterCap::Runtime); + } + if schema_bytes == schema_ids::ROUTING_SCHEMA { + return Some(MethodFilterCap::Routing); + } + if schema_bytes == schema_ids::IDENTITY_SCHEMA { + return Some(MethodFilterCap::Identity); + } + if schema_bytes == schema_ids::HTTP_CLIENT_SCHEMA { + return Some(MethodFilterCap::HttpClient); + } + if schema_bytes == schema_ids::STREAM_DIALER_SCHEMA { + return Some(MethodFilterCap::StreamDialer); + } + if schema_bytes == schema_ids::STREAM_LISTENER_SCHEMA { + return Some(MethodFilterCap::StreamListener); + } + if schema_bytes == schema_ids::VAT_CLIENT_SCHEMA { + return Some(MethodFilterCap::VatClient); + } + if schema_bytes == schema_ids::VAT_LISTENER_SCHEMA { + return Some(MethodFilterCap::VatListener); + } + if schema_bytes == schema_ids::EXECUTOR_SCHEMA { + return Some(MethodFilterCap::Executor); + } + None +} + +#[derive(Clone)] +struct MethodFilteredDynamicCap { + inner: capnp::capability::Client, + policy: DynamicMethodPolicy, + path: String, +} + +#[derive(Clone)] +struct DynamicDispatch(std::rc::Rc); + +impl std::ops::Deref for DynamicDispatch { + type Target = MethodFilteredDynamicCap; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl capnp::capability::Server for DynamicDispatch { + fn dispatch_call( + self, + interface_id: u64, + method_id: u16, + params: capnp::capability::Params, + results: capnp::capability::Results, + ) -> capnp::capability::DispatchCallResult { + (*self.0) + .clone() + .dispatch_call(interface_id, method_id, params, results) + } + + fn as_ptr(&self) -> usize { + self.0.as_ptr() + } +} + +struct UntypedDynamicClient(capnp::capability::Client); + +impl capnp::capability::FromClientHook for UntypedDynamicClient { + fn new(hook: Box) -> Self { + Self(capnp::capability::Client::new(hook)) + } + + fn into_client_hook(self) -> Box { + self.0.hook + } + + fn as_client_hook(&self) -> &dyn capnp::private::capability::ClientHook { + self.0.hook.as_ref() + } +} + +impl capnp::capability::FromServer for UntypedDynamicClient { + type Dispatch = DynamicDispatch; + + fn from_server( + s: capnp::capability::Rc, + ) -> Self::Dispatch { + DynamicDispatch(s) + } +} + +impl capnp::capability::Server for MethodFilteredDynamicCap { + fn dispatch_call( + self, + interface_id: u64, + method_id: u16, + params: capnp::capability::Params, + mut results: capnp::capability::Results, + ) -> capnp::capability::DispatchCallResult { + if interface_id != self.policy.interface_id { + return capnp::capability::DispatchCallResult::new( + capnp::capability::Promise::err(capnp::Error::failed(format!( + "permission denied: {} rejected interface id 0x{interface_id:x} (expected 0x{:x})", + self.path, self.policy.interface_id + ))), + false, + ); + } + + let method_name = self + .policy + .methods_by_id + .get(&method_id) + .cloned() + .unwrap_or_else(|| format!("")); + + if let Some(allowed) = &self.policy.allowed_ids { + if !allowed.contains(&method_id) { + return capnp::capability::DispatchCallResult::new( + capnp::capability::Promise::err(capnp::Error::failed(format!( + "permission denied: {}.{} blocked by export policy", + self.path, method_name + ))), + false, + ); + } + } + + let req = self + .inner; + let maybe_request = params.get().and_then(|p| { + let mut request = req.new_call::( + interface_id, + method_id, + Some(p.target_size()?), + ); + request.get().set_as(p)?; + Ok(request) + }); + let promise = match maybe_request { + Ok(request) => capnp::capability::Promise::from_future(async move { + let resp = request.send().promise.await?; + results.set(resp.get()?)?; + Ok(()) + }), + Err(e) => capnp::capability::Promise::err(e), + }; + capnp::capability::DispatchCallResult::new(promise, false) + } + + fn as_ptr(&self) -> usize { + self as *const Self as usize + } +} + +#[derive(Clone)] +struct MethodFilteredHost { + inner: system_capnp::host::Client, + policy: ExportCapPolicy, +} + +#[allow(refining_impl_trait)] +impl system_capnp::host::Server for MethodFilteredHost { + fn id( + self: capnp::capability::Rc, + _params: system_capnp::host::IdParams, + mut results: system_capnp::host::IdResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "host", "id") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + capnp::capability::Promise::from_future(async move { + let resp = inner.id_request().send().promise.await?; + results.get().set_peer_id(resp.get()?.get_peer_id()?); + Ok(()) + }) + } + + fn addrs( + self: capnp::capability::Rc, + _params: system_capnp::host::AddrsParams, + mut results: system_capnp::host::AddrsResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "host", "addrs") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + capnp::capability::Promise::from_future(async move { + let resp = inner.addrs_request().send().promise.await?; + let addrs = resp.get()?.get_addrs()?; + let mut out = results.get().init_addrs(addrs.len()); + for i in 0..addrs.len() { + out.set(i, addrs.get(i)?); + } + Ok(()) + }) + } + + fn peers( + self: capnp::capability::Rc, + _params: system_capnp::host::PeersParams, + mut results: system_capnp::host::PeersResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "host", "peers") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + capnp::capability::Promise::from_future(async move { + let resp = inner.peers_request().send().promise.await?; + let peers = resp.get()?.get_peers()?; + let mut out = results.get().init_peers(peers.len()); + for i in 0..peers.len() { + let src = peers.get(i); + let mut dst = out.reborrow().get(i); + dst.set_peer_id(src.get_peer_id()?); + let src_addrs = src.get_addrs()?; + let mut dst_addrs = dst.init_addrs(src_addrs.len()); + for j in 0..src_addrs.len() { + dst_addrs.set(j, src_addrs.get(j)?); + } + } + Ok(()) + }) + } + + fn network( + self: capnp::capability::Rc, + _params: system_capnp::host::NetworkParams, + mut results: system_capnp::host::NetworkResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "host", "network") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + let policy = self.policy.clone(); + capnp::capability::Promise::from_future(async move { + let resp = inner.network_request().send().promise.await?; + let src = resp.get()?; + let mut dst = results.get(); + + let stream_listener = src.get_stream_listener()?; + let stream_listener = maybe_wrap_returned_cap( + MethodFilterCap::StreamListener, + "network", + "streamListener", + stream_listener.client, + &policy, + None, + )?; + let stream_listener: system_capnp::stream_listener::Client = + capnp::capability::FromClientHook::new(stream_listener.hook.clone()); + dst.set_stream_listener(stream_listener); + + let stream_dialer = src.get_stream_dialer()?; + let stream_dialer = maybe_wrap_returned_cap( + MethodFilterCap::StreamDialer, + "network", + "streamDialer", + stream_dialer.client, + &policy, + None, + )?; + let stream_dialer: system_capnp::stream_dialer::Client = + capnp::capability::FromClientHook::new(stream_dialer.hook.clone()); + dst.set_stream_dialer(stream_dialer); + + let vat_listener = src.get_vat_listener()?; + let vat_listener = maybe_wrap_returned_cap( + MethodFilterCap::VatListener, + "network", + "vatListener", + vat_listener.client, + &policy, + None, + )?; + let vat_listener: system_capnp::vat_listener::Client = + capnp::capability::FromClientHook::new(vat_listener.hook.clone()); + dst.set_vat_listener(vat_listener); + + let vat_client = src.get_vat_client()?; + let vat_client = maybe_wrap_returned_cap( + MethodFilterCap::VatClient, + "network", + "vatClient", + vat_client.client, + &policy, + None, + )?; + let vat_client: system_capnp::vat_client::Client = + capnp::capability::FromClientHook::new(vat_client.hook.clone()); + dst.set_vat_client(vat_client); + + let http_listener = src.get_http_listener()?; + let http_listener = maybe_wrap_returned_cap( + MethodFilterCap::HttpListener, + "network", + "httpListener", + http_listener.client, + &policy, + None, + )?; + let http_listener: system_capnp::http_listener::Client = + capnp::capability::FromClientHook::new(http_listener.hook.clone()); + dst.set_http_listener(http_listener); + Ok(()) + }) + } +} + +#[derive(Clone)] +struct MethodFilteredRuntime { + inner: system_capnp::runtime::Client, + policy: ExportCapPolicy, +} + +#[allow(refining_impl_trait)] +impl system_capnp::runtime::Server for MethodFilteredRuntime { + fn load( + self: capnp::capability::Rc, + params: system_capnp::runtime::LoadParams, + mut results: system_capnp::runtime::LoadResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "runtime", "load") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + let policy = self.policy.clone(); + let wasm = match params.get() { + Ok(p) => match p.get_wasm() { + Ok(w) => w.to_vec(), + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + capnp::capability::Promise::from_future(async move { + let mut req = inner.load_request(); + req.get().set_wasm(&wasm); + let resp = req.send().promise.await?; + let executor = resp.get()?.get_executor()?; + let executor = maybe_wrap_returned_cap( + MethodFilterCap::Executor, + "load", + "executor", + executor.client, + &policy, + None, + )?; + let executor: system_capnp::executor::Client = + capnp::capability::FromClientHook::new(executor.hook.clone()); + results.get().set_executor(executor); + Ok(()) + }) + } + + fn shutdown( + self: capnp::capability::Rc, + _params: system_capnp::runtime::ShutdownParams, + _results: system_capnp::runtime::ShutdownResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "runtime", "shutdown") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + capnp::capability::Promise::from_future(async move { + inner.shutdown_request().send().promise.await?; + Ok(()) + }) + } +} + +#[derive(Clone)] +struct MethodFilteredRouting { + inner: routing_capnp::routing::Client, + policy: ExportCapPolicy, +} + +#[allow(refining_impl_trait)] +impl routing_capnp::routing::Server for MethodFilteredRouting { + fn provide( + self: capnp::capability::Rc, + params: routing_capnp::routing::ProvideParams, + _results: routing_capnp::routing::ProvideResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "routing", "provide") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + let key = match params.get() { + Ok(p) => match p.get_key() { + Ok(k) => match k.to_string() { + Ok(s) => s, + Err(e) => { + return capnp::capability::Promise::err(capnp::Error::failed(e.to_string())) + } + }, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + capnp::capability::Promise::from_future(async move { + let mut req = inner.provide_request(); + req.get().set_key(&key); + req.send().promise.await?; + Ok(()) + }) + } + + fn find_providers( + self: capnp::capability::Rc, + params: routing_capnp::routing::FindProvidersParams, + _results: routing_capnp::routing::FindProvidersResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "routing", "findProviders") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + let (key, count, sink) = match params.get() { + Ok(p) => { + let key = match p.get_key() { + Ok(k) => match k.to_string() { + Ok(s) => s, + Err(e) => { + return capnp::capability::Promise::err(capnp::Error::failed( + e.to_string(), + )) + } + }, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + let count = p.get_count(); + let sink = match p.get_sink() { + Ok(s) => s, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + (key, count, sink) + } + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + capnp::capability::Promise::from_future(async move { + let mut req = inner.find_providers_request(); + req.get().set_key(&key); + req.get().set_count(count); + req.get().set_sink(sink); + req.send().promise.await?; + Ok(()) + }) + } + + fn hash( + self: capnp::capability::Rc, + params: routing_capnp::routing::HashParams, + mut results: routing_capnp::routing::HashResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "routing", "hash") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + let data = match params.get() { + Ok(p) => match p.get_data() { + Ok(d) => d.to_vec(), + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + capnp::capability::Promise::from_future(async move { + let mut req = inner.hash_request(); + req.get().set_data(&data); + let resp = req.send().promise.await?; + let key = resp + .get()? + .get_key()? + .to_string() + .map_err(|e| capnp::Error::failed(e.to_string()))?; + results.get().set_key(&key); + Ok(()) + }) + } + + fn resolve( + self: capnp::capability::Rc, + params: routing_capnp::routing::ResolveParams, + mut results: routing_capnp::routing::ResolveResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "routing", "resolve") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + let name = match params.get() { + Ok(p) => match p.get_name() { + Ok(n) => match n.to_string() { + Ok(s) => s, + Err(e) => { + return capnp::capability::Promise::err(capnp::Error::failed(e.to_string())) + } + }, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + capnp::capability::Promise::from_future(async move { + let mut req = inner.resolve_request(); + req.get().set_name(&name); + let resp = req.send().promise.await?; + let path = resp + .get()? + .get_path()? + .to_string() + .map_err(|e| capnp::Error::failed(e.to_string()))?; + results.get().set_path(&path); + Ok(()) + }) + } + + fn mkdir( + self: capnp::capability::Rc, + params: routing_capnp::routing::MkdirParams, + mut results: routing_capnp::routing::MkdirResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "routing", "mkdir") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + let (base, path, parents) = match params.get() { + Ok(p) => { + let base = match p.get_base_cid() { + Ok(v) => match v.to_string() { + Ok(s) => s, + Err(e) => { + return capnp::capability::Promise::err(capnp::Error::failed( + e.to_string(), + )) + } + }, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + let path = match p.get_path() { + Ok(v) => match v.to_string() { + Ok(s) => s, + Err(e) => { + return capnp::capability::Promise::err(capnp::Error::failed( + e.to_string(), + )) + } + }, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + (base, path, p.get_parents()) + } + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + capnp::capability::Promise::from_future(async move { + let mut req = inner.mkdir_request(); + req.get().set_base_cid(&base); + req.get().set_path(&path); + req.get().set_parents(parents); + let resp = req.send().promise.await?; + let root_cid = resp + .get()? + .get_root_cid()? + .to_string() + .map_err(|e| capnp::Error::failed(e.to_string()))?; + results.get().set_root_cid(&root_cid); + Ok(()) + }) + } + + fn write_file( + self: capnp::capability::Rc, + params: routing_capnp::routing::WriteFileParams, + mut results: routing_capnp::routing::WriteFileResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "routing", "writeFile") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + let (base, path, data, create_parents) = match params.get() { + Ok(p) => { + let base = match p.get_base_cid() { + Ok(v) => match v.to_string() { + Ok(s) => s, + Err(e) => { + return capnp::capability::Promise::err(capnp::Error::failed( + e.to_string(), + )) + } + }, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + let path = match p.get_path() { + Ok(v) => match v.to_string() { + Ok(s) => s, + Err(e) => { + return capnp::capability::Promise::err(capnp::Error::failed( + e.to_string(), + )) + } + }, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + let data = match p.get_data() { + Ok(v) => v.to_vec(), + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + (base, path, data, p.get_create_parents()) + } + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + capnp::capability::Promise::from_future(async move { + let mut req = inner.write_file_request(); + req.get().set_base_cid(&base); + req.get().set_path(&path); + req.get().set_data(&data); + req.get().set_create_parents(create_parents); + let resp = req.send().promise.await?; + let root_cid = resp + .get()? + .get_root_cid()? + .to_string() + .map_err(|e| capnp::Error::failed(e.to_string()))?; + results.get().set_root_cid(&root_cid); + Ok(()) + }) + } + + fn remove( + self: capnp::capability::Rc, + params: routing_capnp::routing::RemoveParams, + mut results: routing_capnp::routing::RemoveResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "routing", "remove") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + let (base, path, recursive) = match params.get() { + Ok(p) => { + let base = match p.get_base_cid() { + Ok(v) => match v.to_string() { + Ok(s) => s, + Err(e) => { + return capnp::capability::Promise::err(capnp::Error::failed( + e.to_string(), + )) + } + }, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + let path = match p.get_path() { + Ok(v) => match v.to_string() { + Ok(s) => s, + Err(e) => { + return capnp::capability::Promise::err(capnp::Error::failed( + e.to_string(), + )) + } + }, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + (base, path, p.get_recursive()) + } + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + capnp::capability::Promise::from_future(async move { + let mut req = inner.remove_request(); + req.get().set_base_cid(&base); + req.get().set_path(&path); + req.get().set_recursive(recursive); + let resp = req.send().promise.await?; + let root_cid = resp + .get()? + .get_root_cid()? + .to_string() + .map_err(|e| capnp::Error::failed(e.to_string()))?; + results.get().set_root_cid(&root_cid); + Ok(()) + }) + } + + fn publish( + self: capnp::capability::Rc, + params: routing_capnp::routing::PublishParams, + mut results: routing_capnp::routing::PublishResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "routing", "publish") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + let (name, cid, expected_current) = match params.get() { + Ok(p) => { + let name = match p.get_name() { + Ok(v) => match v.to_string() { + Ok(s) => s, + Err(e) => { + return capnp::capability::Promise::err(capnp::Error::failed( + e.to_string(), + )) + } + }, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + let cid = match p.get_cid() { + Ok(v) => match v.to_string() { + Ok(s) => s, + Err(e) => { + return capnp::capability::Promise::err(capnp::Error::failed( + e.to_string(), + )) + } + }, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + let expected_current = match p.get_expected_current() { + Ok(v) => match v.to_string() { + Ok(s) => s, + Err(e) => { + return capnp::capability::Promise::err(capnp::Error::failed( + e.to_string(), + )) + } + }, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + (name, cid, expected_current) + } + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + capnp::capability::Promise::from_future(async move { + let mut req = inner.publish_request(); + req.get().set_name(&name); + req.get().set_cid(&cid); + req.get().set_expected_current(&expected_current); + let resp = req.send().promise.await?; + let published_path = resp + .get()? + .get_published_path()? + .to_string() + .map_err(|e| capnp::Error::failed(e.to_string()))?; + results.get().set_published_path(&published_path); + Ok(()) + }) + } +} + +#[derive(Clone)] +struct MethodFilteredIpfs { + inner: system_capnp::ipfs::Client, + policy: ExportCapPolicy, +} + +#[allow(refining_impl_trait)] +impl system_capnp::ipfs::Server for MethodFilteredIpfs { + fn read( + self: capnp::capability::Rc, + params: system_capnp::ipfs::ReadParams, + mut results: system_capnp::ipfs::ReadResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "ipfs", "read") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + let policy = self.policy.clone(); + let path = match params.get() { + Ok(p) => match p.get_path() { + Ok(v) => match v.to_string() { + Ok(s) => s, + Err(e) => { + return capnp::capability::Promise::err(capnp::Error::failed(e.to_string())) + } + }, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + capnp::capability::Promise::from_future(async move { + let mut req = inner.read_request(); + req.get().set_path(&path); + let resp = req.send().promise.await?; + let stream = resp.get()?.get_stream()?; + let stream = maybe_wrap_returned_cap( + MethodFilterCap::ByteStream, + "read", + "stream", + stream.client, + &policy, + None, + )?; + let stream: system_capnp::byte_stream::Client = + capnp::capability::FromClientHook::new(stream.hook.clone()); + results.get().set_stream(stream); + Ok(()) + }) + } +} + +#[derive(Clone)] +struct MethodFilteredHttpClient { + inner: http_capnp::http_client::Client, + policy: ExportCapPolicy, +} + +#[allow(refining_impl_trait)] +impl http_capnp::http_client::Server for MethodFilteredHttpClient { + fn get( + self: capnp::capability::Rc, + params: http_capnp::http_client::GetParams, + mut results: http_capnp::http_client::GetResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "http-client", "get") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + let (url, headers) = match params.get() { + Ok(p) => { + let url = match p.get_url() { + Ok(v) => match v.to_string() { + Ok(s) => s, + Err(e) => { + return capnp::capability::Promise::err(capnp::Error::failed( + e.to_string(), + )) + } + }, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + let headers = match p.get_headers() { + Ok(v) => v, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + let mut pairs = Vec::new(); + for i in 0..headers.len() { + let h = headers.get(i); + let name = match h.get_name() { + Ok(v) => match v.to_string() { + Ok(s) => s, + Err(e) => { + return capnp::capability::Promise::err(capnp::Error::failed( + e.to_string(), + )) + } + }, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + let value = match h.get_value() { + Ok(v) => match v.to_string() { + Ok(s) => s, + Err(e) => { + return capnp::capability::Promise::err(capnp::Error::failed( + e.to_string(), + )) + } + }, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + pairs.push((name, value)); + } + (url, pairs) + } + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + capnp::capability::Promise::from_future(async move { + let mut req = inner.get_request(); + req.get().set_url(&url); + let mut out_headers = req.get().init_headers(headers.len() as u32); + for (i, (name, value)) in headers.iter().enumerate() { + let mut h = out_headers.reborrow().get(i as u32); + h.set_name(name); + h.set_value(value); + } + let resp = req.send().promise.await?; + let src = resp.get()?; + let mut dst = results.get(); + dst.set_status(src.get_status()); + dst.set_body(src.get_body()?); + let src_headers = src.get_headers()?; + let mut dst_headers = dst.init_headers(src_headers.len()); + for i in 0..src_headers.len() { + let h = src_headers.get(i); + let mut o = dst_headers.reborrow().get(i); + o.set_name(h.get_name()?); + o.set_value(h.get_value()?); + } + Ok(()) + }) + } + + fn post( + self: capnp::capability::Rc, + params: http_capnp::http_client::PostParams, + mut results: http_capnp::http_client::PostResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "http-client", "post") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + let (url, headers, body) = match params.get() { + Ok(p) => { + let url = match p.get_url() { + Ok(v) => match v.to_string() { + Ok(s) => s, + Err(e) => { + return capnp::capability::Promise::err(capnp::Error::failed( + e.to_string(), + )) + } + }, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + let mut pairs = Vec::new(); + let headers = match p.get_headers() { + Ok(v) => v, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + for i in 0..headers.len() { + let h = headers.get(i); + let name = match h.get_name() { + Ok(v) => match v.to_string() { + Ok(s) => s, + Err(e) => { + return capnp::capability::Promise::err(capnp::Error::failed( + e.to_string(), + )) + } + }, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + let value = match h.get_value() { + Ok(v) => match v.to_string() { + Ok(s) => s, + Err(e) => { + return capnp::capability::Promise::err(capnp::Error::failed( + e.to_string(), + )) + } + }, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + pairs.push((name, value)); + } + let body = match p.get_body() { + Ok(v) => v.to_vec(), + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + (url, pairs, body) + } + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + capnp::capability::Promise::from_future(async move { + let mut req = inner.post_request(); + req.get().set_url(&url); + req.get().set_body(&body); + let mut out_headers = req.get().init_headers(headers.len() as u32); + for (i, (name, value)) in headers.iter().enumerate() { + let mut h = out_headers.reborrow().get(i as u32); + h.set_name(name); + h.set_value(value); + } + let resp = req.send().promise.await?; + let src = resp.get()?; + let mut dst = results.get(); + dst.set_status(src.get_status()); + dst.set_body(src.get_body()?); + let src_headers = src.get_headers()?; + let mut dst_headers = dst.init_headers(src_headers.len()); + for i in 0..src_headers.len() { + let h = src_headers.get(i); + let mut o = dst_headers.reborrow().get(i); + o.set_name(h.get_name()?); + o.set_value(h.get_value()?); + } + Ok(()) + }) + } +} + +#[derive(Clone)] +struct MethodFilteredIdentity { + inner: auth_capnp::identity::Client, + policy: ExportCapPolicy, +} + +#[allow(refining_impl_trait)] +impl auth_capnp::identity::Server for MethodFilteredIdentity { + fn signer( + self: capnp::capability::Rc, + params: auth_capnp::identity::SignerParams, + mut results: auth_capnp::identity::SignerResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "identity", "signer") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + let policy = self.policy.clone(); + let domain = match params.get() { + Ok(p) => match p.get_domain() { + Ok(v) => match v.to_string() { + Ok(s) => s, + Err(e) => { + return capnp::capability::Promise::err(capnp::Error::failed(e.to_string())) + } + }, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + capnp::capability::Promise::from_future(async move { + let mut req = inner.signer_request(); + req.get().set_domain(&domain); + let resp = req.send().promise.await?; + let signer = resp.get()?.get_signer()?; + let signer = maybe_wrap_returned_cap( + MethodFilterCap::Signer, + "signer", + "signer", + signer.client, + &policy, + None, + )?; + let signer: auth_capnp::signer::Client = + capnp::capability::FromClientHook::new(signer.hook.clone()); + results.get().set_signer(signer); + Ok(()) + }) + } + + fn verify( + self: capnp::capability::Rc, + params: auth_capnp::identity::VerifyParams, + mut results: auth_capnp::identity::VerifyResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "identity", "verify") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + let (data, sig, pubkey) = match params.get() { + Ok(p) => { + let data = match p.get_data() { + Ok(v) => v.to_vec(), + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + let sig = match p.get_signature() { + Ok(v) => v.to_vec(), + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + let pubkey = match p.get_pubkey() { + Ok(v) => v.to_vec(), + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + (data, sig, pubkey) + } + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + capnp::capability::Promise::from_future(async move { + let mut req = inner.verify_request(); + req.get().set_data(&data); + req.get().set_signature(&sig); + req.get().set_pubkey(&pubkey); + let resp = req.send().promise.await?; + results.get().set_valid(resp.get()?.get_valid()); + Ok(()) + }) + } +} + +#[derive(Clone)] +struct MethodFilteredStreamListener { + inner: system_capnp::stream_listener::Client, + policy: ExportCapPolicy, +} + +#[allow(refining_impl_trait)] +impl system_capnp::stream_listener::Server for MethodFilteredStreamListener { + fn listen( + self: capnp::capability::Rc, + params: system_capnp::stream_listener::ListenParams, + _results: system_capnp::stream_listener::ListenResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "stream-listener", "listen") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + let (executor, protocol) = match params.get() { + Ok(p) => { + let executor = match p.get_executor() { + Ok(v) => v, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + let protocol = match p.get_protocol() { + Ok(v) => match v.to_string() { + Ok(s) => s, + Err(e) => { + return capnp::capability::Promise::err(capnp::Error::failed( + e.to_string(), + )) + } + }, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + (executor, protocol) + } + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + capnp::capability::Promise::from_future(async move { + let mut req = inner.listen_request(); + req.get().set_executor(executor); + req.get().set_protocol(&protocol); + req.send().promise.await?; + Ok(()) + }) + } +} + +#[derive(Clone)] +struct MethodFilteredStreamDialer { + inner: system_capnp::stream_dialer::Client, + policy: ExportCapPolicy, +} + +#[allow(refining_impl_trait)] +impl system_capnp::stream_dialer::Server for MethodFilteredStreamDialer { + fn dial( + self: capnp::capability::Rc, + params: system_capnp::stream_dialer::DialParams, + mut results: system_capnp::stream_dialer::DialResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "stream-dialer", "dial") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + let (peer, protocol) = match params.get() { + Ok(p) => { + let peer = match p.get_peer() { + Ok(v) => v.to_vec(), + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + let protocol = match p.get_protocol() { + Ok(v) => match v.to_string() { + Ok(s) => s, + Err(e) => { + return capnp::capability::Promise::err(capnp::Error::failed( + e.to_string(), + )) + } + }, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + (peer, protocol) + } + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + let policy = self.policy.clone(); + capnp::capability::Promise::from_future(async move { + let mut req = inner.dial_request(); + req.get().set_peer(&peer); + req.get().set_protocol(&protocol); + let resp = req.send().promise.await?; + let stream = resp.get()?.get_stream()?; + let stream = maybe_wrap_returned_cap( + MethodFilterCap::ByteStream, + "dial", + "stream", + stream.client, + &policy, + None, + )?; + let stream: system_capnp::byte_stream::Client = + capnp::capability::FromClientHook::new(stream.hook.clone()); + results.get().set_stream(stream); + Ok(()) + }) + } +} + +#[derive(Clone)] +struct MethodFilteredVatListener { + inner: system_capnp::vat_listener::Client, + policy: ExportCapPolicy, +} + +#[allow(refining_impl_trait)] +impl system_capnp::vat_listener::Server for MethodFilteredVatListener { + fn listen( + self: capnp::capability::Rc, + params: system_capnp::vat_listener::ListenParams, + _results: system_capnp::vat_listener::ListenResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "vat-listener", "listen") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + let mut req = inner.listen_request(); + { + let p = match params.get() { + Ok(v) => v, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + let handler = match p.get_handler() { + Ok(v) => v, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + let descriptor = match p.get_descriptor() { + Ok(v) => v, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + let descriptor_wasi_cid = match descriptor.get_wasi_cid() { + Ok(v) => v.to_vec(), + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + let descriptor_schema_cid = match descriptor.get_schema_cid() { + Ok(v) => v.to_vec(), + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + let caps = match p.get_caps() { + Ok(v) => v, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + + let mut out = req.get(); + { + let mut out_handler = out.reborrow().init_handler(); + match handler.which() { + Ok(system_capnp::vat_handler::WhichReader::Spawn(executor)) => { + let executor = match executor { + Ok(v) => v, + Err(e) => return capnp::capability::Promise::err(e), + }; + out_handler.set_spawn(executor); + } + Ok(system_capnp::vat_handler::WhichReader::Serve(typed)) => { + let typed = match typed { + Ok(v) => v, + Err(e) => return capnp::capability::Promise::err(e), + }; + let cap = match typed.get_cap().get_as_capability::() { + Ok(v) => v, + Err(e) => return capnp::capability::Promise::err(e), + }; + let mut out_typed = out_handler.init_serve(); + out_typed + .reborrow() + .init_cap() + .set_as_capability(cap.hook.clone()); + if !typed.has_schema() { + return capnp::capability::Promise::err(capnp::Error::failed( + "vat-listener.listen serve handler TypedCap missing schema".into(), + )); + } + let schema = match typed.get_schema() { + Ok(v) => v, + Err(e) => { + return capnp::capability::Promise::err(capnp::Error::from(e)); + } + }; + let root = match schema.get_root() { + Ok(v) => v, + Err(e) => { + return capnp::capability::Promise::err(capnp::Error::from(e)); + } + }; + let deps = match schema.get_deps() { + Ok(v) => v, + Err(e) => { + return capnp::capability::Promise::err(capnp::Error::from(e)); + } + }; + let mut out_schema = out_typed.reborrow().init_schema(); + if let Err(e) = out_schema.set_root(root) { + return capnp::capability::Promise::err(e); + } + if let Err(e) = out_schema.set_deps(deps) { + return capnp::capability::Promise::err(e); + } + } + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + } + } + let mut out_descriptor = out.reborrow().init_descriptor(); + out_descriptor.set_wasi_cid(&descriptor_wasi_cid); + out_descriptor.set_schema_cid(&descriptor_schema_cid); + let mut out_caps = out.init_caps(caps.len()); + for i in 0..caps.len() { + let src = caps.get(i); + let mut dst = out_caps.reborrow().get(i); + let name = match src.get_name() { + Ok(v) => v, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + dst.set_name(name); + if src.has_schema() { + let schema = match src.get_schema() { + Ok(v) => v, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + if let Err(e) = dst.set_schema(schema) { + return capnp::capability::Promise::err(capnp::Error::from(e)); + } + } + let cap = match src + .get_cap() + .get_as_capability::() + { + Ok(v) => v, + Err(e) => return capnp::capability::Promise::err(e), + }; + dst.reborrow() + .init_cap() + .set_as_capability(cap.hook.clone()); + } + } + let promise = req.send().promise; + capnp::capability::Promise::from_future(async move { + promise.await?; + Ok(()) + }) + } +} + +#[derive(Clone)] +struct MethodFilteredVatClient { + inner: system_capnp::vat_client::Client, + policy: ExportCapPolicy, +} + +#[allow(refining_impl_trait)] +impl system_capnp::vat_client::Server for MethodFilteredVatClient { + fn dial( + self: capnp::capability::Rc, + params: system_capnp::vat_client::DialParams, + mut results: system_capnp::vat_client::DialResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "vat-client", "dial") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + let policy = self.policy.clone(); + let (peer, descriptor_wasi_cid, descriptor_schema_cid) = match params.get() { + Ok(p) => { + let peer = match p.get_peer() { + Ok(v) => v.to_vec(), + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + let descriptor = match p.get_descriptor() { + Ok(v) => v, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + let descriptor_wasi_cid = match descriptor.get_wasi_cid() { + Ok(v) => v.to_vec(), + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + let descriptor_schema_cid = match descriptor.get_schema_cid() { + Ok(v) => v.to_vec(), + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + (peer, descriptor_wasi_cid, descriptor_schema_cid) + } + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + capnp::capability::Promise::from_future(async move { + let mut req = inner.dial_request(); + req.get().set_peer(&peer); + let mut out_descriptor = req.get().init_descriptor(); + out_descriptor.set_wasi_cid(&descriptor_wasi_cid); + out_descriptor.set_schema_cid(&descriptor_schema_cid); + let resp = req.send().promise.await?; + let typed = resp.get()?.get_typed()?; + let cap = typed.get_cap().get_as_capability::()?; + let typed_schema = typed.get_schema()?; + let typed_schema_root = typed_schema.get_root()?; + let typed_schema_root_bytes = canonicalize_schema_node_bytes(typed_schema_root)?; + let cap = maybe_wrap_returned_cap( + MethodFilterCap::DynamicAny, + "dial", + "cap", + cap, + &policy, + Some(&typed_schema_root_bytes), + )?; + let mut out_typed = results.get().init_typed(); + out_typed + .reborrow() + .init_cap() + .set_as_capability(cap.hook.clone()); + let deps = typed_schema.get_deps()?; + let mut out_schema = out_typed.reborrow().init_schema(); + out_schema.set_root(typed_schema_root)?; + out_schema.set_deps(deps)?; + Ok(()) + }) + } +} + +#[derive(Clone)] +struct MethodFilteredHttpListener { + inner: system_capnp::http_listener::Client, + policy: ExportCapPolicy, +} + +#[allow(refining_impl_trait)] +impl system_capnp::http_listener::Server for MethodFilteredHttpListener { + fn listen( + self: capnp::capability::Rc, + params: system_capnp::http_listener::ListenParams, + _results: system_capnp::http_listener::ListenResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "http-listener", "listen") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + let mut req = inner.listen_request(); + { + let p = match params.get() { + Ok(v) => v, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + let executor = match p.get_executor() { + Ok(v) => v, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + let prefix = match p.get_prefix() { + Ok(v) => v, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + let caps = match p.get_caps() { + Ok(v) => v, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + + let mut out = req.get(); + out.reborrow().set_executor(executor); + out.reborrow().set_prefix(prefix); + let mut out_caps = out.init_caps(caps.len()); + for i in 0..caps.len() { + let src = caps.get(i); + let mut dst = out_caps.reborrow().get(i); + let name = match src.get_name() { + Ok(v) => v, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + dst.set_name(name); + if src.has_schema() { + let schema = match src.get_schema() { + Ok(v) => v, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + if let Err(e) = dst.set_schema(schema) { + return capnp::capability::Promise::err(capnp::Error::from(e)); + } + } + let cap = match src + .get_cap() + .get_as_capability::() + { + Ok(v) => v, + Err(e) => return capnp::capability::Promise::err(e), + }; + dst.reborrow() + .init_cap() + .set_as_capability(cap.hook.clone()); + } + } + let promise = req.send().promise; + capnp::capability::Promise::from_future(async move { + promise.await?; + Ok(()) + }) + } +} + +#[derive(Clone)] +struct MethodFilteredExecutor { + inner: system_capnp::executor::Client, + policy: ExportCapPolicy, +} + +#[allow(refining_impl_trait)] +impl system_capnp::executor::Server for MethodFilteredExecutor { + fn spawn( + self: capnp::capability::Rc, + params: system_capnp::executor::SpawnParams, + mut results: system_capnp::executor::SpawnResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "executor", "spawn") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + let policy = self.policy.clone(); + capnp::capability::Promise::from_future(async move { + let p = params.get()?; + let args = p.get_args()?; + let env = p.get_env()?; + let caps = p.get_caps()?; + let fuel = p.get_fuel_policy()?; + + let mut req = inner.spawn_request(); + { + let mut out = req.get(); + let mut out_args = out.reborrow().init_args(args.len()); + for i in 0..args.len() { + out_args.set(i, args.get(i)?); + } + let mut out_env = out.reborrow().init_env(env.len()); + for i in 0..env.len() { + out_env.set(i, env.get(i)?); + } + let mut out_caps = out.reborrow().init_caps(caps.len()); + for i in 0..caps.len() { + let src = caps.get(i); + let mut dst = out_caps.reborrow().get(i); + dst.set_name(src.get_name()?); + if src.has_schema() { + dst.set_schema(src.get_schema()?)?; + } + let cap = src + .get_cap() + .get_as_capability::()?; + dst.reborrow() + .init_cap() + .set_as_capability(cap.hook.clone()); + } + let mut out_fuel = out.init_fuel_policy(); + match fuel.which()? { + system_capnp::fuel_policy::Which::Scheduled(()) => out_fuel.set_scheduled(()), + system_capnp::fuel_policy::Which::Oneshot(src) => { + let src = src?; + let mut dst = out_fuel.init_oneshot(); + dst.set_total_budget(src.get_total_budget()); + dst.set_max_per_epoch(src.get_max_per_epoch()); + dst.set_min_per_epoch(src.get_min_per_epoch()); + } + } + } + let resp = req.send().promise.await?; + let process = resp.get()?.get_process()?; + let process = maybe_wrap_returned_cap( + MethodFilterCap::Process, + "spawn", + "process", + process.client, + &policy, + None, + )?; + let process: system_capnp::process::Client = + capnp::capability::FromClientHook::new(process.hook.clone()); + results.get().set_process(process); + Ok(()) + }) + } +} + +#[derive(Clone)] +struct MethodFilteredProcess { + inner: system_capnp::process::Client, + policy: ExportCapPolicy, +} + +#[allow(refining_impl_trait)] +impl system_capnp::process::Server for MethodFilteredProcess { + fn stdin( + self: capnp::capability::Rc, + _params: system_capnp::process::StdinParams, + mut results: system_capnp::process::StdinResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "process", "stdin") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + let policy = self.policy.clone(); + capnp::capability::Promise::from_future(async move { + let resp = inner.stdin_request().send().promise.await?; + let stream = resp.get()?.get_stream()?; + let stream = maybe_wrap_returned_cap( + MethodFilterCap::ByteStream, + "stdin", + "stream", + stream.client, + &policy, + None, + )?; + let stream: system_capnp::byte_stream::Client = + capnp::capability::FromClientHook::new(stream.hook.clone()); + results.get().set_stream(stream); + Ok(()) + }) + } + + fn stdout( + self: capnp::capability::Rc, + _params: system_capnp::process::StdoutParams, + mut results: system_capnp::process::StdoutResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "process", "stdout") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + let policy = self.policy.clone(); + capnp::capability::Promise::from_future(async move { + let resp = inner.stdout_request().send().promise.await?; + let stream = resp.get()?.get_stream()?; + let stream = maybe_wrap_returned_cap( + MethodFilterCap::ByteStream, + "stdout", + "stream", + stream.client, + &policy, + None, + )?; + let stream: system_capnp::byte_stream::Client = + capnp::capability::FromClientHook::new(stream.hook.clone()); + results.get().set_stream(stream); + Ok(()) + }) + } + + fn stderr( + self: capnp::capability::Rc, + _params: system_capnp::process::StderrParams, + mut results: system_capnp::process::StderrResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "process", "stderr") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + let policy = self.policy.clone(); + capnp::capability::Promise::from_future(async move { + let resp = inner.stderr_request().send().promise.await?; + let stream = resp.get()?.get_stream()?; + let stream = maybe_wrap_returned_cap( + MethodFilterCap::ByteStream, + "stderr", + "stream", + stream.client, + &policy, + None, + )?; + let stream: system_capnp::byte_stream::Client = + capnp::capability::FromClientHook::new(stream.hook.clone()); + results.get().set_stream(stream); + Ok(()) + }) + } + + fn wait( + self: capnp::capability::Rc, + _params: system_capnp::process::WaitParams, + mut results: system_capnp::process::WaitResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "process", "wait") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + capnp::capability::Promise::from_future(async move { + let resp = inner.wait_request().send().promise.await?; + results.get().set_exit_code(resp.get()?.get_exit_code()); + Ok(()) + }) + } + + fn bootstrap( + self: capnp::capability::Rc, + _params: system_capnp::process::BootstrapParams, + mut results: system_capnp::process::BootstrapResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "process", "bootstrap") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + let policy = self.policy.clone(); + capnp::capability::Promise::from_future(async move { + let resp = inner.bootstrap_request().send().promise.await?; + let typed = resp.get()?.get_typed()?; + let cap = typed.get_cap().get_as_capability::()?; + let typed_schema = typed.get_schema()?; + let typed_schema_root = typed_schema.get_root()?; + let typed_schema_root_bytes = canonicalize_schema_node_bytes(typed_schema_root)?; + let cap = maybe_wrap_returned_cap( + MethodFilterCap::DynamicAny, + "bootstrap", + "cap", + cap, + &policy, + Some(&typed_schema_root_bytes), + )?; + let mut out_typed = results.get().init_typed(); + out_typed + .reborrow() + .init_cap() + .set_as_capability(cap.hook.clone()); + let deps = typed_schema.get_deps()?; + let mut out_schema = out_typed.reborrow().init_schema(); + out_schema.set_root(typed_schema_root)?; + out_schema.set_deps(deps)?; + Ok(()) + }) + } + + fn kill( + self: capnp::capability::Rc, + _params: system_capnp::process::KillParams, + _results: system_capnp::process::KillResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "process", "kill") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + capnp::capability::Promise::from_future(async move { + inner.kill_request().send().promise.await?; + Ok(()) + }) + } +} + +#[derive(Clone)] +struct MethodFilteredSigner { + inner: auth_capnp::signer::Client, + policy: ExportCapPolicy, +} + +#[allow(refining_impl_trait)] +impl auth_capnp::signer::Server for MethodFilteredSigner { + fn sign( + self: capnp::capability::Rc, + params: auth_capnp::signer::SignParams, + mut results: auth_capnp::signer::SignResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "signer", "sign") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + let (nonce, epoch_seq) = match params.get() { + Ok(p) => (p.get_nonce(), p.get_epoch_seq()), + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + capnp::capability::Promise::from_future(async move { + let mut req = inner.sign_request(); + req.get().set_nonce(nonce); + req.get().set_epoch_seq(epoch_seq); + let resp = req.send().promise.await?; + results.get().set_sig(resp.get()?.get_sig()?); + Ok(()) + }) + } +} + +#[derive(Clone)] +struct MethodFilteredByteStream { + inner: system_capnp::byte_stream::Client, + policy: ExportCapPolicy, +} + +#[allow(refining_impl_trait)] +impl system_capnp::byte_stream::Server for MethodFilteredByteStream { + fn read( + self: capnp::capability::Rc, + params: system_capnp::byte_stream::ReadParams, + mut results: system_capnp::byte_stream::ReadResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "byte-stream", "read") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + let max_bytes = match params.get() { + Ok(p) => p.get_max_bytes(), + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + capnp::capability::Promise::from_future(async move { + let mut req = inner.read_request(); + req.get().set_max_bytes(max_bytes); + let resp = req.send().promise.await?; + results.get().set_data(resp.get()?.get_data()?); + Ok(()) + }) + } + + fn write( + self: capnp::capability::Rc, + params: system_capnp::byte_stream::WriteParams, + _results: system_capnp::byte_stream::WriteResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "byte-stream", "write") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + let data = match params.get() { + Ok(p) => match p.get_data() { + Ok(v) => v.to_vec(), + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }, + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + capnp::capability::Promise::from_future(async move { + let mut req = inner.write_request(); + req.get().set_data(&data); + req.send().promise.await?; + Ok(()) + }) + } + + fn close( + self: capnp::capability::Rc, + _params: system_capnp::byte_stream::CloseParams, + _results: system_capnp::byte_stream::CloseResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + if let Err(e) = allow_method(&self.policy, "byte-stream", "close") { + return capnp::capability::Promise::err(e); + } + let inner = self.inner.clone(); + capnp::capability::Promise::from_future(async move { + inner.close_request().send().promise.await?; + Ok(()) + }) + } +} + +fn parse_policy_cap_name(v: &Val) -> Result { + match v { + Val::Str(s) | Val::Sym(s) | Val::Keyword(s) => Ok(s.clone()), + other => Err(capnp::Error::failed(format!( + "export policy: expected cap name string/symbol/keyword, got {other}" + ))), + } +} + +fn convert_att_policy(policy: &AttenuationPolicy) -> ExportCapPolicy { + let mut returns = BTreeMap::new(); + for (method, fields) in &policy.returns { + let mut mapped_fields = BTreeMap::new(); + for (field, nested) in fields { + mapped_fields.insert(field.clone(), convert_att_policy(nested)); + } + returns.insert(method.clone(), mapped_fields); + } + ExportCapPolicy { + allow_methods: Some(policy.allow_methods.clone()), + returns, + } +} + +fn parse_export_cap_value(cap_name: &str, value: &Val) -> Result { + let Val::Cap { name, inner, .. } = value else { + return Err(capnp::Error::failed(format!( + "init.glia export '{cap_name}' must map to a capability value, got {value}" + ))); + }; + + if let Some(att) = inner.downcast_ref::() { + let base_name = match &att.base { + Val::Cap { name, .. } => name.as_str(), + Val::Keyword(k) if k == "self" => { + return Err(capnp::Error::failed(format!( + "init.glia export '{cap_name}' cannot use :self as top-level base" + ))) + } + other => { + return Err(capnp::Error::failed(format!( + "init.glia export '{cap_name}' attenuation base must be a cap, got {other}" + ))) + } + }; + if base_name != cap_name { + return Err(capnp::Error::failed(format!( + "init.glia export key '{cap_name}' must attenuate the '{cap_name}' cap, got base '{base_name}'" + ))); + } + return Ok(convert_att_policy(&att.policy)); + } + + if name != cap_name { + return Err(capnp::Error::failed(format!( + "init.glia export key '{cap_name}' must map to cap '{cap_name}', got '{name}'" + ))); + } + + Ok(ExportCapPolicy::default()) +} + +fn is_supported_method(kind: MethodFilterCap, method: &str) -> bool { + match kind { + MethodFilterCap::Host => matches!(method, "id" | "addrs" | "peers" | "network"), + MethodFilterCap::Runtime => matches!(method, "load" | "shutdown"), + MethodFilterCap::Routing => matches!( + method, + "provide" + | "findProviders" + | "hash" + | "resolve" + | "mkdir" + | "writeFile" + | "remove" + | "publish" + ), + MethodFilterCap::Identity => matches!(method, "signer" | "verify"), + MethodFilterCap::Ipfs => method == "read", + MethodFilterCap::HttpClient => matches!(method, "get" | "post"), + MethodFilterCap::StreamListener => method == "listen", + MethodFilterCap::StreamDialer => method == "dial", + MethodFilterCap::VatListener => method == "listen", + MethodFilterCap::VatClient => method == "dial", + MethodFilterCap::HttpListener => method == "listen", + MethodFilterCap::Executor => method == "spawn", + MethodFilterCap::Process => { + matches!( + method, + "stdin" | "stdout" | "stderr" | "wait" | "bootstrap" | "kill" + ) + } + MethodFilterCap::Signer => method == "sign", + MethodFilterCap::ByteStream => matches!(method, "read" | "write" | "close"), + MethodFilterCap::DynamicAny => true, + } +} + +fn return_field_cap_kind( + kind: MethodFilterCap, + method: &str, + field: &str, +) -> Option { + match (kind, method, field) { + (MethodFilterCap::Host, "network", "streamListener") => { + Some(MethodFilterCap::StreamListener) + } + (MethodFilterCap::Host, "network", "streamDialer") => Some(MethodFilterCap::StreamDialer), + (MethodFilterCap::Host, "network", "vatListener") => Some(MethodFilterCap::VatListener), + (MethodFilterCap::Host, "network", "vatClient") => Some(MethodFilterCap::VatClient), + (MethodFilterCap::Host, "network", "httpListener") => Some(MethodFilterCap::HttpListener), + (MethodFilterCap::Runtime, "load", "executor") => Some(MethodFilterCap::Executor), + (MethodFilterCap::Identity, "signer", "signer") => Some(MethodFilterCap::Signer), + (MethodFilterCap::Ipfs, "read", "stream") => Some(MethodFilterCap::ByteStream), + (MethodFilterCap::StreamDialer, "dial", "stream") => Some(MethodFilterCap::ByteStream), + (MethodFilterCap::Executor, "spawn", "process") => Some(MethodFilterCap::Process), + (MethodFilterCap::Process, "stdin", "stream") => Some(MethodFilterCap::ByteStream), + (MethodFilterCap::Process, "stdout", "stream") => Some(MethodFilterCap::ByteStream), + (MethodFilterCap::Process, "stderr", "stream") => Some(MethodFilterCap::ByteStream), + (MethodFilterCap::Process, "bootstrap", "cap") => Some(MethodFilterCap::DynamicAny), + (MethodFilterCap::VatClient, "dial", "cap") => Some(MethodFilterCap::DynamicAny), + _ => None, + } +} + +fn method_supports_cap_returns(kind: MethodFilterCap, method: &str) -> bool { + matches!( + (kind, method), + (MethodFilterCap::Host, "network") + | (MethodFilterCap::Runtime, "load") + | (MethodFilterCap::Identity, "signer") + | (MethodFilterCap::Ipfs, "read") + | (MethodFilterCap::StreamDialer, "dial") + | (MethodFilterCap::Executor, "spawn") + | (MethodFilterCap::Process, "stdin" | "stdout" | "stderr" | "bootstrap") + | (MethodFilterCap::VatClient, "dial") + ) +} + +fn validate_cap_policy( + cap_name: &str, + kind: MethodFilterCap, + policy: &ExportCapPolicy, +) -> Result<(), capnp::Error> { + if matches!(kind, MethodFilterCap::DynamicAny) { + return Ok(()); + } + + if let Some(allow) = &policy.allow_methods { + for method in allow { + if !is_supported_method(kind, method) { + return Err(capnp::Error::failed(format!( + "init.glia export '{cap_name}' references unknown method '{method}'" + ))); + } + } + } + + for (method, fields) in &policy.returns { + if !is_supported_method(kind, method) { + return Err(capnp::Error::failed(format!( + "init.glia export '{cap_name}' :returns references unknown method '{method}'" + ))); + } + if let Some(allow) = &policy.allow_methods { + if !allow.contains(method) { + return Err(capnp::Error::failed(format!( + "init.glia export '{cap_name}' :returns method '{method}' must also be allowed by :allow" + ))); + } + } + if !method_supports_cap_returns(kind, method) { + return Err(capnp::Error::failed(format!( + "init.glia export '{cap_name}' method '{method}' does not return capability fields" + ))); + } + for (field, nested) in fields { + let Some(child_kind) = return_field_cap_kind(kind, method, field) else { + return Err(capnp::Error::failed(format!( + "init.glia export '{cap_name}' method '{method}' references unknown return field '{field}'" + ))); + }; + validate_cap_policy(&format!("{cap_name}.{method}.{field}"), child_kind, nested)?; + } + } + Ok(()) +} + +fn parse_export_policy(v: &Val) -> Result { + let root = match v { + Val::Map(m) => m, + other => { + return Err(capnp::Error::failed(format!( + "init.glia must return a map, got {other}" + ))) + } + }; + + if root.get(&Val::Keyword("export".into())).is_some() { + return Err(capnp::Error::failed( + "init.glia legacy {:export {:caps ... :methods ...}} policy is no longer supported; return a bare export map {:host host-cap ...}".into(), + )); + } + + let mut caps = BTreeMap::new(); + for (k, v) in root.iter() { + let cap_name = parse_policy_cap_name(k)?; + let Some(kind) = method_filter_cap(&cap_name) else { + return Err(capnp::Error::failed(format!( + "init.glia export references unknown cap '{cap_name}'" + ))); + }; + let cap_policy = parse_export_cap_value(&cap_name, v)?; + validate_cap_policy(&cap_name, kind, &cap_policy)?; + if caps.insert(cap_name.clone(), cap_policy).is_some() { + return Err(capnp::Error::failed(format!( + "init.glia export contains duplicate cap key '{cap_name}'" + ))); + } + } + + Ok(ExportPolicy { caps }) +} + +fn maybe_wrap_export_cap( + kind: MethodFilterCap, + base: capnp::capability::Client, + policy: &ExportCapPolicy, +) -> Result { + if policy.allow_methods.is_none() && policy.returns.is_empty() { + return Ok(base); + } + + match kind { + MethodFilterCap::Host => { + let typed = capnp::capability::FromClientHook::new(base.hook.clone()); + let wrapped: system_capnp::host::Client = capnp_rpc::new_client(MethodFilteredHost { + inner: typed, + policy: policy.clone(), + }); + Ok(wrapped.client) + } + MethodFilterCap::Runtime => { + let typed = capnp::capability::FromClientHook::new(base.hook.clone()); + let wrapped: system_capnp::runtime::Client = + capnp_rpc::new_client(MethodFilteredRuntime { + inner: typed, + policy: policy.clone(), + }); + Ok(wrapped.client) + } + MethodFilterCap::Routing => { + let typed = capnp::capability::FromClientHook::new(base.hook.clone()); + let wrapped: routing_capnp::routing::Client = + capnp_rpc::new_client(MethodFilteredRouting { + inner: typed, + policy: policy.clone(), + }); + Ok(wrapped.client) + } + MethodFilterCap::Identity => { + let typed = capnp::capability::FromClientHook::new(base.hook.clone()); + let wrapped: auth_capnp::identity::Client = + capnp_rpc::new_client(MethodFilteredIdentity { + inner: typed, + policy: policy.clone(), + }); + Ok(wrapped.client) + } + MethodFilterCap::Ipfs => { + let typed = capnp::capability::FromClientHook::new(base.hook.clone()); + let wrapped: system_capnp::ipfs::Client = capnp_rpc::new_client(MethodFilteredIpfs { + inner: typed, + policy: policy.clone(), + }); + Ok(wrapped.client) + } + MethodFilterCap::HttpClient => { + let typed = capnp::capability::FromClientHook::new(base.hook.clone()); + let wrapped: http_capnp::http_client::Client = + capnp_rpc::new_client(MethodFilteredHttpClient { + inner: typed, + policy: policy.clone(), + }); + Ok(wrapped.client) + } + MethodFilterCap::StreamListener => { + let typed = capnp::capability::FromClientHook::new(base.hook.clone()); + let wrapped: system_capnp::stream_listener::Client = + capnp_rpc::new_client(MethodFilteredStreamListener { + inner: typed, + policy: policy.clone(), + }); + Ok(wrapped.client) + } + MethodFilterCap::StreamDialer => { + let typed = capnp::capability::FromClientHook::new(base.hook.clone()); + let wrapped: system_capnp::stream_dialer::Client = + capnp_rpc::new_client(MethodFilteredStreamDialer { + inner: typed, + policy: policy.clone(), + }); + Ok(wrapped.client) + } + MethodFilterCap::VatListener => { + let typed = capnp::capability::FromClientHook::new(base.hook.clone()); + let wrapped: system_capnp::vat_listener::Client = + capnp_rpc::new_client(MethodFilteredVatListener { + inner: typed, + policy: policy.clone(), + }); + Ok(wrapped.client) + } + MethodFilterCap::VatClient => { + let typed = capnp::capability::FromClientHook::new(base.hook.clone()); + let wrapped: system_capnp::vat_client::Client = + capnp_rpc::new_client(MethodFilteredVatClient { + inner: typed, + policy: policy.clone(), + }); + Ok(wrapped.client) + } + MethodFilterCap::HttpListener => { + let typed = capnp::capability::FromClientHook::new(base.hook.clone()); + let wrapped: system_capnp::http_listener::Client = + capnp_rpc::new_client(MethodFilteredHttpListener { + inner: typed, + policy: policy.clone(), + }); + Ok(wrapped.client) + } + MethodFilterCap::Executor => { + let typed = capnp::capability::FromClientHook::new(base.hook.clone()); + let wrapped: system_capnp::executor::Client = + capnp_rpc::new_client(MethodFilteredExecutor { + inner: typed, + policy: policy.clone(), + }); + Ok(wrapped.client) + } + MethodFilterCap::Process => { + let typed = capnp::capability::FromClientHook::new(base.hook.clone()); + let wrapped: system_capnp::process::Client = + capnp_rpc::new_client(MethodFilteredProcess { + inner: typed, + policy: policy.clone(), + }); + Ok(wrapped.client) + } + MethodFilterCap::Signer => { + let typed = capnp::capability::FromClientHook::new(base.hook.clone()); + let wrapped: auth_capnp::signer::Client = capnp_rpc::new_client(MethodFilteredSigner { + inner: typed, + policy: policy.clone(), + }); + Ok(wrapped.client) + } + MethodFilterCap::ByteStream => { + let typed = capnp::capability::FromClientHook::new(base.hook.clone()); + let wrapped: system_capnp::byte_stream::Client = + capnp_rpc::new_client(MethodFilteredByteStream { + inner: typed, + policy: policy.clone(), + }); + Ok(wrapped.client) + } + MethodFilterCap::DynamicAny => Err(capnp::Error::failed( + "internal: dynamic AnyPointer wrapper requires schema bytes".into(), + )), + } +} + +fn maybe_wrap_returned_cap( + kind: MethodFilterCap, + method: &str, + field: &str, + base: capnp::capability::Client, + policy: &ExportCapPolicy, + schema_bytes: Option<&[u8]>, +) -> Result { + let Some(child_policy) = return_policy(policy, method, field) else { + return Ok(base); + }; + if matches!(kind, MethodFilterCap::DynamicAny) { + let schema_bytes = schema_bytes.ok_or_else(|| { + capnp::Error::failed(format!( + "internal: missing schema bytes for dynamic return policy on {method}.{field}" + )) + })?; + if let Some(known_kind) = known_cap_kind_for_schema(schema_bytes) { + return maybe_wrap_export_cap(known_kind, base, child_policy); + } + let dyn_policy = build_dynamic_method_policy( + "dynamic-cap", + method, + field, + child_policy, + schema_bytes, + )?; + let wrapped: UntypedDynamicClient = capnp_rpc::new_client(MethodFilteredDynamicCap { + inner: base, + policy: dyn_policy, + path: format!("{method}.{field}"), + }); + return Ok(wrapped.0); + } + maybe_wrap_export_cap(kind, base, child_policy) } #[allow(refining_impl_trait)] @@ -73,32 +2486,84 @@ impl membrane_capnp::membrane::Server for KernelBootstrap { let membrane = match self.membrane.borrow().clone() { Some(m) => m, None => { - return capnp::capability::Promise::err(capnp::Error::failed( - "kernel bootstrap membrane not ready".into(), - )) + return capnp::capability::Promise::err(capnp::Error::failed(format!( + "{INIT_MEMBRANE_NOT_READY}: kernel bootstrap membrane not ready" + ))) + } + }; + let policy = match self.policy.borrow().clone() { + Some(p) => p, + None => { + return capnp::capability::Promise::err(capnp::Error::failed(format!( + "{INIT_POLICY_NOT_READY}: kernel export policy not ready" + ))) } }; capnp::capability::Promise::from_future(async move { let resp = membrane.graft_request().send().promise.await?; let src_caps = resp.get()?.get_caps()?; - let mut dst_caps = results.get().init_caps(src_caps.len()); + let mut export_count = 0u32; + let mut available_caps = BTreeSet::new(); + + for i in 0..src_caps.len() { + let src = src_caps.get(i); + let cap_name = src + .get_name()? + .to_str() + .map_err(|e| capnp::Error::failed(e.to_string()))? + .to_string(); + available_caps.insert(cap_name.clone()); + if !policy.caps.contains_key(&cap_name) { + continue; + } + export_count += 1; + } + + let unknown_caps: Vec = policy + .caps + .keys() + .filter(|name| !available_caps.contains(*name)) + .cloned() + .collect(); + if !unknown_caps.is_empty() { + return Err(capnp::Error::failed(format!( + "export policy references unknown cap(s): {}", + unknown_caps.join(", ") + ))); + } + let mut dst_caps = results.get().init_caps(export_count); + let mut dst_i = 0u32; for i in 0..src_caps.len() { let src = src_caps.get(i); - let mut dst = dst_caps.reborrow().get(i); - dst.set_name(src.get_name()?); + let cap_name = src + .get_name()? + .to_str() + .map_err(|e| capnp::Error::failed(e.to_string()))? + .to_string(); + let Some(cap_policy) = policy.caps.get(&cap_name) else { + continue; + }; + let base = src + .get_cap() + .get_as_capability::()?; + let kind = method_filter_cap(&cap_name).ok_or_else(|| { + capnp::Error::failed(format!( + "export policy method filter unsupported at runtime for cap '{cap_name}'" + )) + })?; + let client = maybe_wrap_export_cap(kind, base, cap_policy)?; + + let mut dst = dst_caps.reborrow().get(dst_i); + dst.set_name(&cap_name); dst.reborrow() .init_cap() - .set_as_capability( - src.get_cap() - .get_as_capability::()? - .hook - .clone(), - ); + .set_as_capability(client.hook.clone()); if src.has_schema() { dst.set_schema(src.get_schema()?)?; } + dst_i += 1; } Ok(()) @@ -164,7 +2629,6 @@ struct Session { cwd: String, } - // --------------------------------------------------------------------------- // Cap extraction — get type-erased capnp Client from Val::Cap.inner // --------------------------------------------------------------------------- @@ -207,6 +2671,15 @@ type HandlerFn = for<'a> fn( fn build_dispatch() -> HashMap<&'static str, HandlerFn> { let mut t: HashMap<&'static str, HandlerFn> = HashMap::new(); t.insert("load", |a, _| Box::pin(std::future::ready(eval_load(a)))); + t.insert("list-dir", |a, _| { + Box::pin(std::future::ready(eval_list_dir(a))) + }); + t.insert("path-is-dir", |a, _| { + Box::pin(std::future::ready(eval_path_is_dir(a))) + }); + t.insert("sort-strings", |a, _| { + Box::pin(std::future::ready(eval_sort_strings(a))) + }); t.insert("cd", |a, c| Box::pin(std::future::ready(eval_cd(a, c)))); t.insert("help", |_, _| { Box::pin(std::future::ready(Ok(Val::Str(HELP_TEXT.to_string())))) @@ -271,6 +2744,140 @@ fn eval_cd(args: &[Val], ctx: &RefCell) -> Result { Ok(Val::Nil) } +fn resolve_kernel_builtin_path(path: &str) -> Result { + fn enforce_under_root( + root: &std::path::Path, + resolved: &std::path::Path, + original: &str, + ) -> Result<(), String> { + let canonical_root = std::fs::canonicalize(root) + .map_err(|e| format!("WW_ROOT '{}' is not accessible: {e}", root.display()))?; + // Require the nearest existing ancestor to stay within WW_ROOT so + // symlink traversal cannot escape the sandbox. + let mut probe = resolved; + while !probe.exists() { + probe = probe.parent().ok_or_else(|| { + format!("failed to resolve parent while checking path '{original}'") + })?; + } + let canonical_probe = std::fs::canonicalize(probe) + .map_err(|e| format!("failed to canonicalize '{original}': {e}"))?; + if !canonical_probe.starts_with(&canonical_root) { + return Err(format!( + "path escapes WW_ROOT via symlink traversal: {original}" + )); + } + Ok(()) + } + + let mut rel = std::path::PathBuf::new(); + for component in std::path::Path::new(path).components() { + match component { + std::path::Component::RootDir | std::path::Component::CurDir => {} + std::path::Component::Normal(part) => rel.push(part), + std::path::Component::ParentDir => { + return Err(format!("path escapes root via '..': {path}")); + } + std::path::Component::Prefix(_) => { + return Err(format!("path prefixes are not supported: {path}")); + } + } + } + + if let Ok(root) = std::env::var("WW_ROOT") { + let root_path = std::path::Path::new(&root); + let resolved = if rel.as_os_str().is_empty() { + root_path.to_path_buf() + } else { + root_path.join(rel) + }; + enforce_under_root(root_path, &resolved, path)?; + return Ok(resolved.to_string_lossy().to_string()); + } + if rel.as_os_str().is_empty() { + Ok("/".to_string()) + } else { + Ok(format!("/{}", rel.to_string_lossy())) + } +} + +fn eval_list_dir(args: &[Val]) -> Result { + let path = match args.first() { + Some(Val::Str(s)) => s.clone(), + _ => return Err("(list-dir \"\")".into()), + }; + let resolved = resolve_kernel_builtin_path(&path) + .map_err(|e| Val::from(format!("list-dir: {path}: {e}")))?; + let canonical_resolved = std::fs::canonicalize(&resolved) + .map_err(|e| Val::from(format!("list-dir: {resolved}: {e}")))?; + let canonical_resolved_str = canonical_resolved.to_string_lossy().to_string(); + let entries = std::fs::read_dir(&canonical_resolved) + .map_err(|e| Val::from(format!("list-dir: {canonical_resolved_str}: {e}")))?; + + let mut out = Vec::new(); + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(e) => { + log::warn!("list-dir: skipping unreadable entry in {canonical_resolved_str}: {e}"); + continue; + } + }; + let metadata = match std::fs::metadata(entry.path()) { + Ok(metadata) => metadata, + Err(e) => { + log::warn!( + "list-dir: skipping entry in {canonical_resolved_str} (metadata error, possibly broken symlink): {e}" + ); + continue; + } + }; + if metadata.is_file() { + if let Some(name) = entry.file_name().to_str() { + out.push(Val::Str(name.to_string())); + } else { + log::warn!( + "list-dir: skipping non-utf8 filename in {canonical_resolved_str}" + ); + } + } else { + log::debug!("list-dir: skipping non-file entry in {canonical_resolved_str}"); + } + } + Ok(Val::List(out)) +} + +fn eval_path_is_dir(args: &[Val]) -> Result { + let path = match args.first() { + Some(Val::Str(s)) => s.clone(), + _ => return Err("(path-is-dir \"\")".into()), + }; + let resolved = resolve_kernel_builtin_path(&path) + .map_err(|e| Val::from(format!("path-is-dir: {path}: {e}")))?; + match std::fs::canonicalize(&resolved) { + Ok(canonical) => Ok(Val::Bool(canonical.is_dir())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Val::Bool(false)), + Err(e) => Err(Val::from(format!("path-is-dir: {resolved}: {e}"))), + } +} + +fn eval_sort_strings(args: &[Val]) -> Result { + let items: &[Val] = match args.first() { + Some(Val::List(v)) | Some(Val::Vector(v)) => v.as_slice(), + _ => return Err("(sort-strings )".into()), + }; + + let mut strings: Vec = Vec::with_capacity(items.len()); + for item in items { + match item { + Val::Str(s) => strings.push(s.clone()), + _ => return Err(Val::from("sort-strings: all elements must be strings")), + } + } + strings.sort(); + Ok(Val::List(strings.into_iter().map(Val::Str).collect())) +} + // --------------------------------------------------------------------------- // Kernel dispatch — bridges glia's evaluator to kernel capabilities // --------------------------------------------------------------------------- @@ -450,14 +3057,21 @@ fn make_host_handler( let listener = network .get_vat_listener() .map_err(|e| Val::from(e.to_string()))?; + let schema = schema.as_ref().ok_or_else(|| { + Val::from( + "host :listen requires cell schema bytes for vat descriptor identity", + ) + })?; + let wasi_cid = schema_id::compute_cid(wasm); + let schema_cid = schema_id::compute_cid(schema); let mut req = listener.listen_request(); { let mut handler = req.get().init_handler(); handler.set_spawn(executor); } - if let Some(s) = schema { - req.get().set_schema(s); - } + let mut descriptor = req.get().init_descriptor(); + descriptor.set_wasi_cid(wasi_cid.as_bytes()); + descriptor.set_schema_cid(schema_cid.as_bytes()); // Forward captured caps from the `with` block. // Pre-filter to avoid ghost entries in the capnp list. if !caps.is_empty() { @@ -585,48 +3199,11 @@ fn make_host_handler( match rest.len() { 2 => { // VatListener mode: (perform host :listen runtime ) - // Load wasm via Runtime → Executor, then call - // VatListener.listen() with the Executor. - let wasm = match rest.get(1) { - Some(Val::Bytes(b)) => b.clone(), - _ => { - return Err(Val::from( - "host :listen — expected wasm bytes as 2nd arg", - )) - } - }; - - // Load the wasm to get an Executor (pipelining). - let mut load_req = runtime.load_request(); - load_req.get().set_wasm(&wasm); - let executor = load_req - .send() - .pipeline - .get_executor(); - - let network_resp = host - .network_request() - .send() - .promise - .await - .map_err(|e| Val::from(e.to_string()))?; - let network = network_resp - .get() - .map_err(|e| Val::from(e.to_string()))?; - let listener = network - .get_vat_listener() - .map_err(|e| Val::from(e.to_string()))?; - let mut req = listener.listen_request(); - { - let mut handler = req.get().init_handler(); - handler.set_spawn(executor); - } - req.send() - .promise - .await - .map_err(|e| Val::from(e.to_string()))?; - log::info!("host :listen — registered vat handler"); - Val::Nil + // This legacy path cannot provide schema bytes for + // descriptor.schemaCid; require explicit cell input. + return Err(Val::from( + "host :listen runtime is deprecated for vat mode; use (perform host :listen (cell ))", + )); } 3 => { // StreamListener mode: (perform host :listen runtime "proto" ) @@ -683,7 +3260,7 @@ fn make_host_handler( } _ => { return Err(Val::from( - "host :listen — usage: (perform host :listen runtime ) or (perform host :listen runtime \"proto\" )", + "host :listen — usage: (perform host :listen ) or (perform host :listen \"/path\") or (perform host :listen runtime \"proto\" )", )) } } @@ -697,9 +3274,11 @@ fn make_host_handler( schema_ids::HTTP_CLIENT_CID.to_string(), Rc::new(c.clone()), ), - None => return Err(Val::from( - "http-client not available (node started without --http-dial)", - )), + None => { + return Err(Val::from( + "http-client not available (node started without --http-dial)", + )) + } } } _ => return Err(Val::from(format!("host: unknown method :{method}"))), @@ -1099,7 +3678,7 @@ Capabilities (via perform): (perform host :id) Peer ID (perform host :addrs) Listen addresses (perform host :peers) Connected peers - (perform host :listen runtime ) Register RPC handler + (perform host :listen (cell )) Register RPC handler (perform host :listen runtime \"p\" ) Register stream handler (perform runtime :run :env {}) Spawn foreground process @@ -1111,42 +3690,21 @@ Capabilities (via perform): Effects: (perform :load \"\") Load bytes from virtual filesystem + (perform :list-dir \"\") List directory entries + (perform :path-is-dir \"\") True if path exists and is a directory + (perform :sort-strings ) Sort strings lexicographically Built-ins: (load \"\") Load bytes (dispatch form) + (list-dir \"\") List directory entries + (path-is-dir \"\") True if path exists and is a directory + (sort-strings ) Sort strings lexicographically (cd \"\") Change working directory (help) This message (exit) Quit Unrecognized commands are looked up in PATH (default /bin)."; -// --------------------------------------------------------------------------- -// Init.d — evaluate scripts from $WW_ROOT/etc/init.d/*.glia -// --------------------------------------------------------------------------- - -/// Parse an init.d script from raw bytes. Returns `None` on error (logs details). -/// Extracted from `run_initd` for testability — the caller uses `None` to skip -/// the failed script and continue (SysV best-effort model). -fn parse_initd_script(name: &str, data: &[u8]) -> Option> { - let content = match std::str::from_utf8(data) { - Ok(s) => s, - Err(e) => { - log::error!("init.d: {name}: not valid UTF-8: {e}"); - return None; - } - }; - match read_many(content) { - Ok(forms) => { - log::info!("init.d: parsed {name} ({} form(s))", forms.len()); - Some(forms) - } - Err(e) => { - log::error!("init.d: {name}: parse error: {e}"); - None - } - } -} - /// Wrap a form in cap handlers + keyword effect handlers. /// /// Produces: @@ -1176,9 +3734,57 @@ fn wrap_with_handlers(form: &Val) -> Val { form.clone(), ]); + let with_list_dir = Val::List(vec![ + Val::Sym("with-effect-handler".into()), + Val::Keyword("list-dir".into()), + Val::List(vec![ + Val::Sym("fn".into()), + Val::Vector(vec![Val::Sym("path".into()), Val::Sym("resume".into())]), + Val::List(vec![ + Val::Sym("resume".into()), + Val::List(vec![Val::Sym("list-dir".into()), Val::Sym("path".into())]), + ]), + ]), + with_load, + ]); + + let with_path_is_dir = Val::List(vec![ + Val::Sym("with-effect-handler".into()), + Val::Keyword("path-is-dir".into()), + Val::List(vec![ + Val::Sym("fn".into()), + Val::Vector(vec![Val::Sym("path".into()), Val::Sym("resume".into())]), + Val::List(vec![ + Val::Sym("resume".into()), + Val::List(vec![ + Val::Sym("path-is-dir".into()), + Val::Sym("path".into()), + ]), + ]), + ]), + with_list_dir, + ]); + + let with_sort_strings = Val::List(vec![ + Val::Sym("with-effect-handler".into()), + Val::Keyword("sort-strings".into()), + Val::List(vec![ + Val::Sym("fn".into()), + Val::Vector(vec![Val::Sym("items".into()), Val::Sym("resume".into())]), + Val::List(vec![ + Val::Sym("resume".into()), + Val::List(vec![ + Val::Sym("sort-strings".into()), + Val::Sym("items".into()), + ]), + ]), + ]), + with_path_is_dir, + ]); + // Wrap in cap handlers (innermost to outermost). let caps = ["import", "routing", "runtime", "host"]; - let mut wrapped = with_load; + let mut wrapped = with_sort_strings; for cap_name in &caps { let handler_name = format!("{cap_name}-handler"); wrapped = Val::List(vec![ @@ -1191,109 +3797,60 @@ fn wrap_with_handlers(form: &Val) -> Val { wrapped } -/// Scan `$WW_ROOT/etc/init.d/*.glia` via the WASI virtual filesystem, -/// parse and evaluate each file as a glia script. Returns true if any -/// expression blocked -/// (i.e. a foreground process ran to completion via `(runtime run ...)`). -async fn run_initd( +fn resolve_boot_file_path(rel_path: &str) -> Option { + if let Ok(ww_root) = std::env::var("WW_ROOT") { + let rooted = std::path::Path::new(&ww_root).join(rel_path); + // Fail-closed with WW_ROOT: do not silently fall back to host /etc + // when the rooted file is missing. + return rooted + .exists() + .then_some(rooted.to_string_lossy().to_string()); + } + + let host = format!("/{rel_path}"); + std::path::Path::new(&host).exists().then_some(host) +} + +async fn run_init_glia( env: &mut Env, ctx: &RefCell, dispatch: &HashMap<&'static str, HandlerFn>, -) -> Result> { - let ww_root = std::env::var("WW_ROOT").unwrap_or_default(); - if ww_root.is_empty() { - log::debug!("init.d: WW_ROOT not set, skipping"); - return Ok(false); - } - let root = ww_root.trim_end_matches('/'); - - // Read init.d scripts via WASI virtual filesystem. - // Try $WW_ROOT/etc/init.d first (IPFS CidTree path), then fall back to - // /etc/init.d (direct WASI preopen for local images). - let initd_paths = [format!("{root}/etc/init.d"), "/etc/init.d".to_string()]; - let (initd_path, entries) = { - let mut found = None; - for path in &initd_paths { - if let Ok(dir) = std::fs::read_dir(path) { - let mut names: Vec = dir - .filter_map(|entry| { - let entry = entry.ok()?; - let name = entry.file_name().to_str()?.to_string(); - if name.ends_with(".glia") { - Some(name) - } else { - None - } - }) - .collect(); - names.sort(); - found = Some((path.clone(), names)); - break; - } - } - match found { - Some(f) => f, - None => { - log::warn!( - "init.d: not found (tried {} paths), skipping", - initd_paths.len() - ); - return Ok(false); +) -> Result { + let init_path = + resolve_boot_file_path("etc/init.glia").ok_or_else(|| match std::env::var("WW_ROOT") { + Ok(root) => { + let root = std::path::Path::new(&root); + capnp::Error::failed(format!( + "boot failed: missing required {}/etc/init.glia", + root.display() + )) } - } - }; - - if entries.is_empty() { - log::info!("init.d: no scripts found"); - return Ok(false); + Err(_) => capnp::Error::failed("boot failed: missing required /etc/init.glia".into()), + })?; + log::info!("boot: evaluating init script at {init_path}"); + + let data = std::fs::read(&init_path) + .map_err(|e| capnp::Error::failed(format!("boot failed: read {init_path}: {e}")))?; + let content = std::str::from_utf8(&data).map_err(|e| { + capnp::Error::failed(format!("boot failed: init.glia is not valid UTF-8: {e}")) + })?; + let forms = read_many(content) + .map_err(|e| capnp::Error::failed(format!("boot failed: init.glia parse error: {e}")))?; + if forms.is_empty() { + return Err(capnp::Error::failed( + "boot failed: init.glia must return an export policy map".into(), + )); } - log::info!("init.d: found {} script(s)", entries.len()); - let mut blocked = false; - - // SysV init: execute each script in lexicographic order, best-effort. - // On failure: log with full context, continue to next script. - for name in &entries { - let script_path = format!("{initd_path}/{name}"); - - // Read the glia script via WASI FS — failure skips this script. - let data = match std::fs::read(&script_path) { - Ok(d) => d, - Err(e) => { - log::error!("init.d: {name}: read failed: {e}"); - continue; - } - }; - - let forms = match parse_initd_script(name, &data) { - Some(f) => f, - None => continue, // SysV: skip failed script - }; - - for (i, form) in forms.iter().enumerate() { - log::info!("init.d: {name}: evaluating form {}/{}", i + 1, forms.len()); - // Wrap each form in default effect handlers so init.d - // scripts can use (perform :load ...) etc. - let wrapped = wrap_with_handlers(form); - match eval(&wrapped, env, ctx, dispatch).await { - Ok(Val::Nil) => {} - Ok(Val::Int(code)) => { - // A (runtime run ...) that returned an exit code means - // a foreground process ran to completion. - log::info!("init.d: {name}: foreground process exited ({code})"); - blocked = true; - } - Ok(result) => { - log::debug!("init.d: {name}: {result}"); - } - Err(e) => { - log::error!("init.d: {name}: form {}: {e}", i + 1); - } - } - } + let mut result = Val::Nil; + for (i, form) in forms.iter().enumerate() { + let wrapped = wrap_with_handlers(form); + result = eval(&wrapped, env, ctx, dispatch).await.map_err(|e| { + capnp::Error::failed(format!("boot failed: init.glia form {}: {e}", i + 1)) + })?; } - Ok(blocked) + parse_export_policy(&result) } // --------------------------------------------------------------------------- @@ -1489,6 +4046,33 @@ fn make_schema_builtin() -> Val { } fn make_doc_builtin() -> Val { + fn count_recursive_return_edges(policy: &AttenuationPolicy) -> usize { + policy + .returns + .values() + .map(|fields| { + fields.len() + + fields + .values() + .map(count_recursive_return_edges) + .sum::() + }) + .sum() + } + + fn max_recursive_return_depth(policy: &AttenuationPolicy) -> usize { + let Some(max_child) = policy + .returns + .values() + .flat_map(|fields| fields.values()) + .map(max_recursive_return_depth) + .max() + else { + return 0; + }; + 1 + max_child + } + Val::NativeFn { name: "doc".into(), func: Rc::new(|args: &[Val]| -> Result { @@ -1501,7 +4085,9 @@ fn make_doc_builtin() -> Val { "runtime" => "runtime — cell spawn + execution", "routing" => "routing — DHT content routing (provide / find)", "identity" => "identity — node Ed25519 signing keys", - "http" | "http-client" => "http-client — outbound HTTP requests (gated by --http-dial)", + "http" | "http-client" => { + "http-client — outbound HTTP requests (gated by --http-dial)" + } _ => { if let Some(glia_cap) = inner.downcast_ref::() { return Ok(Val::Str(format!( @@ -1510,9 +4096,11 @@ fn make_doc_builtin() -> Val { ))); } if let Some(att) = inner.downcast_ref::() { + let return_edges = count_recursive_return_edges(&att.policy); + let return_depth = max_recursive_return_depth(&att.policy); return Ok(Val::Str(format!( - "attenuated capability — method whitelist\n cap-name: {cap_name}\n schema-cid: {schema_cid}\n methods: {}", - att.allow_methods.len() + "attenuated capability — method whitelist\n cap-name: {cap_name}\n schema-cid: {schema_cid}\n methods: {}\n return-edges: {return_edges}\n return-depth: {return_depth}", + att.policy.allow_methods.len(), ))); } return Err(glia::error::permission_denied( @@ -1584,139 +4172,143 @@ fn run_impl() { init_logging(); let exported_membrane: Rc>> = Rc::new(RefCell::new(None)); + let exported_policy: Rc>> = Rc::new(RefCell::new(None)); let bootstrap: membrane_capnp::membrane::Client = capnp_rpc::new_client(KernelBootstrap { membrane: Rc::clone(&exported_membrane), + policy: Rc::clone(&exported_policy), }); system::serve(bootstrap.client, move |membrane: Membrane| { let exported_membrane = Rc::clone(&exported_membrane); + let exported_policy = Rc::clone(&exported_policy); async move { - *exported_membrane.borrow_mut() = Some(membrane.clone()); - let graft_resp = membrane.graft_request().send().promise.await?; - let results = graft_resp.get()?; - - // Iterate the caps list to find capabilities by name. - let caps = results.get_caps()?; - - let host: system_capnp::host::Client = get_graft_cap(&caps, "host")?; - let runtime: system_capnp::runtime::Client = get_graft_cap(&caps, "runtime")?; - let routing: routing_capnp::routing::Client = get_graft_cap(&caps, "routing")?; - let identity: auth_capnp::identity::Client = get_graft_cap(&caps, "identity")?; - let http_client: Option = - get_graft_cap(&caps, "http-client").ok(); - - let ctx = RefCell::new(Session { - host: host.clone(), - runtime: runtime.clone(), - routing: routing.clone(), - identity, - http_client: http_client.clone(), - cwd: "/".to_string(), - }); + let graft_resp = membrane.graft_request().send().promise.await?; + let results = graft_resp.get()?; + + // Iterate the caps list to find capabilities by name. + let caps = results.get_caps()?; - let dispatch = build_dispatch(); - let mut env = Env::new(); + let host: system_capnp::host::Client = get_graft_cap(&caps, "host")?; + let runtime: system_capnp::runtime::Client = get_graft_cap(&caps, "runtime")?; + let routing: routing_capnp::routing::Client = get_graft_cap(&caps, "routing")?; + let identity: auth_capnp::identity::Client = get_graft_cap(&caps, "identity")?; + let http_client: Option = + get_graft_cap(&caps, "http-client").ok(); - // Bind graft caps + effect handlers from the membrane response. - // The membrane exports a flat list of named capabilities; we iterate - // it, downcast each to its typed client, and bind both a Val::Cap - // (for collect_caps / :listen forwarding) and an effect handler - // (for `(perform cap :method ...)` in Glia). - { - let s = ctx.borrow(); - for i in 0..caps.len() { - let entry = caps.get(i); - let cap_name = entry - .get_name()? - .to_str() - .map_err(|e| capnp::Error::failed(e.to_string()))?; - - let (schema_cid, inner, handler): (&str, Rc, Val) = - match cap_name { - "host" => ( - schema_ids::HOST_CID, - Rc::new(s.host.clone()), - make_host_handler( - s.host.clone(), - s.runtime.clone(), - s.http_client.clone(), - ), - ), - "runtime" => ( - schema_ids::RUNTIME_CID, - Rc::new(s.runtime.clone()), - make_runtime_handler(s.runtime.clone()), - ), - "routing" => ( - schema_ids::ROUTING_CID, - Rc::new(s.routing.clone()), - make_routing_handler(s.routing.clone()), - ), - "identity" => { - // Identity is stored in the Session but has no - // Glia effect handler — skip env binding. - continue; - } - "http-client" => { - match s.http_client.clone() { - Some(c) => ( - schema_ids::HTTP_CLIENT_CID, - Rc::new(c), - // No standalone handler — http-client is accessed - // via (perform host :http-client). - Val::Nil, + let ctx = RefCell::new(Session { + host: host.clone(), + runtime: runtime.clone(), + routing: routing.clone(), + identity, + http_client: http_client.clone(), + cwd: "/".to_string(), + }); + + let dispatch = build_dispatch(); + let mut env = Env::new(); + + // Bind graft caps + effect handlers from the membrane response. + // The membrane exports a flat list of named capabilities; we iterate + // it, downcast each to its typed client, and bind both a Val::Cap + // (for collect_caps / :listen forwarding) and an effect handler + // (for `(perform cap :method ...)` in Glia). + { + let s = ctx.borrow(); + for i in 0..caps.len() { + let entry = caps.get(i); + let cap_name = entry + .get_name()? + .to_str() + .map_err(|e| capnp::Error::failed(e.to_string()))?; + + let (schema_cid, inner, handler): (&str, Rc, Val) = + match cap_name { + "host" => ( + schema_ids::HOST_CID, + Rc::new(s.host.clone()), + make_host_handler( + s.host.clone(), + s.runtime.clone(), + s.http_client.clone(), ), - None => { - log::warn!("graft: host sent 'http-client' but Session has None, skipping"); - continue; + ), + "runtime" => ( + schema_ids::RUNTIME_CID, + Rc::new(s.runtime.clone()), + make_runtime_handler(s.runtime.clone()), + ), + "routing" => ( + schema_ids::ROUTING_CID, + Rc::new(s.routing.clone()), + make_routing_handler(s.routing.clone()), + ), + "identity" => { + // Identity is stored in the Session but has no + // Glia effect handler — skip env binding. + continue; + } + "http-client" => { + match s.http_client.clone() { + Some(c) => ( + schema_ids::HTTP_CLIENT_CID, + Rc::new(c), + // No standalone handler — http-client is accessed + // via (perform host :http-client). + Val::Nil, + ), + None => { + log::warn!("graft: host sent 'http-client' but Session has None, skipping"); + continue; + } } } - } - other => { - log::warn!("graft: unknown cap '{other}', skipping"); - continue; - } - }; + other => { + log::warn!("graft: unknown cap '{other}', skipping"); + continue; + } + }; - env.set( - cap_name.to_string(), - make_cap(cap_name, schema_cid.to_string(), inner), - ); - if !matches!(handler, Val::Nil) { - env.set(format!("{cap_name}-handler"), handler); + env.set( + cap_name.to_string(), + make_cap(cap_name, schema_cid.to_string(), inner), + ); + if !matches!(handler, Val::Nil) { + env.set(format!("{cap_name}-handler"), handler); + } } + + // Introspection builtins. `(schema cap)` returns the cap's + // canonical Schema.Node bytes; `(doc cap)` returns a human- + // readable summary. Bytes come from the build-time schema + // registry baked into the kernel (see std/kernel/build.rs). + env.set("schema".to_string(), make_schema_builtin()); + env.set("doc".to_string(), make_doc_builtin()); + env.set("help".to_string(), make_help_builtin()); + env.set("import".to_string(), make_import_cap()); + env.set("import-handler".to_string(), make_import_handler()); } - // Introspection builtins. `(schema cap)` returns the cap's - // canonical Schema.Node bytes; `(doc cap)` returns a human- - // readable summary. Bytes come from the build-time schema - // registry baked into the kernel (see std/kernel/build.rs). - env.set("schema".to_string(), make_schema_builtin()); - env.set("doc".to_string(), make_doc_builtin()); - env.set("help".to_string(), make_help_builtin()); - env.set("import".to_string(), make_import_cap()); - env.set("import-handler".to_string(), make_import_handler()); - } + // Load the prelude (standard macros: when, and, or, defn, cond, not). + { + let mut kd = KernelDispatch { + ctx: &ctx, + table: &dispatch, + }; + glia::load_prelude(&mut env, &mut kd).await; + } - // Load the prelude (standard macros: when, and, or, defn, cond, not). - { - let mut kd = KernelDispatch { - ctx: &ctx, - table: &dispatch, + // Boot policy gate: init.glia must succeed and return a valid policy map. + // Until this is set, KernelBootstrap::graft is fail-closed. + let policy = match run_init_glia(&mut env, &ctx, &dispatch).await { + Ok(p) => p, + Err(e) => { + log::error!("{e}"); + std::process::exit(1); + } }; - glia::load_prelude(&mut env, &mut kd).await; - } + *exported_policy.borrow_mut() = Some(policy); + *exported_membrane.borrow_mut() = Some(membrane.clone()); - // Run init.d scripts first. If a foreground process blocked - // (e.g. `(runtime run ...)` in the script), we're done. - let blocked = run_initd(&mut env, &ctx, &dispatch) - .await - .unwrap_or_else(|e| { - log::error!("init.d: {e}"); - false - }); - - if !blocked { let is_tty = std::env::var("WW_TTY").is_ok(); let result = if is_tty { run_shell(&mut env, ctx, &dispatch).await @@ -1727,7 +4319,6 @@ fn run_impl() { if let Err(e) = result { log::error!("kernel error: {e}"); } - } Ok(()) } @@ -1739,72 +4330,7 @@ wasip2::cli::command::export!(Kernel); #[cfg(test)] mod tests { use super::*; - - // --- init.d parse + SysV error recovery --- - - #[test] - fn parse_initd_script_valid() { - let data = b"(cd \"/foo\") (cd \"/bar\")"; - let forms = parse_initd_script("test.glia", data).unwrap(); - assert_eq!(forms.len(), 2); - } - - #[test] - fn parse_initd_script_malformed() { - let data = b"(cd \"/foo\") (broken"; - assert!(parse_initd_script("bad.glia", data).is_none()); - } - - #[test] - fn parse_initd_script_invalid_utf8() { - assert!(parse_initd_script("binary.glia", &[0xFF, 0xFE]).is_none()); - } - - #[test] - fn parse_initd_script_empty() { - let forms = parse_initd_script("empty.glia", b"").unwrap(); - assert!(forms.is_empty()); - } - - #[test] - fn parse_initd_script_comments_only() { - let data = b"; just a comment\n; another one\n"; - let forms = parse_initd_script("comments.glia", data).unwrap(); - assert!(forms.is_empty()); - } - - #[test] - fn sysv_continues_past_failed_scripts() { - // SysV contract: each script is processed independently. - // parse_initd_script returns None on failure, enabling the caller - // to `continue` to the next script. - let scripts: Vec<(&str, &[u8])> = vec![ - ("01-bad.glia", &[0xFF, 0xFE]), // invalid UTF-8 - ("02-broken.glia", b"(unclosed"), // parse error - ("03-good.glia", b"(cd \"/ok\")"), // valid - ("04-also-bad.glia", b"(a) )unexpected"), // parse error - ("05-also-good.glia", b"(help)"), // valid - ]; - - let results: Vec>> = scripts - .iter() - .map(|(name, data)| parse_initd_script(name, data)) - .collect(); - - assert!(results[0].is_none(), "invalid UTF-8 should fail"); - assert!(results[1].is_none(), "unclosed paren should fail"); - assert_eq!( - results[2].as_ref().unwrap().len(), - 1, - "valid script should parse" - ); - assert!(results[3].is_none(), "unexpected close should fail"); - assert_eq!( - results[4].as_ref().unwrap().len(), - 1, - "valid script should parse" - ); - } + static WW_ROOT_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); // --- load --- @@ -1850,6 +4376,153 @@ mod tests { assert_eq!(second.unwrap(), Val::Bytes(b"cached-bytes".to_vec())); } + #[test] + fn eval_path_is_dir_reports_presence() { + let dir = tempfile::tempdir().unwrap(); + let dir_path = dir.path().to_str().unwrap().to_string(); + let missing = format!("{dir_path}/does-not-exist"); + assert_eq!( + eval_path_is_dir(&[Val::Str(dir_path)]).unwrap(), + Val::Bool(true) + ); + assert_eq!( + eval_path_is_dir(&[Val::Str(missing)]).unwrap(), + Val::Bool(false) + ); + } + + #[test] + fn eval_path_is_dir_rejects_parent_traversal_under_ww_root() { + let _guard = WW_ROOT_TEST_LOCK.lock().unwrap(); + let ww_root = tempfile::tempdir().unwrap(); + std::env::set_var("WW_ROOT", ww_root.path()); + let result = eval_path_is_dir(&[Val::Str("/../../etc".into())]); + std::env::remove_var("WW_ROOT"); + + let err = result.expect_err("expected traversal to be rejected"); + let msg = format!("{err}"); + assert!(msg.contains("path escapes root"), "unexpected error: {msg}"); + } + + #[test] + fn eval_list_dir_rejects_parent_traversal_under_ww_root() { + let _guard = WW_ROOT_TEST_LOCK.lock().unwrap(); + let ww_root = tempfile::tempdir().unwrap(); + std::env::set_var("WW_ROOT", ww_root.path()); + let result = eval_list_dir(&[Val::Str("/../../etc".into())]); + std::env::remove_var("WW_ROOT"); + + let err = result.expect_err("expected traversal to be rejected"); + let msg = format!("{err}"); + assert!(msg.contains("path escapes root"), "unexpected error: {msg}"); + } + + #[test] + #[cfg(unix)] + fn eval_path_is_dir_rejects_symlink_escape_under_ww_root() { + use std::os::unix::fs::symlink; + + let _guard = WW_ROOT_TEST_LOCK.lock().unwrap(); + let ww_root = tempfile::tempdir().unwrap(); + let link = ww_root.path().join("escape"); + symlink("/etc", &link).unwrap(); + + std::env::set_var("WW_ROOT", ww_root.path()); + let result = eval_path_is_dir(&[Val::Str("/escape".into())]); + std::env::remove_var("WW_ROOT"); + + let err = result.expect_err("expected symlink escape to be rejected"); + let msg = format!("{err}"); + assert!( + msg.contains("symlink traversal"), + "unexpected error: {msg}" + ); + } + + #[test] + #[cfg(unix)] + fn eval_list_dir_rejects_symlink_escape_under_ww_root() { + use std::os::unix::fs::symlink; + + let _guard = WW_ROOT_TEST_LOCK.lock().unwrap(); + let ww_root = tempfile::tempdir().unwrap(); + let link = ww_root.path().join("escape"); + symlink("/etc", &link).unwrap(); + + std::env::set_var("WW_ROOT", ww_root.path()); + let result = eval_list_dir(&[Val::Str("/escape".into())]); + std::env::remove_var("WW_ROOT"); + + let err = result.expect_err("expected symlink escape to be rejected"); + let msg = format!("{err}"); + assert!( + msg.contains("symlink traversal"), + "unexpected error: {msg}" + ); + } + + #[test] + #[cfg(unix)] + fn eval_list_dir_includes_symlink_to_file() { + use std::os::unix::fs::symlink; + + let _guard = WW_ROOT_TEST_LOCK.lock().unwrap(); + std::env::remove_var("WW_ROOT"); + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("target.glia"); + let link = dir.path().join("link.glia"); + std::fs::write(&target, b"(+ 1 2)").unwrap(); + symlink(&target, &link).unwrap(); + + let listed = eval_list_dir(&[Val::Str(dir.path().to_str().unwrap().to_string())]).unwrap(); + let names: Vec = match listed { + Val::List(items) => items + .into_iter() + .filter_map(|v| match v { + Val::Str(s) => Some(s), + _ => None, + }) + .collect(), + other => panic!("expected list of names, got {other}"), + }; + assert!( + names.iter().any(|s| s == "link.glia"), + "expected symlinked file entry in list-dir output: {names:?}" + ); + } + + #[test] + #[cfg(unix)] + fn eval_list_dir_skips_broken_symlink_instead_of_failing() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().unwrap(); + let good = dir.path().join("good.glia"); + let broken = dir.path().join("broken.glia"); + std::fs::write(&good, b"(+ 1 2)").unwrap(); + symlink(dir.path().join("missing-target.glia"), &broken).unwrap(); + + let listed = eval_list_dir(&[Val::Str(dir.path().to_str().unwrap().to_string())]).unwrap(); + let names: Vec = match listed { + Val::List(items) => items + .into_iter() + .filter_map(|v| match v { + Val::Str(s) => Some(s), + _ => None, + }) + .collect(), + other => panic!("expected list of names, got {other}"), + }; + assert!( + names.iter().any(|s| s == "good.glia"), + "got names: {names:?}" + ); + assert!( + !names.iter().any(|s| s == "broken.glia"), + "broken symlink should be skipped: {names:?}" + ); + } + // --- wrap_with_handlers --- #[test] @@ -1878,7 +4551,15 @@ mod tests { #[test] fn dispatch_table_has_builtins() { let table = build_dispatch(); - let expected = ["load", "cd", "help", "exit"]; + let expected = [ + "load", + "list-dir", + "path-is-dir", + "sort-strings", + "cd", + "help", + "exit", + ]; for verb in &expected { assert!(table.contains_key(verb), "missing dispatch entry: {verb}"); } @@ -1889,6 +4570,57 @@ mod tests { ); } + #[test] + fn resolve_boot_file_path_uses_ww_root_without_host_fallback() { + let _guard = WW_ROOT_TEST_LOCK.lock().unwrap(); + let ww_root = tempfile::tempdir().unwrap(); + let host_root = tempfile::tempdir().unwrap(); + let host_rel = format!( + "tmp/{}/init.glia", + host_root.path().file_name().unwrap().to_string_lossy() + ); + let host_path = std::path::Path::new("/").join(&host_rel); + std::fs::create_dir_all(host_path.parent().unwrap()).unwrap(); + std::fs::write(&host_path, b"; host file").unwrap(); + + std::env::set_var("WW_ROOT", ww_root.path()); + let resolved = resolve_boot_file_path(&host_rel); + std::env::remove_var("WW_ROOT"); + + assert!( + resolved.is_none(), + "WW_ROOT must not silently fall back to host path, got: {resolved:?}" + ); + } + + #[test] + fn resolve_boot_file_path_finds_file_under_ww_root() { + let _guard = WW_ROOT_TEST_LOCK.lock().unwrap(); + let ww_root = tempfile::tempdir().unwrap(); + let rooted = ww_root.path().join("etc/init.glia"); + std::fs::create_dir_all(rooted.parent().unwrap()).unwrap(); + std::fs::write(&rooted, b"; rooted init").unwrap(); + + std::env::set_var("WW_ROOT", ww_root.path()); + let resolved = resolve_boot_file_path("etc/init.glia"); + std::env::remove_var("WW_ROOT"); + + assert_eq!( + resolved, + Some(rooted.to_string_lossy().to_string()), + "expected WW_ROOT-scoped init path" + ); + } + + #[test] + fn resolve_kernel_builtin_path_honors_ww_root_slash() { + let _guard = WW_ROOT_TEST_LOCK.lock().unwrap(); + std::env::set_var("WW_ROOT", "/"); + let resolved = resolve_kernel_builtin_path("/tmp").unwrap(); + std::env::remove_var("WW_ROOT"); + assert_eq!(resolved, "/tmp".to_string()); + } + // =================================================================== // Integration tests — dispatch handlers against capnp-rpc stub servers // =================================================================== @@ -1952,6 +4684,7 @@ mod tests { r.set_stream_dialer(capnp_rpc::new_client(TestStreamDialer)); r.set_vat_listener(capnp_rpc::new_client(TestVatListener)); r.set_vat_client(capnp_rpc::new_client(TestVatClient)); + r.set_http_listener(capnp_rpc::new_client(TestHttpListener)); Promise::ok(()) } } @@ -2099,13 +4832,48 @@ mod tests { } } - // --- Stub StreamDialer + VatClient (unused, just satisfy network result) --- + // --- Stub StreamDialer + VatClient --- struct TestStreamDialer; impl system_capnp::stream_dialer::Server for TestStreamDialer {} struct TestVatClient; - impl system_capnp::vat_client::Server for TestVatClient {} + #[allow(refining_impl_trait)] + impl system_capnp::vat_client::Server for TestVatClient { + fn dial( + self: capnp::capability::Rc, + params: system_capnp::vat_client::DialParams, + mut results: system_capnp::vat_client::DialResults, + ) -> Promise<(), capnp::Error> { + let p = capnp_rpc::pry!(params.get()); + let descriptor = capnp_rpc::pry!(p.get_descriptor()); + let schema_cid = capnp_rpc::pry!(descriptor.get_schema_cid()); + if schema_cid != schema_ids::HOST_CID.as_bytes() { + return Promise::err(capnp::Error::failed( + "expected descriptor.schemaCid to match host schema CID".into(), + )); + } + let host: system_capnp::host::Client = capnp_rpc::new_client(TestHost); + let aligned = bytes_to_aligned_words(schema_ids::HOST_SCHEMA); + let segments: &[&[u8]] = &[capnp::Word::words_to_bytes(&aligned)]; + let segment_array = capnp::message::SegmentArray::new(segments); + let reader = + capnp::message::Reader::new(segment_array, capnp::message::ReaderOptions::new()); + let schema_node: capnp::schema_capnp::node::Reader<'_> = capnp_rpc::pry!(reader.get_root()); + let mut typed = results.get().init_typed(); + typed + .reborrow() + .init_cap() + .set_as_capability(host.client.hook.clone()); + let mut out_schema = typed.reborrow().init_schema(); + capnp_rpc::pry!(out_schema.set_root(schema_node)); + out_schema.init_deps(0); + Promise::ok(()) + } + } + + struct TestHttpListener; + impl system_capnp::http_listener::Server for TestHttpListener {} // --- Stub Identity (unimplemented — not under test) --- @@ -2180,59 +4948,314 @@ mod tests { } } - /// Run an async block on a single-threaded tokio + capnp-rpc LocalSet. - async fn run_local(f: F) -> T - where - F: Future, - { - tokio::task::LocalSet::new().run_until(f).await + /// Run an async block on a single-threaded tokio + capnp-rpc LocalSet. + async fn run_local(f: F) -> T + where + F: Future, + { + tokio::task::LocalSet::new().run_until(f).await + } + + /// Call an AsyncNativeFn handler with a method keyword + rest args. + /// Provides a resume function and extracts the resumed value. + /// Returns Ok(resumed_value) or the handler's Err. + async fn call_handler(handler: &Val, method: &str, rest: &[Val]) -> Result { + let func = match handler { + Val::AsyncNativeFn { func, .. } => func.clone(), + _ => panic!("expected AsyncNativeFn"), + }; + let mut data_items = vec![Val::Keyword(method.into())]; + data_items.extend_from_slice(rest); + let data = Val::List(data_items); + + // Create a resume function that captures the value. + let captured: Rc>> = Rc::new(RefCell::new(None)); + let cap = captured.clone(); + let resume = Val::NativeFn { + name: "test-resume".into(), + func: Rc::new(move |args: &[Val]| { + *cap.borrow_mut() = Some(args[0].clone()); + Err(Val::Resume(Box::new(args[0].clone()))) + }), + }; + + match func(vec![data, resume]).await { + Err(Val::Resume(_)) => { + // Handler called resume — extract the value. + Ok(captured.borrow().clone().unwrap()) + } + Err(e) => Err(e), + Ok(v) => Ok(v), // Handler returned directly without resume. + } + } + + // --- kernel bootstrap tests --- + + #[tokio::test] + async fn test_kernel_bootstrap_forwards_membrane_graft() { + run_local(async { + let runtime: system_capnp::runtime::Client = capnp_rpc::new_client(TestRuntime); + let upstream: Membrane = capnp_rpc::new_client(TestMembrane { + runtime: runtime.clone(), + }); + let state: Rc>> = Rc::new(RefCell::new(Some(upstream))); + let policy = Rc::new(RefCell::new(Some(ExportPolicy { + caps: [("runtime".to_string(), ExportCapPolicy::default())] + .into_iter() + .collect(), + }))); + + let bootstrap: Membrane = capnp_rpc::new_client(KernelBootstrap { + membrane: state, + policy, + }); + let resp = bootstrap + .graft_request() + .send() + .promise + .await + .expect("bootstrap graft should succeed"); + let caps = resp.get().unwrap().get_caps().unwrap(); + + let forwarded_runtime: system_capnp::runtime::Client = + get_graft_cap(&caps, "runtime").expect("runtime cap should be forwarded"); + let load_resp = forwarded_runtime + .load_request() + .send() + .promise + .await + .expect("forwarded runtime should be callable"); + assert!(load_resp.get().unwrap().has_executor()); + }) + .await; + } + + #[tokio::test] + async fn test_kernel_bootstrap_errors_when_policy_references_unknown_cap() { + run_local(async { + let runtime: system_capnp::runtime::Client = capnp_rpc::new_client(TestRuntime); + let upstream: Membrane = capnp_rpc::new_client(TestMembrane { + runtime: runtime.clone(), + }); + let state: Rc>> = Rc::new(RefCell::new(Some(upstream))); + let policy = Rc::new(RefCell::new(Some(ExportPolicy { + caps: [ + ("runtime".to_string(), ExportCapPolicy::default()), + ("rutnime".to_string(), ExportCapPolicy::default()), + ] + .into_iter() + .collect(), + }))); + + let bootstrap: Membrane = capnp_rpc::new_client(KernelBootstrap { + membrane: state, + policy, + }); + + match bootstrap.graft_request().send().promise.await { + Ok(_) => panic!("bootstrap graft should fail for unknown policy cap"), + Err(err) => { + let msg = format!("{err}"); + assert!( + msg.contains("unknown cap"), + "expected unknown cap error, got: {msg}" + ); + assert!( + msg.contains("rutnime"), + "expected offending cap name in error, got: {msg}" + ); + } + } + }) + .await; + } + + #[tokio::test] + async fn test_kernel_bootstrap_errors_when_membrane_not_ready() { + run_local(async { + let state: Rc>> = Rc::new(RefCell::new(None)); + let policy = Rc::new(RefCell::new(Some(ExportPolicy { + caps: [("runtime".to_string(), ExportCapPolicy::default())] + .into_iter() + .collect(), + }))); + let bootstrap: Membrane = capnp_rpc::new_client(KernelBootstrap { + membrane: state, + policy, + }); + + match bootstrap.graft_request().send().promise.await { + Ok(_) => panic!("bootstrap graft should fail before membrane is ready"), + Err(err) => { + assert!( + format!("{err}").contains(INIT_MEMBRANE_NOT_READY), + "unexpected error: {err}" + ); + } + } + }) + .await; + } + + #[tokio::test] + async fn test_kernel_bootstrap_errors_when_policy_not_ready() { + run_local(async { + let runtime: system_capnp::runtime::Client = capnp_rpc::new_client(TestRuntime); + let upstream: Membrane = capnp_rpc::new_client(TestMembrane { runtime }); + let state: Rc>> = Rc::new(RefCell::new(Some(upstream))); + let policy = Rc::new(RefCell::new(None)); + let bootstrap: Membrane = capnp_rpc::new_client(KernelBootstrap { + membrane: state, + policy, + }); + + match bootstrap.graft_request().send().promise.await { + Ok(_) => panic!("bootstrap graft should fail before policy is ready"), + Err(err) => { + assert!( + format!("{err}").contains(INIT_POLICY_NOT_READY), + "unexpected error: {err}" + ); + } + } + }) + .await; } - /// Call an AsyncNativeFn handler with a method keyword + rest args. - /// Provides a resume function and extracts the resumed value. - /// Returns Ok(resumed_value) or the handler's Err. - async fn call_handler(handler: &Val, method: &str, rest: &[Val]) -> Result { - let func = match handler { - Val::AsyncNativeFn { func, .. } => func.clone(), - _ => panic!("expected AsyncNativeFn"), - }; - let mut data_items = vec![Val::Keyword(method.into())]; - data_items.extend_from_slice(rest); - let data = Val::List(data_items); + #[tokio::test] + async fn test_kernel_bootstrap_enforces_recursive_policy_on_host_network_stream_listener() { + run_local(async { + struct HostOnlyMembrane { + host: system_capnp::host::Client, + } + #[allow(refining_impl_trait)] + impl membrane_capnp::membrane::Server for HostOnlyMembrane { + fn graft( + self: capnp::capability::Rc, + _params: membrane_capnp::membrane::GraftParams, + mut results: membrane_capnp::membrane::GraftResults, + ) -> Promise<(), capnp::Error> { + let mut caps = results.get().init_caps(1); + let mut entry = caps.reborrow().get(0); + entry.set_name("host"); + entry + .reborrow() + .init_cap() + .set_as_capability(self.host.client.hook.clone()); + Promise::ok(()) + } + } - // Create a resume function that captures the value. - let captured: Rc>> = Rc::new(RefCell::new(None)); - let cap = captured.clone(); - let resume = Val::NativeFn { - name: "test-resume".into(), - func: Rc::new(move |args: &[Val]| { - *cap.borrow_mut() = Some(args[0].clone()); - Err(Val::Resume(Box::new(args[0].clone()))) - }), - }; + let upstream: Membrane = capnp_rpc::new_client(HostOnlyMembrane { + host: capnp_rpc::new_client(TestHost), + }); + let state: Rc>> = Rc::new(RefCell::new(Some(upstream))); - match func(vec![data, resume]).await { - Err(Val::Resume(_)) => { - // Handler called resume — extract the value. - Ok(captured.borrow().clone().unwrap()) + let stream_listener_policy = ExportCapPolicy { + allow_methods: Some(BTreeSet::new()), + returns: BTreeMap::new(), + }; + let host_policy = ExportCapPolicy { + allow_methods: Some(["network".to_string()].into_iter().collect()), + returns: BTreeMap::from([( + "network".to_string(), + BTreeMap::from([("streamListener".to_string(), stream_listener_policy)]), + )]), + }; + let policy = Rc::new(RefCell::new(Some(ExportPolicy { + caps: BTreeMap::from([("host".to_string(), host_policy)]), + }))); + + let bootstrap: Membrane = capnp_rpc::new_client(KernelBootstrap { + membrane: state, + policy, + }); + let resp = bootstrap + .graft_request() + .send() + .promise + .await + .expect("bootstrap graft should succeed"); + let caps = resp.get().unwrap().get_caps().unwrap(); + let forwarded_host: system_capnp::host::Client = + get_graft_cap(&caps, "host").expect("host cap should be forwarded"); + let network_resp = forwarded_host + .network_request() + .send() + .promise + .await + .expect("host.network should be allowed"); + let stream_listener = network_resp + .get() + .unwrap() + .get_stream_listener() + .expect("stream listener should be present"); + match stream_listener.listen_request().send().promise.await { + Ok(_) => panic!("stream-listener.listen should be denied by recursive policy"), + Err(err) => { + let msg = format!("{err}"); + assert!( + msg.contains("permission denied"), + "expected permission denied, got: {msg}" + ); + } } - Err(e) => Err(e), - Ok(v) => Ok(v), // Handler returned directly without resume. - } + }) + .await; } - // --- kernel bootstrap tests --- - #[tokio::test] - async fn test_kernel_bootstrap_forwards_membrane_graft() { + async fn test_kernel_bootstrap_enforces_recursive_policy_on_vat_client_dial_cap() { run_local(async { - let runtime: system_capnp::runtime::Client = capnp_rpc::new_client(TestRuntime); - let upstream: Membrane = capnp_rpc::new_client(TestMembrane { - runtime: runtime.clone(), + struct HostOnlyMembrane { + host: system_capnp::host::Client, + } + #[allow(refining_impl_trait)] + impl membrane_capnp::membrane::Server for HostOnlyMembrane { + fn graft( + self: capnp::capability::Rc, + _params: membrane_capnp::membrane::GraftParams, + mut results: membrane_capnp::membrane::GraftResults, + ) -> Promise<(), capnp::Error> { + let mut caps = results.get().init_caps(1); + let mut entry = caps.reborrow().get(0); + entry.set_name("host"); + entry + .reborrow() + .init_cap() + .set_as_capability(self.host.client.hook.clone()); + Promise::ok(()) + } + } + + let upstream: Membrane = capnp_rpc::new_client(HostOnlyMembrane { + host: capnp_rpc::new_client(TestHost), }); let state: Rc>> = Rc::new(RefCell::new(Some(upstream))); - let bootstrap: Membrane = capnp_rpc::new_client(KernelBootstrap { membrane: state }); + let cap_policy = ExportCapPolicy { + allow_methods: Some(["id".to_string()].into_iter().collect()), + returns: BTreeMap::new(), + }; + let vat_client_policy = ExportCapPolicy { + allow_methods: Some(["dial".to_string()].into_iter().collect()), + returns: BTreeMap::from([("dial".to_string(), BTreeMap::from([("cap".to_string(), cap_policy)]))]), + }; + let host_policy = ExportCapPolicy { + allow_methods: Some(["network".to_string()].into_iter().collect()), + returns: BTreeMap::from([( + "network".to_string(), + BTreeMap::from([("vatClient".to_string(), vat_client_policy)]), + )]), + }; + let policy = Rc::new(RefCell::new(Some(ExportPolicy { + caps: BTreeMap::from([("host".to_string(), host_policy)]), + }))); + + let bootstrap: Membrane = capnp_rpc::new_client(KernelBootstrap { + membrane: state, + policy, + }); let resp = bootstrap .graft_request() .send() @@ -2240,32 +5263,221 @@ mod tests { .await .expect("bootstrap graft should succeed"); let caps = resp.get().unwrap().get_caps().unwrap(); + let forwarded_host: system_capnp::host::Client = + get_graft_cap(&caps, "host").expect("host cap should be forwarded"); - let forwarded_runtime: system_capnp::runtime::Client = - get_graft_cap(&caps, "runtime").expect("runtime cap should be forwarded"); - let load_resp = forwarded_runtime - .load_request() + let network_resp = forwarded_host + .network_request() .send() .promise .await - .expect("forwarded runtime should be callable"); - assert!(load_resp.get().unwrap().has_executor()); + .expect("host.network should be allowed"); + let vat_client = network_resp + .get() + .unwrap() + .get_vat_client() + .expect("vat client should be present"); + + let mut dial_req = vat_client.dial_request(); + dial_req.get().set_peer(STUB_PEER_ID); + { + let mut descriptor = dial_req.get().init_descriptor(); + descriptor.set_wasi_cid(b"test-wasi-cid"); + descriptor.set_schema_cid(schema_ids::HOST_CID.as_bytes()); + } + let dial_resp = dial_req.send().promise.await.expect("dial should be allowed"); + let typed_host: system_capnp::host::Client = dial_resp + .get() + .unwrap() + .get_typed() + .unwrap() + .get_cap() + .get_as_capability() + .expect("returned cap should cast to host"); + + let id_resp = typed_host.id_request().send().promise.await; + assert!(id_resp.is_ok(), "id should be allowed"); + match typed_host.addrs_request().send().promise.await { + Ok(_) => panic!("host.addrs should be denied by recursive dial.cap policy"), + Err(err) => { + let msg = format!("{err}"); + assert!( + msg.contains("permission denied"), + "expected permission denied, got: {msg}" + ); + } + } }) .await; } #[tokio::test] - async fn test_kernel_bootstrap_errors_when_membrane_not_ready() { + async fn test_kernel_bootstrap_enforces_recursive_policy_on_process_bootstrap_cap() { run_local(async { - let state: Rc>> = Rc::new(RefCell::new(None)); - let bootstrap: Membrane = capnp_rpc::new_client(KernelBootstrap { membrane: state }); + struct TestProcessReturnsHost; + #[allow(refining_impl_trait)] + impl system_capnp::process::Server for TestProcessReturnsHost { + fn bootstrap( + self: capnp::capability::Rc, + _params: system_capnp::process::BootstrapParams, + mut results: system_capnp::process::BootstrapResults, + ) -> Promise<(), capnp::Error> { + let host: system_capnp::host::Client = capnp_rpc::new_client(TestHost); + let aligned = bytes_to_aligned_words(schema_ids::HOST_SCHEMA); + let segments: &[&[u8]] = &[capnp::Word::words_to_bytes(&aligned)]; + let segment_array = capnp::message::SegmentArray::new(segments); + let reader = + capnp::message::Reader::new(segment_array, capnp::message::ReaderOptions::new()); + let schema_node: capnp::schema_capnp::node::Reader<'_> = + capnp_rpc::pry!(reader.get_root()); + let mut typed = results.get().init_typed(); + typed + .reborrow() + .init_cap() + .set_as_capability(host.client.hook.clone()); + let mut out_schema = typed.reborrow().init_schema(); + capnp_rpc::pry!(out_schema.set_root(schema_node)); + out_schema.init_deps(0); + Promise::ok(()) + } + } - match bootstrap.graft_request().send().promise.await { - Ok(_) => panic!("bootstrap graft should fail before membrane is ready"), + struct TestExecutorReturnsProcess; + #[allow(refining_impl_trait)] + impl system_capnp::executor::Server for TestExecutorReturnsProcess { + fn spawn( + self: capnp::capability::Rc, + _params: system_capnp::executor::SpawnParams, + mut results: system_capnp::executor::SpawnResults, + ) -> Promise<(), capnp::Error> { + results + .get() + .set_process(capnp_rpc::new_client(TestProcessReturnsHost)); + Promise::ok(()) + } + } + + struct TestRuntimeReturnsExecutor; + #[allow(refining_impl_trait)] + impl system_capnp::runtime::Server for TestRuntimeReturnsExecutor { + fn load( + self: capnp::capability::Rc, + _params: system_capnp::runtime::LoadParams, + mut results: system_capnp::runtime::LoadResults, + ) -> Promise<(), capnp::Error> { + results + .get() + .set_executor(capnp_rpc::new_client(TestExecutorReturnsProcess)); + Promise::ok(()) + } + } + + struct RuntimeOnlyMembrane { + runtime: system_capnp::runtime::Client, + } + #[allow(refining_impl_trait)] + impl membrane_capnp::membrane::Server for RuntimeOnlyMembrane { + fn graft( + self: capnp::capability::Rc, + _params: membrane_capnp::membrane::GraftParams, + mut results: membrane_capnp::membrane::GraftResults, + ) -> Promise<(), capnp::Error> { + let mut caps = results.get().init_caps(1); + let mut entry = caps.reborrow().get(0); + entry.set_name("runtime"); + entry + .reborrow() + .init_cap() + .set_as_capability(self.runtime.client.hook.clone()); + Promise::ok(()) + } + } + + let upstream: Membrane = capnp_rpc::new_client(RuntimeOnlyMembrane { + runtime: capnp_rpc::new_client(TestRuntimeReturnsExecutor), + }); + let state: Rc>> = Rc::new(RefCell::new(Some(upstream))); + + let cap_policy = ExportCapPolicy { + allow_methods: Some(["id".to_string()].into_iter().collect()), + returns: BTreeMap::new(), + }; + let process_policy = ExportCapPolicy { + allow_methods: Some(["bootstrap".to_string()].into_iter().collect()), + returns: BTreeMap::from([( + "bootstrap".to_string(), + BTreeMap::from([("cap".to_string(), cap_policy)]), + )]), + }; + let executor_policy = ExportCapPolicy { + allow_methods: Some(["spawn".to_string()].into_iter().collect()), + returns: BTreeMap::from([( + "spawn".to_string(), + BTreeMap::from([("process".to_string(), process_policy)]), + )]), + }; + let runtime_policy = ExportCapPolicy { + allow_methods: Some(["load".to_string()].into_iter().collect()), + returns: BTreeMap::from([( + "load".to_string(), + BTreeMap::from([("executor".to_string(), executor_policy)]), + )]), + }; + let policy = Rc::new(RefCell::new(Some(ExportPolicy { + caps: BTreeMap::from([("runtime".to_string(), runtime_policy)]), + }))); + + let bootstrap: Membrane = capnp_rpc::new_client(KernelBootstrap { + membrane: state, + policy, + }); + let resp = bootstrap + .graft_request() + .send() + .promise + .await + .expect("bootstrap graft should succeed"); + let caps = resp.get().unwrap().get_caps().unwrap(); + let runtime: system_capnp::runtime::Client = + get_graft_cap(&caps, "runtime").expect("runtime cap should be forwarded"); + + let mut load_req = runtime.load_request(); + load_req.get().set_wasm(b"00"); + let load_resp = load_req.send().promise.await.expect("load should be allowed"); + let executor = load_resp.get().unwrap().get_executor().unwrap(); + + let spawn_resp = executor + .spawn_request() + .send() + .promise + .await + .expect("spawn should be allowed"); + let process = spawn_resp.get().unwrap().get_process().unwrap(); + + let boot_req = process.bootstrap_request(); + let boot_resp = boot_req + .send() + .promise + .await + .expect("process.bootstrap should be allowed"); + let typed_host: system_capnp::host::Client = boot_resp + .get() + .unwrap() + .get_typed() + .unwrap() + .get_cap() + .get_as_capability() + .expect("returned cap should cast to host"); + + let id_resp = typed_host.id_request().send().promise.await; + assert!(id_resp.is_ok(), "id should be allowed"); + match typed_host.peers_request().send().promise.await { + Ok(_) => panic!("host.peers should be denied by recursive bootstrap.cap policy"), Err(err) => { + let msg = format!("{err}"); assert!( - format!("{err}").contains("not ready"), - "unexpected error: {err}" + msg.contains("permission denied"), + "expected permission denied, got: {msg}" ); } } @@ -2273,6 +5485,97 @@ mod tests { .await; } + #[test] + fn test_parse_export_policy_rejects_non_map() { + let err = parse_export_policy(&Val::Int(1)).unwrap_err(); + assert!(format!("{err}").contains("must return a map")); + } + + #[test] + fn test_parse_export_policy_rejects_legacy_export_shape() { + let policy = read("{:export {:caps [\"runtime\"] :methods {}}}").unwrap(); + let err = parse_export_policy(&policy).unwrap_err(); + assert!(format!("{err}").contains("legacy")); + } + + #[test] + fn test_parse_export_policy_accepts_bare_map() { + let policy = Val::Map(glia::ValMap::from_pairs(vec![ + ( + Val::Keyword("runtime".into()), + test_cap("runtime", "runtime-cid"), + ), + (Val::Keyword("host".into()), test_cap("host", "host-cid")), + ])); + let parsed = parse_export_policy(&policy).unwrap(); + assert!(parsed.caps.contains_key("runtime")); + assert!(parsed.caps.contains_key("host")); + } + + #[test] + fn test_parse_export_policy_allows_empty_caps() { + let policy = read("{}").unwrap(); + let parsed = parse_export_policy(&policy).unwrap(); + assert!(parsed.caps.is_empty()); + } + + #[test] + fn test_parse_export_policy_rejects_unknown_export_cap() { + let policy = Val::Map(glia::ValMap::from_pairs(vec![( + Val::Keyword("custom".into()), + test_cap("custom", "custom-cid"), + )])); + let err = parse_export_policy(&policy).unwrap_err(); + assert!(format!("{err}").contains("unknown cap")); + } + + #[test] + fn test_parse_export_policy_rejects_duplicate_cap_keys_after_canonicalization() { + let policy = Val::Map(glia::ValMap::from_pairs(vec![ + (Val::Keyword("host".into()), test_cap("host", "host-cid")), + (Val::Str("host".into()), test_cap("host", "host-cid")), + ])); + let err = parse_export_policy(&policy).unwrap_err(); + assert!(format!("{err}").contains("duplicate cap key")); + } + + #[test] + fn test_parse_export_policy_accepts_recursive_returns_under_anypointer() { + let deny_more = AttenuationPolicy { + allow_methods: ["id".to_string()].into_iter().collect(), + returns: BTreeMap::from([( + "id".to_string(), + BTreeMap::from([("x".to_string(), AttenuationPolicy::default())]), + )]), + }; + let vat_client_policy = AttenuationPolicy { + allow_methods: ["dial".to_string()].into_iter().collect(), + returns: BTreeMap::from([( + "dial".to_string(), + BTreeMap::from([("cap".to_string(), deny_more)]), + )]), + }; + let host_policy = AttenuationPolicy { + allow_methods: ["network".to_string()].into_iter().collect(), + returns: BTreeMap::from([( + "network".to_string(), + BTreeMap::from([("vatClient".to_string(), vat_client_policy)]), + )]), + }; + let host_att = make_cap( + "host", + "host-cid", + Rc::new(AttenuatedCapInner { + base: test_cap("host", "host-cid"), + policy: host_policy, + descriptor: vec![], + }), + ); + let policy = Val::Map(glia::ValMap::from_pairs(vec![(Val::Keyword("host".into()), host_att)])); + let parsed = parse_export_policy(&policy).unwrap(); + assert!(parsed.caps.contains_key("host")); + } + // --- host tests --- #[tokio::test] @@ -2612,10 +5915,7 @@ mod tests { let peer_id = entries .get(&Val::Keyword("peer-id".into())) .expect("missing :peer-id key"); - assert_eq!( - *peer_id, - Val::Str(bs58::encode(b"peer-0").into_string()) - ); + assert_eq!(*peer_id, Val::Str(bs58::encode(b"peer-0").into_string())); } other => panic!("expected map, got {other:?}"), } @@ -2716,10 +6016,9 @@ mod tests { run_local(async { let s = test_session(); let handler = make_routing_handler(s.routing.clone()); - let result = - call_handler(&handler, "resolve", &[Val::Str("/ipns/k51qzi-test".into())]) - .await - .unwrap(); + let result = call_handler(&handler, "resolve", &[Val::Str("/ipns/k51qzi-test".into())]) + .await + .unwrap(); assert_eq!(result, Val::Str("/ipfs/bafyrei-test-resolved".into())); }) .await; @@ -2766,10 +6065,7 @@ mod tests { ), ]; for (name, cid, inner, handler) in caps { - env.set( - name.to_string(), - make_cap(name, cid, inner), - ); + env.set(name.to_string(), make_cap(name, cid, inner)); env.set(format!("{name}-handler"), handler); } env.set("import".to_string(), make_import_cap()); @@ -2797,7 +6093,6 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let file_path = dir.path().join("test.bin"); std::fs::write(&file_path, b"hello-bytes").unwrap(); - std::env::remove_var("WW_ROOT"); let form = Val::List(vec![ Val::Sym("perform".into()), @@ -2820,8 +6115,6 @@ mod tests { let mut env = Env::new(); bind_caps_in_env(&mut env, &ctx.borrow()); - std::env::remove_var("WW_ROOT"); - let form = Val::List(vec![ Val::Sym("perform".into()), Val::Keyword("load".into()), @@ -2836,7 +6129,7 @@ mod tests { // --- init script eval integration --- - /// Eval (perform host :listen runtime (perform :load "path")) end-to-end. + /// Eval (perform host :listen (cell ...)) end-to-end. #[tokio::test] async fn test_chess_glia_listen_form_evals_end_to_end() { run_local(async { @@ -2848,10 +6141,9 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let wasm_path = dir.path().join("chess-demo.wasm"); std::fs::write(&wasm_path, b"fake-wasm-bytes").unwrap(); - std::env::remove_var("WW_ROOT"); let script = format!( - r#"(perform host :listen runtime (perform :load "{}"))"#, + r#"(perform host :listen (cell (perform :load "{}") (schema host)))"#, wasm_path.to_str().unwrap() ); let form = read(&script).unwrap(); @@ -2878,10 +6170,9 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let wasm_path = dir.path().join("chess-demo.wasm"); std::fs::write(&wasm_path, b"fake-wasm-bytes").unwrap(); - std::env::remove_var("WW_ROOT"); let script = format!( - r#"(perform host :listen runtime (perform :load "{}")) + r#"(perform host :listen (cell (perform :load "{}") (schema host))) (perform runtime :run (perform :load "{}"))"#, wasm_path.to_str().unwrap(), wasm_path.to_str().unwrap() @@ -2908,36 +6199,6 @@ mod tests { .await; } - // --- run_initd integration --- - - /// run_initd with no WW_ROOT set returns false (no scripts to run). - #[tokio::test] - async fn test_run_initd_no_ww_root_skips() { - run_local(async { - let ctx = RefCell::new(test_session()); - let dispatch = build_dispatch(); - let mut env = Env::new(); - std::env::remove_var("WW_ROOT"); - let blocked = run_initd(&mut env, &ctx, &dispatch).await.unwrap(); - assert!(!blocked, "should not block when WW_ROOT is unset"); - }) - .await; - } - - /// run_initd with empty WW_ROOT skips gracefully. - #[tokio::test] - async fn test_run_initd_empty_ww_root_skips() { - run_local(async { - let ctx = RefCell::new(test_session()); - let dispatch = build_dispatch(); - let mut env = Env::new(); - std::env::set_var("WW_ROOT", ""); - let blocked = run_initd(&mut env, &ctx, &dispatch).await.unwrap(); - assert!(!blocked, "should not block when WW_ROOT is empty"); - }) - .await; - } - // --- extract_capnp_client tests --- #[test] @@ -3047,7 +6308,14 @@ mod tests { #[test] fn schema_returns_bytes_for_each_known_cap() { let builtin = make_schema_builtin(); - for name in ["host", "runtime", "routing", "identity", "http", "http-client"] { + for name in [ + "host", + "runtime", + "routing", + "identity", + "http", + "http-client", + ] { let cap = test_cap(name, "test-cid"); let result = call_builtin(&builtin, &[cap]).unwrap_or_else(|e| { panic!("schema for '{name}' returned error: {e}"); @@ -3078,10 +6346,7 @@ mod tests { fn schema_arity_mismatch_returns_structured_error() { let builtin = make_schema_builtin(); let err = call_builtin(&builtin, &[]).unwrap_err(); - assert_eq!( - glia::error::type_tag(&err), - Some(glia::error::tag::ARITY) - ); + assert_eq!(glia::error::type_tag(&err), Some(glia::error::tag::ARITY)); } #[test] @@ -3124,6 +6389,62 @@ mod tests { ); } + #[test] + fn doc_attenuated_cap_reports_recursive_returns() { + let builtin = make_doc_builtin(); + let base = test_cap("host-att", "host-cid"); + + let mut allow_host = BTreeSet::new(); + allow_host.insert("network".to_string()); + let mut allow_vat = BTreeSet::new(); + allow_vat.insert("dial".to_string()); + let mut allow_remote = BTreeSet::new(); + allow_remote.insert("id".to_string()); + + let remote_policy = AttenuationPolicy { + allow_methods: allow_remote, + returns: BTreeMap::new(), + }; + let vat_policy = AttenuationPolicy { + allow_methods: allow_vat, + returns: BTreeMap::from([( + "dial".to_string(), + BTreeMap::from([("cap".to_string(), remote_policy)]), + )]), + }; + let host_policy = AttenuationPolicy { + allow_methods: allow_host, + returns: BTreeMap::from([( + "network".to_string(), + BTreeMap::from([("vatClient".to_string(), vat_policy)]), + )]), + }; + + let att_cap = make_cap( + "host-att", + "host-cid", + Rc::new(AttenuatedCapInner { + base, + policy: host_policy, + descriptor: b"attenuated".to_vec(), + }), + ); + + let result = call_builtin(&builtin, &[att_cap]).unwrap(); + let text = match result { + Val::Str(s) => s, + other => panic!("expected Val::Str, got {other:?}"), + }; + assert!( + text.contains("return-edges: 2"), + "doc should include recursive return edges: {text}" + ); + assert!( + text.contains("return-depth: 2"), + "doc should include recursive return depth: {text}" + ); + } + #[test] fn help_includes_schema_byte_count_for_known_cap() { let builtin = make_help_builtin(); @@ -3196,7 +6517,10 @@ mod tests { "glia:defcap:v1", Rc::new(AttenuatedCapInner { base, - allow_methods: allow, + policy: AttenuationPolicy { + allow_methods: allow, + returns: BTreeMap::new(), + }, descriptor: descriptor.clone(), }), ); @@ -3204,22 +6528,66 @@ mod tests { assert_eq!(out, Val::Bytes(descriptor)); } + fn encode_test_interface_schema(methods: &[(&str, u16)]) -> Vec { + let mut msg = capnp::message::Builder::new_default(); + { + let mut node = msg.init_root::>(); + node.set_id(0xabad1dea); + let mut iface = node.reborrow().init_interface(); + let mut out_methods = iface.reborrow().init_methods(methods.len() as u32); + for (i, (name, code_order)) in methods.iter().enumerate() { + let mut method = out_methods.reborrow().get(i as u32); + method.set_name(name); + method.set_code_order(*code_order); + } + } + let segments = msg.get_segments_for_output(); + assert_eq!(segments.len(), 1, "test schema should fit in one segment"); + segments[0].to_vec() + } + + #[test] + fn parse_interface_methods_uses_wire_ordinals_not_code_order() { + let schema = encode_test_interface_schema(&[("alpha", 7), ("beta", 0)]); + let (_interface_id, by_name, by_id) = parse_interface_methods_from_schema(&schema).unwrap(); + + // Wire method IDs are list ordinals, so alpha=0 and beta=1. + assert_eq!(by_name.get("alpha"), Some(&0)); + assert_eq!(by_name.get("beta"), Some(&1)); + assert_eq!(by_id.get(&0).map(String::as_str), Some("alpha")); + assert_eq!(by_id.get(&1).map(String::as_str), Some("beta")); + } + + #[test] + fn build_dynamic_method_policy_resolves_allow_names_to_wire_ordinals() { + let schema = encode_test_interface_schema(&[("alpha", 7), ("beta", 0)]); + let mut allow = BTreeSet::new(); + allow.insert("alpha".to_string()); + let policy = ExportCapPolicy { + allow_methods: Some(allow), + returns: BTreeMap::new(), + }; + + let dyn_policy = + build_dynamic_method_policy("iface", "dial", "cap", &policy, &schema).unwrap(); + let allowed_ids = dyn_policy.allowed_ids.expect("allow set should be present"); + + assert!(allowed_ids.contains(&0), "alpha should map to wire ordinal 0"); + assert!( + !allowed_ids.contains(&1), + "beta wire ordinal should not be in allow set" + ); + } + #[test] fn unwrap_cap_arg_rejects_zero_args() { let err = unwrap_cap_arg("schema", &[]).unwrap_err(); - assert_eq!( - glia::error::type_tag(&err), - Some(glia::error::tag::ARITY) - ); + assert_eq!(glia::error::type_tag(&err), Some(glia::error::tag::ARITY)); } #[test] fn unwrap_cap_arg_rejects_two_args() { let err = unwrap_cap_arg("schema", &[Val::Nil, Val::Nil]).unwrap_err(); - assert_eq!( - glia::error::type_tag(&err), - Some(glia::error::tag::ARITY) - ); + assert_eq!(glia::error::type_tag(&err), Some(glia::error::tag::ARITY)); } - } diff --git a/std/lib/init/default.glia b/std/lib/init/default.glia new file mode 100644 index 00000000..b7f8f2a3 --- /dev/null +++ b/std/lib/init/default.glia @@ -0,0 +1,17 @@ +;; Shared default init flow. +;; +;; This file only handles init.d orchestration (mechanism), not export policy. +;; Image-local /etc/init.glia remains the policy authority and should return +;; the final bare export map after loading this file. +;; +;; Deterministic order is lexical; use numeric prefixes such as: +;; 00-setup.glia, 10-http.glia, 20-worker.glia +;; If /etc/init.d is absent, orchestration is skipped. + +(def os (perform import "ww/os")) +(def initd-path "/etc/init.d") +(def initd-entries + (if (os :dir-exists? initd-path) + (os :discover-initd initd-path) + [])) +(os :eval-initd initd-path initd-entries) diff --git a/std/lib/ww/os.glia b/std/lib/ww/os.glia new file mode 100644 index 00000000..2162e5c8 --- /dev/null +++ b/std/lib/ww/os.glia @@ -0,0 +1,33 @@ +;; ww/os — minimal OS-facing helpers for init orchestration +;; +;; Usage: (def os (perform import "ww/os")) +;; (os :list-dir "/etc/init.d") +;; (os :dir-exists? "/etc/init.d") +;; (os :sort-strings ["b.glia" "a.glia"]) ;=> ("a.glia" "b.glia") +;; +;; TODO(post-PR3): expand ww/os beyond boot-orchestration needs (glob/stat/etc). + +(def s (perform import "ww/string")) + +(defn list-dir [path] + (perform :list-dir path)) + +(defn dir-exists? [path] + (perform :path-is-dir path)) + +(defn sort-strings [items] + (perform :sort-strings items)) + +(defn discover-initd [path] + (filter + (fn [name] + (and + (s :ends-with? name ".glia") + (not (s :starts-with? name ".")))) + (sort-strings (list-dir path)))) + +(defn eval-initd [path entries] + (loop [remaining entries] + (when (not (empty? remaining)) + (load-file (str path "/" (first remaining))) + (recur (rest remaining))))) diff --git a/std/status/etc/init.glia b/std/status/etc/init.glia new file mode 100644 index 00000000..c9c23178 --- /dev/null +++ b/std/status/etc/init.glia @@ -0,0 +1,8 @@ +(load-file "/lib/init/default.glia") + +{:identity identity + :host host + :runtime runtime + :routing routing + :ipfs ipfs + :http-client http-client} diff --git a/tests/discovery_integration.rs b/tests/discovery_integration.rs index 0012c41d..4da10fcf 100644 --- a/tests/discovery_integration.rs +++ b/tests/discovery_integration.rs @@ -86,16 +86,22 @@ async fn spawn_greeter_on_pool( let spawn_resp = req.send().promise.await.unwrap(); let process = spawn_resp.get().unwrap().get_process().unwrap(); - let bootstrap_resp = tokio::time::timeout( - std::time::Duration::from_secs(60), - process.bootstrap_request().send().promise, - ) + let bootstrap_resp = tokio::time::timeout(std::time::Duration::from_secs(60), { + let req = process.bootstrap_request(); + req.send().promise + }) .await; match bootstrap_resp { Ok(Ok(resp)) => { - let cap: capnp::capability::Client = - resp.get().unwrap().get_cap().get_as_capability().unwrap(); + let cap: capnp::capability::Client = resp + .get() + .unwrap() + .get_typed() + .unwrap() + .get_cap() + .get_as_capability() + .unwrap(); // Bridge the bootstrap cap to the duplex stream so the // test thread can use it. diff --git a/tests/shell_e2e.rs b/tests/shell_e2e.rs index d7ce8e96..b93053f9 100644 --- a/tests/shell_e2e.rs +++ b/tests/shell_e2e.rs @@ -81,17 +81,23 @@ async fn spawn_shell_on_pool(pool: &ExecutorPool) -> Result { - let cap: capnp::capability::Client = - resp.get().unwrap().get_cap().get_as_capability().unwrap(); + let cap: capnp::capability::Client = resp + .get() + .unwrap() + .get_typed() + .unwrap() + .get_cap() + .get_as_capability() + .unwrap(); eprintln!(" [worker] got bootstrap cap, bridging to duplex"); let (reader, writer) = tokio::io::split(cell_end); diff --git a/tests/snap_hello_rs_http_listener_e2e.rs b/tests/snap_hello_rs_http_listener_e2e.rs deleted file mode 100644 index 5d7daf8c..00000000 --- a/tests/snap_hello_rs_http_listener_e2e.rs +++ /dev/null @@ -1,520 +0,0 @@ -//! End-to-end test for the snap-hello-rs cell via the HttpListener -//! dispatch chain. -//! -//! Mirrors `tests/status_cell_http_listener_e2e.rs`. Validates the full -//! Farcaster Snap content-negotiation contract + Snap v1 viewer-aware -//! / POST handling: -//! -//! GET with `Accept: application/vnd.farcaster.snap+json` -//! → 200, snap-JSON body, snap content-type, Vary/Cache-Control/ACAO -//! -//! GET with `Accept: text/html` (or anything else) -//! → 200, HTML body, text/html content-type, Link rel=alternate -//! -//! GET with snap Accept + `verified_snap: Some(VerifiedJfs{fid=N})` -//! → 200, snap-JSON with `content: "Hello, FID #N"` (viewer-aware) -//! -//! POST with snap Accept -//! → 200, snap-JSON ack (snap spec's submit-action contract) -//! -//! Spec: https://docs.farcaster.xyz/snap/spec-overview -//! https://docs.farcaster.xyz/snap/http-headers -//! https://docs.farcaster.xyz/snap/auth -//! -//! Requires pre-built snap WASM: `make -C examples/snap-hello-rs`. -//! No graft caps used; the cell is stateless. - -// Tests in this file hold a `std::sync::Mutex<()>` across `.await` -// points to serialize Runtime setup (see `TEST_LOCK` below). Clippy -// flags this in general because `std::sync::Mutex` can deadlock if -// the future moves to a different thread mid-await. We use -// `#[tokio::test(flavor = "current_thread")]` everywhere here, which -// pins each test's future to a single thread for its entire lifetime, -// so the deadlock condition can't arise. Suppress the lint at the -// file level rather than per-test to keep the test bodies readable. -#![allow(clippy::await_holding_lock)] - -use std::sync::Mutex; - -use tokio::sync::{mpsc, oneshot, watch}; - -use ww::dispatcher::server::{new_registry, CgiRequest, CgiResponse}; -use ww::launcher::create_runtime_client; -use ww::rpc::{CachePolicy, NetworkState}; -use ww::system_capnp; - -/// Serialize tests within this file. Each test spins up its own -/// Runtime + 4-worker executor pool + libp2p stack and connects to -/// the local Kubo daemon at port 5001; running them in parallel -/// (cargo test's default) blows past one of those concurrency -/// limits and all tests fail with what looks like setup races. -/// Holding this Mutex for the duration of each test is the simplest -/// fix that avoids adding a `serial_test` dep. -static TEST_LOCK: Mutex<()> = Mutex::new(()); - -const SNAP_WASM_PATH: &str = "examples/snap-hello-rs/bin/snap-hello-rs.wasm"; -const SNAP_TYPE: &str = "application/vnd.farcaster.snap+json"; - -fn snap_wasm_exists() -> bool { - std::path::Path::new(SNAP_WASM_PATH).exists() -} - -fn synth_peer_id_bytes() -> Vec { - let kp = libp2p::identity::Keypair::generate_ed25519(); - libp2p::PeerId::from_public_key(&kp.public()).to_bytes() -} - -/// Case-insensitive header lookup. Returns the first match. -fn find_header<'a>(resp: &'a CgiResponse, name: &str) -> Option<&'a str> { - resp.headers - .iter() - .find(|(k, _)| k.eq_ignore_ascii_case(name)) - .map(|(_, v)| v.as_str()) -} - -/// Set up Runtime + HttpListener wiring, register the snap cell on -/// `/snaps/hello`, and return the request-sender for that route. -/// Lives inside a `LocalSet` because capnp-rpc spawns `!Send` tasks. -async fn register_snap_route() -> mpsc::Sender { - let network_state = NetworkState::new(); - let peer_id_bytes = synth_peer_id_bytes(); - network_state.set_local_peer_id(peer_id_bytes).await; - - let epoch = membrane::Epoch { - seq: 1, - head: vec![], - provenance: membrane::Provenance::Block(0), - }; - let (_epoch_tx, epoch_rx) = watch::channel(epoch); - let guard = membrane::EpochGuard { - issued_seq: 1, - receiver: epoch_rx.clone(), - }; - let stream_control = libp2p_stream::Behaviour::new().new_control(); - - let (swarm_tx, _swarm_rx) = mpsc::channel(16); - let runtime = create_runtime_client( - network_state, - swarm_tx, - false, - Some(guard.clone()), - Some(epoch_rx.clone()), - None, - Some(stream_control), - None, - None, - CachePolicy::Shared, - ww::ipfs::HttpClient::new("http://localhost:5001".into()), - Vec::new(), - ); - - let wasm = std::fs::read(SNAP_WASM_PATH).expect("read snap-hello-rs.wasm"); - let mut load_req = runtime.load_request(); - load_req.get().set_wasm(&wasm); - let load_resp = load_req.send().promise.await.expect("runtime.load"); - let executor = load_resp - .get() - .expect("load resp") - .get_executor() - .expect("get executor"); - - let route_registry = new_registry(); - let listener_impl = - ww::rpc::http_listener::HttpListenerImpl::new(guard, route_registry.clone()); - let listener: system_capnp::http_listener::Client = capnp_rpc::new_client(listener_impl); - - let mut listen_req = listener.listen_request(); - listen_req.get().set_executor(executor); - listen_req.get().set_prefix("/snaps/hello"); - // No caps grafted: this cell is stateless and pure. - let _ = listen_req.get().init_caps(0); - listen_req - .send() - .promise - .await - .expect("HttpListener.listen should succeed"); - - let routes = route_registry.read().expect("registry read lock"); - routes - .get("/snaps/hello") - .cloned() - .expect("route /snaps/hello should be registered") -} - -/// Send a GET CGI request (no body, no verified payload) with the given -/// headers through the route channel and await the response. -async fn dispatch(tx: &mpsc::Sender, headers: Vec<(String, String)>) -> CgiResponse { - dispatch_full(tx, "GET", headers, Vec::new(), None).await -} - -/// Full dispatch with all knobs exposed: method, headers, body, and -/// optional verified-JFS payload (simulating the listener having -/// already verified an `X-Snap-Payload` header upstream). -async fn dispatch_full( - tx: &mpsc::Sender, - method: &str, - headers: Vec<(String, String)>, - body: Vec, - verified_snap: Option, -) -> CgiResponse { - let (response_tx, response_rx) = oneshot::channel(); - let cgi_req = CgiRequest { - method: method.into(), - path: "/snaps/hello".into(), - query: String::new(), - headers, - body, - verified_snap, - response_tx, - }; - tx.send(cgi_req) - .await - .expect("CgiRequest should send through route channel"); - - tokio::time::timeout(std::time::Duration::from_secs(20), response_rx) - .await - .expect("dispatch should respond within 20s") - .expect("response_rx not dropped") -} - -/// Snap-aware client: Accept header signals snap support. -/// Cell must return snap-JSON with all 4 spec-required headers. -#[tokio::test(flavor = "current_thread")] -async fn snap_cell_with_snap_accept_returns_snap_json_and_required_headers() { - let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - if !snap_wasm_exists() { - eprintln!( - "skipping: {SNAP_WASM_PATH} not built (run `make -C examples/snap-hello-rs` first)" - ); - return; - } - - let local = tokio::task::LocalSet::new(); - local - .run_until(async { - let tx = register_snap_route().await; - let resp = dispatch(&tx, vec![("Accept".into(), SNAP_TYPE.into())]).await; - - assert_eq!(resp.status, 200, "expected HTTP 200"); - - // Headers required by the snap spec. - assert_eq!( - find_header(&resp, "Content-Type"), - Some(SNAP_TYPE), - "Content-Type must be the snap media type" - ); - assert_eq!( - find_header(&resp, "Vary"), - Some("Accept"), - "Vary: Accept is required by spec content-negotiation contract" - ); - assert_eq!( - find_header(&resp, "Cache-Control"), - Some("public, max-age=300"), - "Cache-Control should match the documented posture" - ); - assert_eq!( - find_header(&resp, "Access-Control-Allow-Origin"), - Some("*"), - "ACAO should be open" - ); - - // Body validates against the Farcaster Snap response shape. - let body = std::str::from_utf8(&resp.body).expect("UTF-8 body"); - let json: serde_json::Value = serde_json::from_str(body) - .unwrap_or_else(|e| panic!("response should parse as JSON: {e}\nbody: {body}")); - assert_eq!(json["version"], "2.0"); - // v1.5: root is a stack containing [greeting, ping_button] - assert_eq!(json["ui"]["root"], "root"); - assert_eq!(json["ui"]["elements"]["root"]["type"], "stack"); - let children = json["ui"]["elements"]["root"]["children"] - .as_array() - .expect("stack root must have children array"); - assert_eq!(children.len(), 2); - assert_eq!(json["ui"]["elements"]["greeting"]["type"], "text"); - assert_eq!( - json["ui"]["elements"]["greeting"]["props"]["content"], - "Hello, @stranger" - ); - assert_eq!(json["ui"]["elements"]["ping_button"]["type"], "button"); - assert_eq!( - json["ui"]["elements"]["ping_button"]["props"]["label"], - "Ping me" - ); - assert_eq!( - json["ui"]["elements"]["ping_button"]["on"]["press"]["action"], - "submit" - ); - // Target URL derived from Host header (none set in this test - // request -> falls back to the cell's hardcoded default - // master.wetware.run, which is fine here). - assert!( - json["ui"]["elements"]["ping_button"]["on"]["press"]["params"]["target"] - .as_str() - .is_some_and(|t| t.starts_with("https://") && t.ends_with("/snaps/hello")), - "submit target must be an https /snaps/hello URL" - ); - }) - .await; -} - -/// Plain browser / link previewer / crawler: no snap Accept. -/// Cell must return HTML + a `Link rel="alternate"` header pointing -/// at the snap representation. Spec citizenship per /snap/http-headers. -#[tokio::test(flavor = "current_thread")] -async fn snap_cell_without_snap_accept_returns_html_with_link_alternate() { - let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - if !snap_wasm_exists() { - eprintln!( - "skipping: {SNAP_WASM_PATH} not built (run `make -C examples/snap-hello-rs` first)" - ); - return; - } - - let local = tokio::task::LocalSet::new(); - local - .run_until(async { - let tx = register_snap_route().await; - let resp = dispatch(&tx, vec![("Accept".into(), "text/html".into())]).await; - - assert_eq!(resp.status, 200, "expected HTTP 200"); - - // HTML posture's required headers. - let ct = find_header(&resp, "Content-Type").expect("Content-Type missing"); - assert!( - ct.starts_with("text/html"), - "Content-Type should be text/html, got {ct:?}" - ); - assert_eq!(find_header(&resp, "Vary"), Some("Accept")); - assert_eq!( - find_header(&resp, "Cache-Control"), - Some("public, max-age=300") - ); - assert_eq!(find_header(&resp, "Access-Control-Allow-Origin"), Some("*")); - - // The Link header is the protocol-discovery hook for snap-aware - // clients fetching with a non-snap Accept. Must point at the snap - // type (empty `<>` resolves to current URL per RFC 3986). - let link = find_header(&resp, "Link").expect("Link header missing"); - assert!( - link.contains("rel=\"alternate\""), - "Link must declare rel=alternate, got {link:?}" - ); - assert!( - link.contains(SNAP_TYPE), - "Link must reference the snap media type, got {link:?}" - ); - - // Body is HTML and mentions the snap title (lightly — we don't - // overfit to the exact placeholder text). - let body = std::str::from_utf8(&resp.body).expect("UTF-8 body"); - assert!( - body.contains(""), - "HTML fallback body should be a real HTML document" - ); - }) - .await; -} - -/// Empty Accept header (some bare crawlers, naked curl) — same as -/// no-snap-Accept path. This isn't a separate posture; it's a sanity -/// check that the negotiation defaults to the HTML fallback rather -/// than panicking or returning snap-JSON to a non-Farcaster client. -#[tokio::test(flavor = "current_thread")] -async fn snap_cell_with_empty_accept_returns_html_fallback() { - let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - if !snap_wasm_exists() { - eprintln!( - "skipping: {SNAP_WASM_PATH} not built (run `make -C examples/snap-hello-rs` first)" - ); - return; - } - - let local = tokio::task::LocalSet::new(); - local - .run_until(async { - let tx = register_snap_route().await; - let resp = dispatch(&tx, Vec::new()).await; - - assert_eq!(resp.status, 200); - let ct = find_header(&resp, "Content-Type").expect("Content-Type missing"); - assert!( - ct.starts_with("text/html"), - "empty Accept should default to HTML fallback, got {ct:?}" - ); - assert!(find_header(&resp, "Link").is_some(), "Link header expected"); - }) - .await; -} - -/// JFS-verified viewer-aware path. With a `verified_snap` payload -/// claiming FID 12345, the listener emits `X_SNAP_FID_CLAIMED=12345` -/// as a CGI env var, which the cell reads via `viewer_greeting()` -/// and renders as `Hello, FID #12345`. -#[tokio::test(flavor = "current_thread")] -async fn snap_cell_with_verified_jfs_renders_fid_aware_greeting() { - let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - if !snap_wasm_exists() { - eprintln!( - "skipping: {SNAP_WASM_PATH} not built (run `make -C examples/snap-hello-rs` first)" - ); - return; - } - - let local = tokio::task::LocalSet::new(); - local - .run_until(async { - let tx = register_snap_route().await; - - // Synthesize a verified payload — the listener has already - // done the JFS crypto check upstream; we're testing the - // env-var passthrough + cell rendering. - let verified = ww::rpc::jfs::VerifiedJfs { - payload: ww::rpc::jfs::JfsPayload { - fid: 12345, - inputs: serde_json::json!({}), - audience: "https://master.wetware.run".to_string(), - timestamp: 1_700_000_000, - user: serde_json::json!({"fid": 12345}), - surface: serde_json::json!({"type": "standalone"}), - }, - payload_b64url: "stub-b64url-passthrough".to_string(), - }; - - let resp = dispatch_full( - &tx, - "GET", - vec![("Accept".into(), SNAP_TYPE.into())], - Vec::new(), - Some(verified), - ) - .await; - - assert_eq!(resp.status, 200); - let body = std::str::from_utf8(&resp.body).expect("UTF-8 body"); - let json: serde_json::Value = - serde_json::from_str(body).expect("snap-JSON should parse"); - assert_eq!( - json["ui"]["elements"]["greeting"]["props"]["content"], "Hello, FID #12345", - "viewer-aware greeting should render the verified FID, got body: {body}" - ); - }) - .await; -} - -/// POST request returns the snap-JSON UI tree with a fresh timestamp -/// (the snap spec's submit-action contract: server returns the next -/// UI state). v1.5 adds `— pinged at UTC` to the greeting so -/// each click visibly mutates the rendered text. -#[tokio::test(flavor = "current_thread")] -async fn snap_cell_post_returns_snap_ack_with_timestamp() { - let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - if !snap_wasm_exists() { - eprintln!( - "skipping: {SNAP_WASM_PATH} not built (run `make -C examples/snap-hello-rs` first)" - ); - return; - } - - let local = tokio::task::LocalSet::new(); - local - .run_until(async { - let tx = register_snap_route().await; - let resp = dispatch_full( - &tx, - "POST", - vec![("Accept".into(), SNAP_TYPE.into())], - Vec::new(), - None, - ) - .await; - - assert_eq!(resp.status, 200); - assert_eq!( - find_header(&resp, "Content-Type"), - Some(SNAP_TYPE), - "POST ack must use the snap media type" - ); - // POST is per-viewer (stamped with verified FID + timestamp); - // must NOT be cacheable upstream. - assert_eq!( - find_header(&resp, "Cache-Control"), - Some("private, no-store"), - "POST responses are per-viewer; must be no-store" - ); - let body = std::str::from_utf8(&resp.body).expect("UTF-8 body"); - let json: serde_json::Value = - serde_json::from_str(body).expect("POST body must parse as snap JSON"); - assert_eq!(json["version"], "2.0"); - assert_eq!(json["ui"]["root"], "root"); - // Anonymous POST (no JFS verified): greeting still includes - // the timestamp marker even without a FID. - let content = json["ui"]["elements"]["greeting"]["props"]["content"] - .as_str() - .expect("greeting content must be a string"); - assert!( - content.starts_with("Hello, @stranger — pinged at "), - "anonymous POST should still timestamp; got {content:?}" - ); - assert!( - content.ends_with(" UTC (unix)"), - "POST timestamp suffix expected; got {content:?}" - ); - }) - .await; -} - -/// POST with a JFS-verified payload renders `Hello, FID #N — pinged -/// at `. Combines viewer-awareness with the v1.5 timestamp -/// dynamism — this is the path real Farcaster client button-presses -/// will exercise. -#[tokio::test(flavor = "current_thread")] -async fn snap_cell_post_with_verified_jfs_renders_fid_and_timestamp() { - let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - if !snap_wasm_exists() { - eprintln!( - "skipping: {SNAP_WASM_PATH} not built (run `make -C examples/snap-hello-rs` first)" - ); - return; - } - - let local = tokio::task::LocalSet::new(); - local - .run_until(async { - let tx = register_snap_route().await; - - let verified = ww::rpc::jfs::VerifiedJfs { - payload: ww::rpc::jfs::JfsPayload { - fid: 7777, - inputs: serde_json::json!({}), - audience: "https://master.wetware.run".to_string(), - timestamp: 1_700_000_000, - user: serde_json::json!({"fid": 7777}), - surface: serde_json::json!({"type": "standalone"}), - }, - payload_b64url: "stub-b64url-passthrough".to_string(), - }; - - let resp = dispatch_full( - &tx, - "POST", - vec![("Accept".into(), SNAP_TYPE.into())], - Vec::new(), - Some(verified), - ) - .await; - - assert_eq!(resp.status, 200); - let body = std::str::from_utf8(&resp.body).expect("UTF-8 body"); - let json: serde_json::Value = - serde_json::from_str(body).expect("snap-JSON should parse"); - let content = json["ui"]["elements"]["greeting"]["props"]["content"] - .as_str() - .expect("content must be a string"); - assert!( - content.starts_with("Hello, FID #7777 — pinged at "), - "POST with verified JFS should render FID + timestamp; got {content:?}" - ); - }) - .await; -} diff --git a/tests/status_cell_e2e.rs b/tests/status_cell_e2e.rs index 15625b82..d05605df 100644 --- a/tests/status_cell_e2e.rs +++ b/tests/status_cell_e2e.rs @@ -102,7 +102,6 @@ async fn status_cell_serves_json_with_non_null_peer_id() { &[], // no extra HTTP headers "localhost", 2080, - None, // no JFS-verified snap payload ); let mut spawn_req = executor.spawn_request(); diff --git a/tests/status_cell_http_listener_e2e.rs b/tests/status_cell_http_listener_e2e.rs index 2399748c..117b2784 100644 --- a/tests/status_cell_http_listener_e2e.rs +++ b/tests/status_cell_http_listener_e2e.rs @@ -150,7 +150,6 @@ async fn status_cell_via_http_listener_with_extra_caps_returns_non_null_peer_id( query: String::new(), headers: Vec::new(), body: Vec::new(), - verified_snap: None, response_tx, }; tx.send(cgi_req) diff --git a/tests/stdin_shutdown_integration.rs b/tests/stdin_shutdown_integration.rs index dfc48e3d..963f4eb5 100644 --- a/tests/stdin_shutdown_integration.rs +++ b/tests/stdin_shutdown_integration.rs @@ -156,12 +156,15 @@ async fn test_vat_connection_closes_stdin_on_peer_disconnect() { load_req.get().set_wasm(&wasm_clone); let load_resp = load_req.send().promise.await.unwrap(); let executor = load_resp.get().unwrap().get_executor().unwrap(); + let expected_schema_cid = + ww::rpc::schema_cid(membrane::schema_registry::HOST_SCHEMA); ww::rpc::vat_listener::handle_vat_connection_spawn( executor, Vec::new(), // no extra caps in test // Convert tokio duplex → futures-io via compat layer. bridge_stream.compat(), "test-protocol-cid", + &expected_schema_cid, ) .await });