diff --git a/CHANGELOG.md b/CHANGELOG.md index 5115fb5c..4d89b7bd 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 protocol now performs inline schema attestation before Cap'n Proto starts.** On `/ww/0.1.0/vat/{cid}`, listener writes a versioned schema-attestation preface (canonical `schema.Node` bytes) before RPC bootstrap; dialer verifies magic/version, canonicalizes payload, recomputes CID, and hard-fails on mismatch. `VatClient.dial` now returns typed schema from attested producer bytes (not caller-echoed schema input). - **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. @@ -599,6 +602,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/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..52a925c6 100644 --- a/capnp/system.capnp +++ b/capnp/system.capnp @@ -8,6 +8,17 @@ @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 PeerInfo { peerId @0 :Data; # libp2p peer ID, serialized. @@ -128,9 +139,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 (schema :Data) -> (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,7 +152,7 @@ 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. } } @@ -166,15 +177,14 @@ interface VatListener { } interface VatClient { - dial @0 (peer :Data, schema :Data) -> (cap :AnyPointer); + dial @0 (peer :Data, schema :Data) -> (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. # 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..460d1020 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,7 @@ 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) { + if !attenuated.policy.allow_methods.contains(&method) { return Err(error::permission_denied( &format!("method :{method} denied by attenuation policy on '{name}'"), None, @@ -2898,6 +3261,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 +3331,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] @@ -6640,6 +7069,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/src/lib.rs b/crates/rpc/src/lib.rs index 458ff868..45d4b0d4 100644 --- a/crates/rpc/src/lib.rs +++ b/crates/rpc/src/lib.rs @@ -96,6 +96,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. @@ -515,9 +542,22 @@ impl system_capnp::process::Server for ProcessImpl { fn bootstrap( self: capnp::capability::Rc, - _params: system_capnp::process::BootstrapParams, + params: system_capnp::process::BootstrapParams, mut results: system_capnp::process::BootstrapResults, ) -> impl std::future::Future> + 'static { + let schema_bytes = match params.get() { + Ok(p) => match p.get_schema() { + Ok(schema) => schema.to_vec(), + Err(e) => return Promise::err(capnp::Error::from(e)), + }, + Err(e) => return Promise::err(capnp::Error::from(e)), + }; + if schema_bytes.is_empty() { + return Promise::err(capnp::Error::failed( + "process.bootstrap schema must not be empty".into(), + )); + } + let cap = self.bootstrap_cap.clone(); Promise::from_future(async move { let cap = cap.ok_or_else(|| { @@ -525,7 +565,18 @@ impl system_capnp::process::Server for ProcessImpl { "process did not export a bootstrap capability via system::serve()".into(), ) })?; - results.get().init_cap().set_as_capability(cap.hook); + 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(()) }) } @@ -1060,8 +1111,14 @@ mod tests { 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 mut req = process.bootstrap_request(); + req.get().set_schema(membrane::schema_registry::HOST_SCHEMA); + 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 +1139,16 @@ 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 mut req = process.bootstrap_request(); + req.get().set_schema(membrane::schema_registry::HOST_SCHEMA); + 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() } @@ -1115,8 +1177,14 @@ mod tests { // 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 mut req = process.bootstrap_request(); + req.get().set_schema(membrane::schema_registry::HOST_SCHEMA); + 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(); @@ -1159,8 +1227,14 @@ mod tests { 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 mut req = process.bootstrap_request(); + req.get().set_schema(membrane::schema_registry::HOST_SCHEMA); + 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. @@ -1317,9 +1391,21 @@ mod tests { 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 mut req = process.bootstrap_request(); + req.get().set_schema(membrane::schema_registry::HOST_SCHEMA); + 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, _) = @@ -1352,9 +1438,21 @@ mod tests { ); 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 mut req = process.bootstrap_request(); + req.get().set_schema(membrane::schema_registry::HOST_SCHEMA); + 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); @@ -1388,9 +1486,21 @@ mod tests { ); 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 mut req = process.bootstrap_request(); + req.get().set_schema(membrane::schema_registry::HOST_SCHEMA); + 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); @@ -1433,9 +1543,21 @@ mod tests { ); 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 mut req = process.bootstrap_request(); + req.get().set_schema(membrane::schema_registry::HOST_SCHEMA); + 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); @@ -1582,7 +1704,7 @@ 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"); + req.get().set_schema(membrane::schema_registry::HOST_SCHEMA); let result = req.send().promise.await; assert!(result.is_err(), "invalid peer ID should error"); @@ -1616,7 +1738,7 @@ mod tests { let mut handler = req.get().init_handler(); handler.set_spawn(executor); } - req.get().set_schema(b"some schema"); + req.get().set_schema(membrane::schema_registry::HOST_SCHEMA); let result = req.send().promise.await; assert!(result.is_err(), "stale epoch should error"); @@ -1646,7 +1768,7 @@ mod tests { let mut req = dialer.dial_request(); req.get().set_peer(&peer_id.to_bytes()); - req.get().set_schema(b"some schema"); + req.get().set_schema(membrane::schema_registry::HOST_SCHEMA); let result = req.send().promise.await; assert!(result.is_err(), "stale epoch should error"); @@ -1674,7 +1796,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(); @@ -1769,7 +1891,7 @@ mod tests { let mut handler = req.get().init_handler(); handler.set_spawn(executor); } - req.get().set_schema(b"valid schema bytes"); + req.get().set_schema(membrane::schema_registry::HOST_SCHEMA); let result = req.send().promise.await; assert!( diff --git a/crates/rpc/src/vat_client.rs b/crates/rpc/src/vat_client.rs index 1dc0fdbe..6382236c 100644 --- a/crates/rpc/src/vat_client.rs +++ b/crates/rpc/src/vat_client.rs @@ -49,6 +49,7 @@ impl system_capnp::vat_client::Server for VatClientImpl { if schema_bytes.is_empty() { return Promise::err(capnp::Error::failed("schema must not be empty".into())); } + let schema_bytes = pry!(super::canonicalize_schema_bytes(&schema_bytes)); let peer_id = pry!(PeerId::from_bytes(&peer_bytes) .map_err(|e| capnp::Error::failed(format!("invalid peer ID: {e}")))); @@ -82,18 +83,14 @@ 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. - let super::vat_dial::VatDial { bootstrap, driver } = - super::vat_dial::connect::<_, capnp::capability::Client>(stream); + // Verify producer-sourced schema attestation first, then start + // Cap'n Proto RPC on the same stream. + let (super::vat_dial::VatDial { bootstrap, driver }, attested_schema) = + super::vat_dial::connect_with_schema_attestation::<_, capnp::capability::Client>( + stream, + &protocol_cid, + ) + .await?; // The driver runs detached. Cap'n Proto refcounting handles // shutdown: when the guest drops all capabilities obtained from @@ -121,7 +118,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(&attested_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 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_dial.rs b/crates/rpc/src/vat_dial.rs index d5676e3d..ffc18815 100644 --- a/crates/rpc/src/vat_dial.rs +++ b/crates/rpc/src/vat_dial.rs @@ -103,7 +103,11 @@ use capnp::capability::FromClientHook; use capnp_rpc::rpc_twoparty_capnp::Side; use capnp_rpc::twoparty::VatNetwork; use capnp_rpc::RpcSystem; -use futures::io::{AsyncRead, AsyncReadExt, AsyncWrite}; +use futures::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +const SCHEMA_ATTEST_MAGIC: &[u8; 4] = b"WWSC"; +const SCHEMA_ATTEST_VERSION: u8 = 1; +const MAX_SCHEMA_ATTEST_BYTES: usize = 1024 * 1024; // 1 MiB hard cap /// A bootstrapped Cap'n Proto vat connection. /// @@ -177,6 +181,141 @@ where } } +fn map_io_error(op: &str, e: std::io::Error) -> capnp::Error { + capnp::Error::failed(format!("schema attestation {op} failed: {e}")) +} + +/// Write the schema-attestation preface for vat streams. +/// +/// Frame format: +/// - 4 bytes magic: `WWSC` +/// - 1 byte version: currently `1` +/// - 4 bytes big-endian schema length +/// - schema bytes (canonical `schema.Node`) +pub async fn write_schema_attestation( + stream: &mut S, + schema_bytes: &[u8], +) -> Result<(), capnp::Error> +where + S: AsyncWrite + Unpin, +{ + if schema_bytes.is_empty() { + return Err(capnp::Error::failed( + "schema attestation payload must not be empty".into(), + )); + } + if schema_bytes.len() > MAX_SCHEMA_ATTEST_BYTES { + return Err(capnp::Error::failed(format!( + "schema attestation payload too large: {} bytes (max {})", + schema_bytes.len(), + MAX_SCHEMA_ATTEST_BYTES + ))); + } + + stream + .write_all(SCHEMA_ATTEST_MAGIC) + .await + .map_err(|e| map_io_error("write magic", e))?; + stream + .write_all(&[SCHEMA_ATTEST_VERSION]) + .await + .map_err(|e| map_io_error("write version", e))?; + stream + .write_all(&(schema_bytes.len() as u32).to_be_bytes()) + .await + .map_err(|e| map_io_error("write length", e))?; + stream + .write_all(schema_bytes) + .await + .map_err(|e| map_io_error("write payload", e))?; + stream.flush().await.map_err(|e| map_io_error("flush", e))?; + + Ok(()) +} + +/// Read and validate the schema-attestation preface. +/// +/// Returns canonicalized schema bytes if: +/// - frame parses correctly, +/// - schema payload canonicalizes, +/// - `CIDv1(raw, blake3(schema))` matches `expected_cid`. +pub async fn read_and_verify_schema_attestation( + stream: &mut S, + expected_cid: &str, +) -> Result, capnp::Error> +where + S: AsyncRead + Unpin, +{ + let mut magic = [0u8; 4]; + stream + .read_exact(&mut magic) + .await + .map_err(|e| map_io_error("read magic", e))?; + if &magic != SCHEMA_ATTEST_MAGIC { + return Err(capnp::Error::failed( + "invalid vat schema attestation magic".into(), + )); + } + + let mut version = [0u8; 1]; + stream + .read_exact(&mut version) + .await + .map_err(|e| map_io_error("read version", e))?; + if version[0] != SCHEMA_ATTEST_VERSION { + return Err(capnp::Error::failed(format!( + "unsupported vat schema attestation version {} (expected {})", + version[0], SCHEMA_ATTEST_VERSION + ))); + } + + let mut len = [0u8; 4]; + stream + .read_exact(&mut len) + .await + .map_err(|e| map_io_error("read length", e))?; + let len = u32::from_be_bytes(len) as usize; + if len == 0 { + return Err(capnp::Error::failed( + "vat schema attestation payload must not be empty".into(), + )); + } + if len > MAX_SCHEMA_ATTEST_BYTES { + return Err(capnp::Error::failed(format!( + "vat schema attestation payload too large: {len} bytes (max {MAX_SCHEMA_ATTEST_BYTES})" + ))); + } + + let mut schema_bytes = vec![0u8; len]; + stream + .read_exact(&mut schema_bytes) + .await + .map_err(|e| map_io_error("read payload", e))?; + + let canonical = super::canonicalize_schema_bytes(&schema_bytes)?; + let attested_cid = super::schema_cid(&canonical); + if attested_cid != expected_cid { + return Err(capnp::Error::failed(format!( + "vat schema attestation CID mismatch: expected {expected_cid}, got {attested_cid}" + ))); + } + Ok(canonical) +} + +/// Open a vat connection and verify producer-sourced schema attestation +/// before starting Cap'n Proto RPC. +pub async fn connect_with_schema_attestation( + mut stream: S, + expected_cid: &str, +) -> Result<(VatDial, Vec), capnp::Error> +where + S: AsyncRead + AsyncWrite + Unpin + 'static, + C: FromClientHook, +{ + let canonical_schema = read_and_verify_schema_attestation(&mut stream, expected_cid).await?; + Ok((connect(stream), canonical_schema)) +} + #[cfg(test)] mod tests { use super::*; @@ -187,7 +326,7 @@ mod tests { use membrane::system_capnp; use tokio::io; use tokio::sync::mpsc; - use tokio_util::compat::TokioAsyncWriteCompatExt; + use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; /// Helper: spin up a server-side `host::Client` over a duplex pair and /// return the *client-side* half of the duplex for the test to dial. @@ -307,4 +446,57 @@ mod tests { }) .await; } + + #[tokio::test] + async fn schema_attestation_round_trip_verifies_cid() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let schema = membrane::schema_registry::HOST_SCHEMA.to_vec(); + let expected_cid = crate::schema_cid(&schema); + let (reader, writer) = io::duplex(8 * 1024); + + tokio::task::spawn_local(async move { + let mut writer = writer.compat_write(); + write_schema_attestation(&mut writer, &schema) + .await + .unwrap(); + }); + + let mut reader = reader.compat(); + let got = read_and_verify_schema_attestation(&mut reader, &expected_cid) + .await + .expect("attestation should verify"); + assert_eq!(got, membrane::schema_registry::HOST_SCHEMA); + }) + .await; + } + + #[tokio::test] + async fn schema_attestation_rejects_cid_mismatch() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let schema = membrane::schema_registry::HOST_SCHEMA.to_vec(); + let wrong_cid = crate::schema_cid(membrane::schema_registry::RUNTIME_SCHEMA); + let (reader, writer) = io::duplex(8 * 1024); + + tokio::task::spawn_local(async move { + let mut writer = writer.compat_write(); + write_schema_attestation(&mut writer, &schema) + .await + .unwrap(); + }); + + let mut reader = reader.compat(); + let err = read_and_verify_schema_attestation(&mut reader, &wrong_cid) + .await + .expect_err("CID mismatch should fail"); + assert!( + format!("{err}").contains("CID mismatch"), + "unexpected error: {err}" + ); + }) + .await; + } } diff --git a/crates/rpc/src/vat_listener.rs b/crates/rpc/src/vat_listener.rs index 12bf6092..f0fc9047 100644 --- a/crates/rpc/src/vat_listener.rs +++ b/crates/rpc/src/vat_listener.rs @@ -59,6 +59,7 @@ impl system_capnp::vat_listener::Server for VatListenerImpl { "schema bytes must not be empty".into(), )); } + let schema_bytes = pry!(super::canonicalize_schema_bytes(&schema_bytes)); let protocol_cid = super::schema_cid(&schema_bytes); let stream_protocol = pry!(super::schema_protocol(&protocol_cid)); @@ -85,16 +86,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(capnp::Error::from(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 +142,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 schema_bytes = schema_bytes.clone(); let caps = extra_caps.clone(); tokio::task::spawn_local(async move { let _handle_span = tracing::info_span!( @@ -131,7 +150,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, + &schema_bytes, + ) + .await { tracing::error!("Vat cell connection error: {e}"); } @@ -151,7 +177,33 @@ 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(capnp::Error::from(e)), + }; + let served_root = match served_schema.get_root() { + Ok(root) => root, + Err(e) => return Promise::err(capnp::Error::from(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(), + )) + } + }; + if served_schema_bytes != schema_bytes { + return Promise::err(capnp::Error::failed( + "vat-listener.listen schema must match handler.serve typed schema".into(), + )); + } // Accept loop: for each incoming connection, bootstrap with the persistent cap. let mut epoch_rx = self.guard.receiver.clone(); @@ -173,13 +225,14 @@ 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 schema_bytes = 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, &schema_bytes).await { tracing::error!("Vat serve connection error: {e}"); } @@ -222,8 +275,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, + schema_bytes: &[u8], ) -> 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 +314,11 @@ 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 mut req = process.bootstrap_request(); + req.get().set_schema(&schema_bytes); + req.send().promise + }) .await { Ok(Ok(resp)) => resp, @@ -280,10 +335,10 @@ 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()) - { + let bootstrap_cap: capnp::capability::Client = match bootstrap_resp.get().and_then(|r| { + let typed = r.get_typed()?; + typed.get_cap().get_as_capability() + }) { Ok(cap) => cap, Err(e) => { let _ = stdin.close_request().send().promise.await; @@ -291,12 +346,16 @@ pub async fn handle_vat_connection_spawn( } }; - // 4. Bridge: serve the cell's cap to the remote peer over the libp2p stream. + // 4. Write schema attestation first, then start Cap'n Proto RPC. + let mut stream = stream; + super::vat_dial::write_schema_attestation(&mut stream, schema_bytes).await?; + + // 5. Bridge: serve the cell's cap to the remote peer over the libp2p stream. let (reader, writer) = Box::pin(stream).split(); let network = VatNetwork::new(reader, writer, Side::Server, Default::default()); let peer_rpc = RpcSystem::new(Box::new(network), Some(bootstrap_cap)); - // 5. Drive the peer RPC system and cell process concurrently. + // 6. Drive the peer RPC system and cell process concurrently. // When EITHER side finishes (peer disconnects OR cell exits), // we tear down both to avoid serving a dead capability or // keeping a cell alive with no peer. @@ -330,9 +389,13 @@ 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 mut stream = stream; + super::vat_dial::write_schema_attestation(&mut stream, schema_bytes).await?; + let (reader, writer) = Box::pin(stream).split(); let network = VatNetwork::new(reader, writer, Side::Server, Default::default()); let peer_rpc = RpcSystem::new(Box::new(network), Some(bootstrap_cap)); diff --git a/doc/api/wasm-guest.md b/doc/api/wasm-guest.md index b0f950ac..fe3530fc 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` | `(schema: Data) -> (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, 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` (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, schema: Data) -> (typed: TypedCap)` | Open connection to peer on `/ww/0.1.0/vat/{cid}`. Bootstrap RPC and return remote capability plus schema metadata. | ## WASM Custom Sections diff --git a/doc/images.md b/doc/images.md index 1e4ffc8e..143a5727 100644 --- a/doc/images.md +++ b/doc/images.md @@ -8,12 +8,50 @@ 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). +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/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/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/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/src/executor.rs b/src/executor.rs index 6a3c68bf..231fa583 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,72 @@ impl Cell { } } +async fn wait_for_export_policy_ready( + membrane: &GuestMembrane, + timeout: Duration, +) -> std::result::Result<(), String> { + let started = Instant::now(); + 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(Duration::from_millis(100)).await; + } +} + +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 +845,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/std/kernel/src/lib.rs b/std/kernel/src/lib.rs index 1216690f..0d9b9524 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,2402 @@ 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 method in iface.get_methods()?.iter() { + let name = method + .get_name()? + .to_str() + .map_err(|e| capnp::Error::failed(e.to_string()))? + .to_string(); + let id = method.get_code_order(); + 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 schema = match p.get_schema() { + 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(); + { + 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)), + } + } + out.reborrow().set_schema(schema); + 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, schema) = 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 schema = match p.get_schema() { + Ok(v) => v.to_vec(), + Err(e) => return capnp::capability::Promise::err(capnp::Error::from(e)), + }; + (peer, schema) + } + 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); + req.get().set_schema(&schema); + 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(); + let schema = match params.get() { + Ok(p) => match p.get_schema() { + 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.bootstrap_request(); + req.get().set_schema(&schema); + 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, + "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 +2472,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 +2615,6 @@ struct Session { cwd: String, } - // --------------------------------------------------------------------------- // Cap extraction — get type-erased capnp Client from Val::Cap.inner // --------------------------------------------------------------------------- @@ -207,6 +2657,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())))) @@ -239,36 +2698,162 @@ fn eval_load(args: &[Val]) -> Result { let path = match args.first() { Some(Val::Str(s)) => s.clone(), - _ => return Err("(load \"\")".into()), - }; - // Resolve relative to WASI root — the host mounts the merged image at `/`. - let resolved = if path.starts_with('/') { - path.clone() - } else { - format!("/{path}") + _ => return Err("(load \"\")".into()), + }; + // Resolve relative to WASI root — the host mounts the merged image at `/`. + let resolved = if path.starts_with('/') { + path.clone() + } else { + format!("/{path}") + }; + + // Return cached bytes if already loaded. + let cached = CACHE.with(|c| c.borrow().get(&resolved).cloned()); + if let Some(bytes) = cached { + return Ok(Val::Bytes(bytes)); + } + + let bytes = + std::fs::read(&resolved).map_err(|e| Val::from(format!("load: {resolved}: {e}")))?; + CACHE.with(|c| c.borrow_mut().insert(resolved, bytes.clone())); + Ok(Val::Bytes(bytes)) +} + +fn eval_cd(args: &[Val], ctx: &RefCell) -> Result { + let path = match args.first() { + Some(Val::Str(s)) => s.clone(), + Some(Val::Sym(s)) => s.clone(), + None => "/".to_string(), + _ => return Err("(cd \"\")".into()), + }; + ctx.borrow_mut().cwd = path; + 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 = root.trim_end_matches('/'); + 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 entries = std::fs::read_dir(&resolved) + .map_err(|e| Val::from(format!("list-dir: {resolved}: {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 {resolved}: {e}"); + continue; + } + }; + let metadata = match std::fs::metadata(entry.path()) { + Ok(metadata) => metadata, + Err(e) => { + log::warn!( + "list-dir: skipping entry in {resolved} (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 {resolved}"); + } + } else { + log::debug!("list-dir: skipping non-file entry in {resolved}"); + } + } + 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()), }; - - // Return cached bytes if already loaded. - let cached = CACHE.with(|c| c.borrow().get(&resolved).cloned()); - if let Some(bytes) = cached { - return Ok(Val::Bytes(bytes)); - } - - let bytes = - std::fs::read(&resolved).map_err(|e| Val::from(format!("load: {resolved}: {e}")))?; - CACHE.with(|c| c.borrow_mut().insert(resolved, bytes.clone())); - Ok(Val::Bytes(bytes)) + let resolved = resolve_kernel_builtin_path(&path) + .map_err(|e| Val::from(format!("path-is-dir: {path}: {e}")))?; + Ok(Val::Bool(std::path::Path::new(&resolved).is_dir())) } -fn eval_cd(args: &[Val], ctx: &RefCell) -> Result { - let path = match args.first() { - Some(Val::Str(s)) => s.clone(), - Some(Val::Sym(s)) => s.clone(), - None => "/".to_string(), - _ => return Err("(cd \"\")".into()), +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()), }; - ctx.borrow_mut().cwd = path; - Ok(Val::Nil) + + 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())) } // --------------------------------------------------------------------------- @@ -697,9 +3282,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}"))), @@ -1111,42 +3698,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 +3742,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 +3805,58 @@ 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 root = ww_root.trim_end_matches('/'); + let rooted = format!("{root}/{rel_path}"); + // Fail-closed with WW_ROOT: do not silently fall back to host /etc + // when the rooted file is missing. + return std::path::Path::new(&rooted).exists().then_some(rooted); + } + + 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 = root.trim_end_matches('/'); + capnp::Error::failed(format!( + "boot failed: missing required {root}/etc/init.glia" + )) } - } - }; - - 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 +4052,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 +4091,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 +4102,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 +4178,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()?; - let dispatch = build_dispatch(); - let mut env = Env::new(); + // Iterate the caps list to find capabilities by name. + let caps = results.get_caps()?; - // 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 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 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 +4325,6 @@ fn run_impl() { if let Err(e) = result { log::error!("kernel error: {e}"); } - } Ok(()) } @@ -1739,72 +4336,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 +4382,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 +4557,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 +4576,48 @@ 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" + ); + } + // =================================================================== // Integration tests — dispatch handlers against capnp-rpc stub servers // =================================================================== @@ -1952,6 +4681,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 +4829,45 @@ mod tests { } } - // --- Stub StreamDialer + VatClient (unused, just satisfy network result) --- - - struct TestStreamDialer; - impl system_capnp::stream_dialer::Server for TestStreamDialer {} - - struct TestVatClient; - impl system_capnp::vat_client::Server for TestVatClient {} + // --- Stub StreamDialer + VatClient --- + + struct TestStreamDialer; + impl system_capnp::stream_dialer::Server for TestStreamDialer {} + + struct 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 schema = capnp_rpc::pry!(p.get_schema()); + if schema.is_empty() { + return Promise::err(capnp::Error::failed("schema is required".into())); + } + let host: system_capnp::host::Client = capnp_rpc::new_client(TestHost); + let aligned = bytes_to_aligned_words(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) --- @@ -2231,8 +4993,16 @@ mod tests { runtime: runtime.clone(), }); let state: Rc>> = Rc::new(RefCell::new(Some(upstream))); - - let bootstrap: Membrane = capnp_rpc::new_client(KernelBootstrap { membrane: state }); + 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() @@ -2254,17 +5024,90 @@ mod tests { .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 bootstrap: Membrane = capnp_rpc::new_client(KernelBootstrap { membrane: state }); + 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("not ready"), + 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}" ); } @@ -2273,6 +5116,462 @@ mod tests { .await; } + #[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(()) + } + } + + let upstream: Membrane = capnp_rpc::new_client(HostOnlyMembrane { + host: capnp_rpc::new_client(TestHost), + }); + let state: Rc>> = Rc::new(RefCell::new(Some(upstream))); + + 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}" + ); + } + } + }) + .await; + } + + #[tokio::test] + async fn test_kernel_bootstrap_enforces_recursive_policy_on_vat_client_dial_cap() { + 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(()) + } + } + + let upstream: Membrane = capnp_rpc::new_client(HostOnlyMembrane { + host: capnp_rpc::new_client(TestHost), + }); + 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 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() + .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 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); + dial_req.get().set_schema(schema_ids::HOST_SCHEMA); + 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_enforces_recursive_policy_on_process_bootstrap_cap() { + run_local(async { + 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 p = capnp_rpc::pry!(params.get()); + let schema = capnp_rpc::pry!(p.get_schema()); + if schema.is_empty() { + return Promise::err(capnp::Error::failed("schema required".into())); + } + let host: system_capnp::host::Client = capnp_rpc::new_client(TestHost); + let aligned = bytes_to_aligned_words(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 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 mut boot_req = process.bootstrap_request(); + boot_req.get().set_schema(schema_ids::HOST_SCHEMA); + 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!( + msg.contains("permission denied"), + "expected permission denied, got: {msg}" + ); + } + } + }) + .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 +5911,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 +6012,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 +6061,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 +6089,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 +6111,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()), @@ -2848,7 +6137,6 @@ 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 "{}"))"#, @@ -2878,7 +6166,6 @@ 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 "{}")) @@ -2908,36 +6195,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 +6304,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 +6342,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 +6385,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 +6513,10 @@ mod tests { "glia:defcap:v1", Rc::new(AttenuatedCapInner { base, - allow_methods: allow, + policy: AttenuationPolicy { + allow_methods: allow, + returns: BTreeMap::new(), + }, descriptor: descriptor.clone(), }), ); @@ -3207,19 +6527,12 @@ mod tests { #[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..e241ce6c 100644 --- a/tests/discovery_integration.rs +++ b/tests/discovery_integration.rs @@ -86,16 +86,23 @@ 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 mut req = process.bootstrap_request(); + req.get().set_schema(b"test-schema"); + 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..c66673a8 100644 --- a/tests/shell_e2e.rs +++ b/tests/shell_e2e.rs @@ -81,17 +81,24 @@ 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/stdin_shutdown_integration.rs b/tests/stdin_shutdown_integration.rs index dfc48e3d..134a5298 100644 --- a/tests/stdin_shutdown_integration.rs +++ b/tests/stdin_shutdown_integration.rs @@ -162,6 +162,7 @@ async fn test_vat_connection_closes_stdin_on_peer_disconnect() { // Convert tokio duplex → futures-io via compat layer. bridge_stream.compat(), "test-protocol-cid", + b"test-schema", ) .await });