From fb83e0c067605aac66edbb2a691921b9b8f0dc0b Mon Sep 17 00:00:00 2001 From: Surma Date: Fri, 6 Nov 2026 22:49:27 +0000 Subject: [PATCH 1/3] WIP T29: credential hook, pinned HTTP transport and guest networking --- Cargo.lock | 35 ++- host/Cargo.toml | 9 + host/src/exec.rs | 5 +- host/src/harness/environment.rs | 2 +- host/src/harness/mod.rs | 33 ++- host/src/process/http.rs | 385 ++++++++++++++++++++++++++++++++ host/src/process/mod.rs | 108 ++++++++- host/src/process/net.rs | 85 +++++++ host/src/process/services.rs | 2 +- host/tests/hook.rs | 332 +++++++++++++++++++++++++++ host/tests/hook_git.rs | 208 +++++++++++++++++ host/tests/hook_guest.rs | 64 ++++++ host/tests/net.rs | 125 +++++++++++ host/tests/process.rs | 2 +- 14 files changed, 1364 insertions(+), 31 deletions(-) create mode 100644 host/src/process/http.rs create mode 100644 host/src/process/net.rs create mode 100644 host/tests/hook.rs create mode 100644 host/tests/hook_git.rs create mode 100644 host/tests/hook_guest.rs create mode 100644 host/tests/net.rs diff --git a/Cargo.lock b/Cargo.lock index 7d50be6..81c69c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3245,6 +3245,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "if_chain" version = "1.0.3" @@ -4995,6 +5005,7 @@ dependencies = [ "anyhow", "async-trait", "base64 0.22.1", + "bytes", "chacha20poly1305", "clap", "deno_ast", @@ -5012,6 +5023,11 @@ dependencies = [ "hkdf", "hmac", "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "if-addrs", "openssl", "p256", "rsa", @@ -5027,6 +5043,8 @@ dependencies = [ "tempfile", "thiserror 2.0.21", "tokio", + "tokio-rustls", + "toml", "url", "uuid", "wasmtime", @@ -5035,6 +5053,7 @@ dependencies = [ "wat", "webauthn-authenticator-rs", "webauthn-rs", + "webpki-roots", "zeroize", ] @@ -5213,9 +5232,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +checksum = "9c187e0a369471c550af028cba56d4f4fa2a489ad28151ae95a4d340a2a8b782" dependencies = [ "serde_core", ] @@ -6391,9 +6410,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.8+spec-1.1.0" +version = "1.2.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +checksum = "d5755c51863cdac711f1956baf8eb9588e060755f7d3c4c979e70ea1e1f32cd7" dependencies = [ "indexmap", "serde_core", @@ -6406,9 +6425,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +checksum = "173f25c883cd2176a2d8212155638c0a60d484a9d84546d270866f40f56b6e49" dependencies = [ "serde_core", ] @@ -6436,9 +6455,9 @@ dependencies = [ [[package]] name = "toml_writer" -version = "1.1.3+spec-1.1.0" +version = "1.2.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" +checksum = "009dbb7700f11c55ef83aebbe4c0e9cf1f1ac0844bd8d6f00dc38f302026e13a" [[package]] name = "tower" diff --git a/host/Cargo.toml b/host/Cargo.toml index 88863a7..f495fc2 100644 --- a/host/Cargo.toml +++ b/host/Cargo.toml @@ -51,6 +51,15 @@ ssh-key = { version = "0.6.7", features = ["encryption", "ed25519", "ecdsa", "rs uuid = { version = "1.29.0", features = ["v4"] } webauthn-rs = "0.5" openssl = { version = "0.10.82", features = ["vendored"] } +hyper-util = { version = "0.1", features = ["tokio"] } +tokio-rustls = "0.26.6" +webpki-roots = "1.0.11" +if-addrs = "0.15.0" +toml = "1.2.1" +bytes = "1.12.1" +http-body = "1.1.0" +http-body-util = "0.1.5" +hyper = { version = "1.12.0", features = ["client", "http1", "server"] } [dev-dependencies] tempfile = "3" diff --git a/host/src/exec.rs b/host/src/exec.rs index cce79ec..21870ef 100644 --- a/host/src/exec.rs +++ b/host/src/exec.rs @@ -10,14 +10,13 @@ pub enum Command { } /// How to run it. `cwd` is a world path. `timeout` ends the command and all its processes. -/// `conversation` is the harness conversation that runs the command. Its processes and their -/// descendants carry it, so the host knows the caller (for example in the `wasi:http` hook). +/// `conversations` carries the nearest-first conversation chain. Child processes inherit it. #[derive(Clone, Debug, Default)] pub struct Options { pub cwd: String, pub env: Vec<(String, String)>, pub timeout: Option, - pub conversation: Option, + pub conversations: Vec, } /// The stream that one output chunk came from. diff --git a/host/src/harness/environment.rs b/host/src/harness/environment.rs index 8ed9ef0..d732cbc 100644 --- a/host/src/harness/environment.rs +++ b/host/src/harness/environment.rs @@ -88,7 +88,7 @@ pub fn op_exec( }; let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); let task = tokio::spawn(async move { - let result = exec.exec(command, Options { cwd, conversation, timeout: timeout.map(std::time::Duration::from_secs_f64), ..Default::default() }, &mut |stream, bytes| { let _ = sender.send(json!({"stream": if stream == Stream::Stdout {"stdout"} else {"stderr"}, "bytes": bytes})); }).await; + let result = exec.exec(command, Options { cwd, conversations: conversation.into_iter().collect(), timeout: timeout.map(std::time::Duration::from_secs_f64), ..Default::default() }, &mut |stream, bytes| { let _ = sender.send(json!({"stream": if stream == Stream::Stdout {"stdout"} else {"stderr"}, "bytes": bytes})); }).await; let _ = sender.send(match result { Ok(exit) => json!({"exitCode": exit}), Err(e) => json!({"error": e.to_string()}), diff --git a/host/src/harness/mod.rs b/host/src/harness/mod.rs index 246bb37..a70c774 100644 --- a/host/src/harness/mod.rs +++ b/host/src/harness/mod.rs @@ -92,12 +92,25 @@ deno_core::extension!( /// Start the console with the process implementation supplied by the host. pub async fn run_with_exec(world: World, exec: Arc) -> anyhow::Result<()> { + let cred = Arc::new(crate::cred::Cred::open(&world.host_dir)?); + run_with_shared_credentials(world, exec, cred).await +} +async fn run_with_shared_credentials( + world: World, + exec: Arc, + cred: Arc, +) -> anyhow::Result<()> { // Deno's unsynchronized operations require a current-thread Tokio runtime. tokio::task::spawn_blocking(move || { tokio::runtime::Builder::new_current_thread() .enable_all() .build()? - .block_on(run_module(world, exec, include_str!("../../js/bundle.js"))) + .block_on(run_module_with_credentials( + world, + exec, + cred, + include_str!("../../js/bundle.js"), + )) }) .await? .map_err(|error: anyhow::Error| { @@ -232,13 +245,19 @@ pub async fn run(path: &Path) -> anyhow::Result<()> { /// Start the console with the shared process table and an optional userland bundle. pub async fn run_with_userland(path: &Path, userland: Option<&Path>) -> anyhow::Result<()> { let world = World::open_with_userland(path, userland)?; - let runtime = crate::process::Runtime::new(World { - root: world.root.clone(), - host_dir: world.host_dir.clone(), - processes: world.processes.clone(), - })?; + let cred = Arc::new(crate::cred::Cred::open(&world.host_dir)?); + let runtime = crate::process::Runtime::new_with_hooks( + World { + root: world.root.clone(), + host_dir: world.host_dir.clone(), + processes: world.processes.clone(), + }, + cred.clone(), + crate::process::default_script(), + None, + )?; let services = crate::process::supervise(runtime.clone()); - let result = run_with_exec(world, Arc::new(runtime)).await; + let result = run_with_shared_credentials(world, Arc::new(runtime), cred).await; services.abort(); let _ = services.await; result diff --git a/host/src/process/http.rs b/host/src/process/http.rs new file mode 100644 index 0000000..2c3e9cd --- /dev/null +++ b/host/src/process/http.rs @@ -0,0 +1,385 @@ +//! Per-process credential hook and pinned-address HTTP transport. +//! Transport derives from Wasmtime 49's default sender, with a policy check before connect. +use crate::cred::{Caller, Cred, inject::Outcome, script::Script}; +use http_body_util::{BodyExt, Full}; +use std::sync::Arc; +use wasmtime_wasi_http::{Error, RequestOptions}; +use wasmtime_wasi_http::{WasiBody, WasiHttpHooks}; +type Io = Box> + Send>; +pub type Router = fn(&Caller, Request) -> Response; +pub(super) struct Hooks { + pub caller: Caller, + pub cred: Arc, + pub script: Arc, + pub policy: Arc, + pub router: Router, +} +pub fn whoami(caller: &Caller, request: Request) -> Response { + let (status, body) = if request.uri().path() == "/whoami" { + (200, serde_json::to_vec(caller).unwrap()) + } else { + ( + 404, + serde_json::to_vec(&serde_json::json!({"error":"unknown host API route"})).unwrap(), + ) + }; + Response::builder() + .status(status) + .header("content-type", "application/json") + .body(Bytes::from(body)) + .unwrap() +} +fn response(response: Response) -> (Response, Io) { + ( + response.map(|b| Full::new(b).map_err(|n| match n {}).boxed_unsync()), + Box::new(async { Ok(()) }), + ) +} +impl WasiHttpHooks for Hooks { + // Admit every scheme until the URI supplies the host name. + fn is_supported_scheme(&mut self, _: &http::uri::Scheme) -> bool { true } + fn send_request( + &mut self, + request: Request, + options: Option, + fut: Io, + ) -> Box, Io), Error>> + Send> { + drop(fut); + let caller = self.caller.clone(); + let cred = self.cred.clone(); + let script = self.script.clone(); + let policy = self.policy.clone(); + let router = self.router; + Box::new(async move { + let (parts, mut body) = request.into_parts(); + if parts + .uri + .host() + .is_some_and(|h| h.eq_ignore_ascii_case("host.sausage")) + { + let bytes = body.collect().await?.to_bytes(); + return Ok(response(router(&caller, Request::from_parts(parts, bytes)))); + } + if !matches!(parts.uri.scheme_str(), Some("http" | "https")) { + while let Some(frame) = body.frame().await { frame?; } + return Err(Error::HttpRequestUriInvalid); + } + let result = + tokio::task::spawn_blocking(move || cred.inject(parts, &caller, script.as_ref())) + .await; + let mut parts = match result { + Ok(Ok(Outcome::Forward(parts))) => parts, + outcome => { + // Drain without retaining uploads, so the guest can finish its output stream. + while let Some(frame) = body.frame().await { + frame?; + } + let (status, value) = match outcome { + Ok(Ok(Outcome::Denied { item, request })) => ( + 403, + serde_json::json!({"message": format!("Access to {item} needs approval. Request {request} awaits a decision."), "item": item, "request": request}), + ), + Ok(Err(e)) => (502, serde_json::json!({"error": e.to_string()})), + _ => ( + 502, + serde_json::json!({"error": "credential worker failed"}), + ), + }; + return Ok(response( + Response::builder() + .status(status) + .header("content-type", "application/json") + .body(Bytes::from(serde_json::to_vec(&value).unwrap())) + .unwrap(), + )); + } + }; + let authority = parts.uri.authority().ok_or(Error::HttpRequestUriInvalid)?; + if authority.as_str().contains('@') { + return Err(Error::HttpRequestUriInvalid); + } + parts.headers.insert( + http::header::HOST, + authority + .as_str() + .parse() + .map_err(|_| Error::HttpRequestUriInvalid)?, + ); + for value in parts.headers.values_mut() { + value.set_sensitive(true); + } + // Hyper otherwise discards unknown-length GET bodies before WASI finishes them. + if !parts.headers.contains_key(http::header::CONTENT_LENGTH) { + parts.headers.insert( + http::header::TRANSFER_ENCODING, + http::HeaderValue::from_static("chunked"), + ); + } + let (res, io) = + send_request(Request::from_parts(parts, body), options, &policy).await?; + Ok((res.map(BodyExt::boxed_unsync), Box::new(io) as Io)) + }) + } +} +use bytes::Bytes; +use core::future::poll_fn; +use core::pin::{Pin, pin}; +use core::task::{Poll, ready}; +use http::uri::Scheme; +use http::{Request, Response}; +use http_body::Body; +use std::time::Duration; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::net::TcpStream; + +trait TokioStream: AsyncRead + AsyncWrite + Send + Sync + Unpin + 'static { + fn boxed(self) -> Box + where + Self: Sized, + { + Box::new(self) + } +} +impl TokioStream for T where T: AsyncRead + AsyncWrite + Send + Sync + Unpin + 'static {} + +/// Preserve Wasmtime's HTTP/1 transport and request deadlines, but connect only to checked addresses. +/// The returned future drives the connection after the response headers arrive. +async fn send_request( + mut req: Request + Send + 'static>, + options: Option, + policy: &super::net::NetPolicy, +) -> Result< + ( + Response>, + impl Future> + Send, + ), + Error, +> { + let uri = req.uri().clone(); + let authority = uri.authority().ok_or(Error::HttpRequestUriInvalid)?; + let use_tls = uri.scheme() == Some(&Scheme::HTTPS); + let authority = if authority.port().is_some() { + authority.to_string() + } else { + let port = if use_tls { 443 } else { 80 }; + format!("{authority}:{port}") + }; + + let connect_timeout = options + .and_then(|r| r.connect_timeout) + .unwrap_or(Duration::from_secs(600)); + + let first_byte_timeout = options + .and_then(|r| r.first_byte_timeout) + .unwrap_or(Duration::from_secs(600)); + + let between_bytes_timeout = options + .and_then(|r| r.between_bytes_timeout) + .unwrap_or(Duration::from_secs(600)); + + let stream = match tokio::time::timeout(connect_timeout, async { + let addresses = policy.resolve(&uri).await?; + TcpStream::connect(addresses.as_slice()) + .await + .map_err(Error::Connect) + }) + .await + { + Ok(stream) => stream?, + Err(..) => return Err(Error::ConnectionTimeout), + }; + let stream = if use_tls { + // derived from https://github.com/rustls/rustls/blob/main/examples/src/bin/simpleclient.rs + let root_cert_store = rustls::RootCertStore { + roots: webpki_roots::TLS_SERVER_ROOTS.into(), + }; + let config = rustls::ClientConfig::builder() + .with_root_certificates(root_cert_store) + .with_no_client_auth(); + let connector = tokio_rustls::TlsConnector::from(std::sync::Arc::new(config)); + let domain = tls_server_name(&authority)?; + let stream = connector + .connect(domain, stream) + .await + .map_err(Error::Tls)?; + stream.boxed() + } else { + stream.boxed() + }; + let (mut sender, conn) = tokio::time::timeout( + connect_timeout, + hyper::client::conn::http1::Builder::new().handshake(hyper_util::rt::TokioIo::new(stream)), + ) + .await + .map_err(|_| Error::ConnectionTimeout)??; + + // Send origin-form URIs, because this connection does not use a proxy. + *req.uri_mut() = http::Uri::builder() + .path_and_query( + req.uri() + .path_and_query() + .map(|p| p.as_str()) + .unwrap_or("/"), + ) + .build() + .expect("comes from valid request"); + + let send = async move { + use core::task::Context; + + /// Wrapper around [hyper::body::Incoming] used to + /// account for request option timeout configuration + struct IncomingResponseBody { + incoming: hyper::body::Incoming, + timeout: tokio::time::Interval, + } + impl http_body::Body for IncomingResponseBody { + type Data = ::Data; + type Error = Error; + + fn poll_frame( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + match Pin::new(&mut self.as_mut().incoming).poll_frame(cx) { + Poll::Ready(None) => Poll::Ready(None), + Poll::Ready(Some(Err(err))) => { + let err = if err.is_timeout() { + Error::HttpResponseTimeout + } else { + Error::from(err) + }; + Poll::Ready(Some(Err(err))) + } + Poll::Ready(Some(Ok(frame))) => { + self.timeout.reset(); + Poll::Ready(Some(Ok(frame))) + } + Poll::Pending => { + ready!(self.timeout.poll_tick(cx)); + Poll::Ready(Some(Err(Error::ConnectionReadTimeout))) + } + } + } + fn is_end_stream(&self) -> bool { + self.incoming.is_end_stream() + } + fn size_hint(&self) -> http_body::SizeHint { + self.incoming.size_hint() + } + } + + let res = tokio::time::timeout(first_byte_timeout, sender.send_request(req)) + .await + .map_err(|_| Error::ConnectionReadTimeout)? + .map_err(Error::from)?; + // Tokio panics on an `interval` with a zero duration, so clamp it to a + // 1ns timeout which is sort of effectively zero anyway. + let between_bytes_timeout = if between_bytes_timeout == Duration::ZERO { + Duration::new(0, 1) + } else { + between_bytes_timeout + }; + let mut timeout = tokio::time::interval(between_bytes_timeout); + timeout.reset(); + Ok(res.map(|incoming| IncomingResponseBody { incoming, timeout })) + }; + let mut send = pin!(send); + let mut conn = Some(conn); + // Wait for response while driving connection I/O + let res = poll_fn(|cx| match send.as_mut().poll(cx) { + Poll::Ready(Ok(res)) => Poll::Ready(Ok(res)), + Poll::Ready(Err(err)) => Poll::Ready(Err(err)), + Poll::Pending => { + // Response is not ready, poll `hyper` connection to drive I/O if it has not completed yet + let Some(fut) = conn.as_mut() else { + // `hyper` connection already completed + return Poll::Pending; + }; + let res = ready!(Pin::new(fut).poll(cx)); + // `hyper` connection completed, record that to prevent repeated poll + conn = None; + match res { + // `hyper` connection has successfully completed, optimistically poll for response + Ok(()) => send.as_mut().poll(cx), + // `hyper` connection has failed, return the error + Err(err) => Poll::Ready(Err(Error::from(err))), + } + } + }) + .await?; + Ok((res, async move { + let Some(conn) = conn.take() else { + // `hyper` connection has already completed + return Ok(()); + }; + if let Err(err) = conn.await { + if err.is_timeout() { + return Err(Error::HttpResponseTimeout); + } + return Err(err.into()); + } + Ok(()) + })) +} + +/// Resolve the rustls [`ServerName`] used for TLS certificate verification from +/// an outbound request `authority`. +/// +/// `authority` is in `host:port` form, where an IPv6 `host` is wrapped in +/// brackets (for example `[::1]:443`). An IP literal is recognized by parsing +/// the whole authority as a [`SocketAddr`]; this handles the bracketed IPv6 +/// form, which splitting on the first `:` would truncate. Anything else is +/// treated as a host name, with the port stripped off before it is handed to +/// rustls. +/// +/// [`ServerName`]: rustls::pki_types::ServerName +/// [`SocketAddr`]: std::net::SocketAddr +pub(crate) fn tls_server_name( + authority: &str, +) -> Result, rustls::pki_types::InvalidDnsNameError> { + use rustls::pki_types::ServerName; + + if let Ok(addr) = authority.parse::() { + return Ok(ServerName::from(addr.ip())); + } + let host = match authority.split_once(':') { + Some((host, _port)) => host, + None => authority, + }; + Ok(ServerName::try_from(host)?.to_owned()) +} + +#[cfg(test)] +mod tls_server_name_tests { + use super::tls_server_name; + use rustls::pki_types::ServerName; + + #[test] + fn resolves_server_name_from_authority() { + // Host names keep their host and drop the port. + assert_eq!( + tls_server_name("example.com:443").unwrap(), + ServerName::try_from("example.com").unwrap() + ); + assert_eq!( + tls_server_name("example.com").unwrap(), + ServerName::try_from("example.com").unwrap() + ); + + // IP literals resolve to an `IpAddress` server name. The bracketed IPv6 + // form must not be truncated at the first `:`. + assert_eq!( + tls_server_name("127.0.0.1:80").unwrap(), + ServerName::from(std::net::Ipv4Addr::LOCALHOST) + ); + assert_eq!( + tls_server_name("[::1]:443").unwrap(), + ServerName::from(std::net::Ipv6Addr::LOCALHOST) + ); + assert_eq!( + tls_server_name("[2001:db8::1]:8443").unwrap(), + ServerName::from("2001:db8::1".parse::().unwrap()) + ); + } +} diff --git a/host/src/process/mod.rs b/host/src/process/mod.rs index 2db6095..c995588 100644 --- a/host/src/process/mod.rs +++ b/host/src/process/mod.rs @@ -46,6 +46,12 @@ mod bindings { } use bindings::sausage::process::host::{Host, HostChild, Request, Stdio}; mod cache; +mod http; +pub mod net; +/// Temporary default until T28 supplies the bounded QuickJS evaluator. +pub fn default_script() -> Arc { + Arc::new(crate::cred::v8::V8) +} pub use cache::configure as configure_cache; mod services; mod stdio; @@ -84,6 +90,10 @@ pub struct Runtime { entries: Mutex>, next: AtomicU32, alive: Arc, + cred: Arc, + script: Arc, + router: http::Router, + pub network: Arc, } impl Drop for Runtime { fn drop(&mut self) { @@ -98,17 +108,18 @@ struct State { pid: u32, command: u32, group: u32, - conversation: Option, + conversations: Vec, stdio: Io, preview1: Option, http: wasmtime_wasi_http::WasiHttpCtx, + hooks: http::Hooks, } impl wasmtime_wasi_http::WasiHttpView for State { fn http(&mut self) -> wasmtime_wasi_http::WasiHttpCtxView<'_> { wasmtime_wasi_http::WasiHttpCtxView { ctx: &mut self.http, table: &mut self.table, - hooks: Default::default(), + hooks: &mut self.hooks, } } } @@ -148,6 +159,37 @@ impl Runtime { Self::with_compiler(world, cache::Compiler::new(directory)?) } fn with_compiler(world: World, compiler: Arc) -> anyhow::Result> { + let cred = Arc::new(crate::cred::Cred::open(&world.host_dir)?); + Self::with_hooks(world, compiler, cred, default_script(), http::whoami) + } + /// Share credentials and the host API router with the host's other subsystems. + pub fn new_with_hooks( + world: World, + cred: Arc, + script: Arc, + router: Option< + fn( + &crate::cred::Caller, + ::http::Request, + ) -> ::http::Response, + >, + ) -> anyhow::Result> { + Self::with_hooks( + world, + cache::shared()?, + cred, + script, + router.unwrap_or(http::whoami), + ) + } + fn with_hooks( + world: World, + compiler: Arc, + cred: Arc, + script: Arc, + router: http::Router, + ) -> anyhow::Result> { + let network = Arc::new(net::NetPolicy::open(&world.host_dir)?); let engine = compiler.engine.clone(); let alive = Arc::new(AtomicBool::new(true)); let ticking = alive.clone(); @@ -161,6 +203,10 @@ impl Runtime { let runtime = Arc::new(Self { world, engine, + cred, + script, + router, + network, alive, compiler, cache: Mutex::new(Default::default()), @@ -200,7 +246,7 @@ impl Runtime { for path in paths { for candidate in [path.clone(), format!("{path}.wasm")] { match self.world.root.stat(&candidate, true).await { - Ok(stat) if !stat.directory => return Ok(candidate), + Ok(stat) if !stat.directory => return self.canonical_program(candidate).await, Ok(_) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(e.into()), @@ -209,6 +255,32 @@ impl Runtime { } anyhow::bail!("program not found: {name}") } + async fn canonical_program(&self, mut path: String) -> anyhow::Result { + for _ in 0..40 { + let mut prefix = String::new(); + let components: Vec<_> = path.trim_start_matches('/').split('/').collect(); + let mut target = None; + for (index, component) in components.iter().enumerate() { + prefix.push('/'); + prefix.push_str(component); + if self.world.root.stat(&prefix, false).await?.symlink { + let link = self.world.root.readlink(&prefix).await?; + let parent = prefix.rsplit_once('/').unwrap().0; + let resolved = sausage_vfs::resolve(parent, &link); + target = Some(sausage_vfs::resolve( + &resolved, + &components[index + 1..].join("/"), + )); + break; + } + } + match target { + Some(target) => path = target, + None => return Ok(path), + } + } + anyhow::bail!("too many program symlinks") + } fn kill_command(&self, command: u32) { for entry in self.entries.lock().unwrap().values() { if entry.command == command { @@ -241,7 +313,7 @@ impl Runtime { parent: u32, command: u32, inherited_group: u32, - conversation: Option, + conversations: Vec, inherited_io: Option, ) -> anyhow::Result { anyhow::ensure!(!request.argv.is_empty(), "empty argv"); @@ -326,9 +398,14 @@ impl Runtime { .stdin(stdio.input.clone()) .stdout(AsyncStdoutStream::new(8192, stdio.output.clone())) .stderr(AsyncStdoutStream::new(8192, stdio.error.clone())) - .allow_tcp(false) + .allow_tcp(true) .allow_udp(false) - .allow_ip_name_lookup(false); + .allow_ip_name_lookup(true); + let policy = self.network.clone(); + wasi.socket_addr_check(move |address, _| { + let allowed = policy.allows(address); + Box::pin(async move { allowed }) + }); let mut store = Store::new( &self.engine, State { @@ -339,10 +416,21 @@ impl Runtime { pid, command, group, - conversation: conversation.clone(), + conversations: conversations.clone(), stdio, preview1, http: wasmtime_wasi_http::WasiHttpCtx::new(), + hooks: http::Hooks { + caller: crate::cred::Caller { + pid, + program: executable, + conversations: conversations.clone(), + }, + cred: self.cred.clone(), + script: self.script.clone(), + policy: self.network.clone(), + router: self.router, + }, }, ); store.epoch_deadline_async_yield_and_update(1); @@ -353,7 +441,7 @@ impl Runtime { argv: request.argv, cwd: request.cwd, state: "running".into(), - conversation, + conversation: conversations.first().cloned(), }, ); let (tx, code) = watch::channel(None); @@ -456,7 +544,7 @@ impl Host for State { self.pid, self.command, self.group, - self.conversation.clone(), + self.conversations.clone(), Some(self.stdio.clone()), ) .await @@ -594,7 +682,7 @@ impl Exec for Arc { 0, 0, 0, - options.conversation, + options.conversations, None, ); let mut child = if let Some(deadline) = deadline { diff --git a/host/src/process/net.rs b/host/src/process/net.rs new file mode 100644 index 0000000..52fa22b --- /dev/null +++ b/host/src/process/net.rs @@ -0,0 +1,85 @@ +//! Host-side network policy. `/host/network.toml` accepts `deny = ["address:port"]`. +//! Host listeners register their ports with `deny_host_port`. Guests cannot bind or connect there. +use std::{ + collections::HashSet, + net::{IpAddr, SocketAddr}, + path::Path, + sync::RwLock, +}; + +fn normalize(address: SocketAddr) -> SocketAddr { + match address { + SocketAddr::V6(v6) => v6 + .ip() + .to_ipv4_mapped() + .map(|ip| SocketAddr::new(ip.into(), v6.port())) + .unwrap_or(address), + _ => address, + } +} +pub struct NetPolicy { + denied: HashSet, + local: HashSet, + ports: RwLock>, +} +impl NetPolicy { + pub fn open(host_dir: &Path) -> anyhow::Result { + #[derive(serde::Deserialize, Default)] + #[serde(deny_unknown_fields)] + struct Config { + #[serde(default)] + deny: Vec, + } + let config = match std::fs::read_to_string(host_dir.join("network.toml")) { + Ok(text) => toml::from_str::(&text)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Config::default(), + Err(e) => return Err(e.into()), + }; + Ok(Self { + denied: config.deny.into_iter().map(normalize).collect(), + local: if_addrs::get_if_addrs()? + .into_iter() + .map(|a| a.ip()) + .collect(), + ports: RwLock::default(), + }) + } + pub fn deny_host_port(&self, port: u16) { + self.ports.write().unwrap().insert(port); + } + pub fn allows(&self, address: SocketAddr) -> bool { + let address = normalize(address); + let ip = address.ip(); + !self.denied.contains(&address) + && !(self.ports.read().unwrap().contains(&address.port()) + && (ip.is_loopback() || ip.is_unspecified() || self.local.contains(&ip))) + } + pub(super) async fn resolve( + &self, + uri: &http::Uri, + ) -> wasmtime_wasi_http::Result> { + use wasmtime_wasi_http::Error; + let host = uri + .host() + .ok_or(Error::HttpRequestUriInvalid)? + .trim_matches(['[', ']']); + let port = uri + .port_u16() + .unwrap_or(if uri.scheme_str() == Some("https") { + 443 + } else { + 80 + }); + let addresses: Vec<_> = tokio::net::lookup_host((host, port)) + .await + .map_err(|_| Error::DnsError { + rcode: None, + info_code: None, + })? + .collect(); + if addresses.is_empty() || addresses.iter().any(|a| !self.allows(*a)) { + return Err(Error::HttpRequestDenied); + } + Ok(addresses) + } +} diff --git a/host/src/process/services.rs b/host/src/process/services.rs index f61e065..9b814bb 100644 --- a/host/src/process/services.rs +++ b/host/src/process/services.rs @@ -52,7 +52,7 @@ impl Service { 0, 0, 0, - None, + Vec::new(), None, ) .await diff --git a/host/tests/hook.rs b/host/tests/hook.rs new file mode 100644 index 0000000..a25e407 --- /dev/null +++ b/host/tests/hook.rs @@ -0,0 +1,332 @@ +//! Real guest HTTP requests pass through caller identity, credential injection, and streaming transport. +mod hook_git; +mod hook_guest; +use sausage::{ + cred::{ + self, + items::{Field, Item, Rule}, + types::FieldType, + }, + exec::{Command, Exec, Options}, + process::Runtime, + world::World, +}; +use std::sync::Arc; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +struct Fake; +impl cred::script::Script for Fake { + fn evaluate( + &self, + _: &str, + input: &str, + _: Option<&str>, + _: cred::script::Limits, + ) -> Result { + let context: serde_json::Value = serde_json::from_str(input).unwrap(); + Ok(serde_json::json!({"headers":[{"name":"authorization","value":context["credential"]["token"]}]}).to_string()) + } +} +async fn run(runtime: &Arc, url: String, chain: Vec) -> String { + let mut out = Vec::new(); + let code = runtime + .exec( + Command::Argv(vec!["/bin/guest.wasm".into(), url, "GET".into()]), + Options { + cwd: "/".into(), + conversations: chain, + timeout: Some(std::time::Duration::from_secs(30)), + ..Default::default() + }, + &mut |_, b| out.extend_from_slice(b), + ) + .await + .unwrap(); + assert_eq!(code, 0); + String::from_utf8(out).unwrap() +} +#[tokio::test] +async fn identity_injection_denial_and_ambiguity() { + let guest = hook_guest::component(); + let temp = tempfile::tempdir().unwrap(); + let world = World::open(temp.path()).unwrap(); + std::fs::create_dir_all(temp.path().join("world/bin")).unwrap(); + std::fs::copy(guest, temp.path().join("world/bin/guest.wasm")).unwrap(); + let c = Arc::new(cred::Cred::open(&world.host_dir).unwrap()); + let caller = cred::Caller::default(); + let item = c + .create_item( + Item { + id: String::new(), + name: "test item".into(), + version: 0, + free_use: true, + rules: vec![Rule { + scheme: "http".into(), + hosts: vec!["127.0.0.1".into()], + path_prefixes: vec!["/free".into()], + methods: None, + }], + validate_js: None, + transform_js: "inject".into(), + fields: vec![Field { + field_id: String::new(), + name: "token".into(), + public: false, + value: None, + needs_value: true, + field_type: FieldType::Text, + key_type: None, + }], + }, + &caller, + "test", + ) + .unwrap(); + let token = uuid::Uuid::new_v4().to_string(); + c.set_field(&item.id, &item.fields[0].field_id, &token, &caller, "test") + .unwrap(); + let runtime = Runtime::new_with_hooks(world, c.clone(), Arc::new(Fake), None).unwrap(); + let identity = run( + &runtime, + "http://host.sausage/whoami".into(), + vec!["child".into(), "root".into()], + ) + .await; + let who: serde_json::Value = + serde_json::from_str(identity.lines().find(|line| line.starts_with('{')).unwrap()).unwrap(); + assert_eq!(who["pid"], 1); + assert_eq!(who["program"], "/bin/guest.wasm"); + assert_eq!(who["conversations"], serde_json::json!(["child", "root"])); + std::os::unix::fs::symlink("guest.wasm", temp.path().join("world/bin/alias")).unwrap(); + let mut alias_out = Vec::new(); + let code = runtime + .exec( + Command::Argv(vec![ + "/bin/alias".into(), + "custom://host.sausage/whoami".into(), + "GET".into(), + ]), + Options { + cwd: "/".into(), + ..Default::default() + }, + &mut |_, b| alias_out.extend_from_slice(b), + ) + .await + .unwrap(); + assert_eq!(code, 0); + let alias_out = String::from_utf8(alias_out).unwrap(); + assert!(alias_out.contains("/bin/guest.wasm") && !alias_out.contains("/bin/alias")); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server_token = token.clone(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut bytes = Vec::new(); + loop { + let mut b = [0; 1024]; + let n = socket.read(&mut b).await.unwrap(); + assert!(n > 0); + bytes.extend_from_slice(&b[..n]); + if bytes.ends_with(b"0\r\n\r\n") + || (bytes.ends_with(b"\r\n\r\n") + && !String::from_utf8_lossy(&bytes).contains("transfer-encoding: chunked")) + { + break; + } + } + let text = String::from_utf8(bytes).unwrap(); + assert!( + text.contains(&server_token), + "server did not receive the credential" + ); + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") + .await + .unwrap(); + }); + let url = format!("http://127.0.0.1:{port}/free"); + let out = run(&runtime, url.clone(), vec!["root".into()]).await; + assert!(out.contains("status=200") && !out.contains(&token)); + server.await.unwrap(); + let (redirect_url, redirect) = upstream(Some(token.clone()), 0, 302).await; + assert!( + run(&runtime, format!("{redirect_url}/free"), vec![]) + .await + .contains("status=302") + ); + redirect.await.unwrap(); + let (public_url, public) = upstream(None, 0, 200).await; + assert!( + run( + &runtime, + format!("{}/public", public_url.replace("127.0.0.1", "localhost")), + vec![] + ) + .await + .contains("status=200") + ); + public.await.unwrap(); + let mut changed = item.clone(); + changed.free_use = false; + // A second matching item must fail before any network request. + changed.id = String::new(); + changed.name = "second".into(); + changed.rules[0].path_prefixes = vec!["/free".into(), "/deny".into()]; + let second = c.create_item(changed, &caller, "test").unwrap(); + assert!( + run(&runtime, url.clone(), vec![]) + .await + .contains("ambiguous_item") + ); + let url = format!("http://127.0.0.1:{port}/deny"); + let out = run(&runtime, url.clone(), vec!["other".into()]).await; + assert!(out.contains("status=403") && out.contains("second") && out.contains("request")); + let again = run(&runtime, url, vec!["other".into()]).await; + assert!(again.contains("status=403")); + assert_eq!(c.request_list().unwrap().len(), 1); + c.set_field( + &second.id, + &second.fields[0].field_id, + &token, + &caller, + "test", + ) + .unwrap(); + c.approve( + &c.request_list().unwrap()[0].id, + "allow", + cred::requests::Overrides::default(), + Some("test"), + ) + .unwrap(); + let (url, task) = upstream(Some(token.clone()), 0, 200).await; + let out = run( + &runtime, + format!("{url}/deny"), + vec!["descendant".into(), "other".into()], + ) + .await; + assert!(out.contains("status=200") && !out.contains(&token)); + task.await.unwrap(); + let (child_url, task) = upstream(Some(token.clone()), 0, 200).await; + let mut child_out = Vec::new(); + let code = runtime + .exec( + Command::Argv(vec![ + "/bin/guest.wasm".into(), + "spawn".into(), + format!("{child_url}/deny"), + ]), + Options { + cwd: "/".into(), + conversations: vec!["descendant".into(), "other".into()], + timeout: Some(std::time::Duration::from_secs(30)), + ..Default::default() + }, + &mut |_, b| child_out.extend_from_slice(b), + ) + .await + .unwrap(); + assert_eq!(code, 0); + let child_out = String::from_utf8(child_out).unwrap(); + assert!(child_out.contains("status=200") && !child_out.contains(&token)); + task.await.unwrap(); + let denied = run(&runtime, format!("{url}/deny"), vec!["sibling".into()]).await; + assert!(denied.contains("status=403")); +} + +async fn upstream( + token: Option, + length: usize, + status: u16, +) -> (String, tokio::task::JoinHandle<()>) { + use http_body_util::{BodyExt, Full}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let task = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let service = + hyper::service::service_fn(move |request: http::Request| { + let token = token.clone(); + async move { + match token { + Some(token) => assert!( + request + .headers() + .get("authorization") + .is_some_and(|h| h.as_bytes() == token.as_bytes()), + "credential absent or incorrect" + ), + None => assert!(!request.headers().contains_key("authorization")), + } + let mut body = request.into_body(); + let mut received = 0; + while let Some(frame) = body.frame().await { + if let Ok(data) = frame.unwrap().into_data() { + received += data.len(); + } + } + assert_eq!(received, length); + Ok::<_, std::convert::Infallible>( + http::Response::builder() + .status(status) + .header("location", "http://localhost/public") + .body(Full::new(bytes::Bytes::from_static(b"ok"))) + .unwrap(), + ) + } + }); + hyper::server::conn::http1::Builder::new() + .keep_alive(false) + .serve_connection(hyper_util::rt::TokioIo::new(socket), service) + .await + .unwrap(); + }); + (url, task) +} + +#[tokio::test] +async fn streamed_upload_redirect_and_http_admin_denial() { + let guest = hook_guest::component(); + let temp = tempfile::tempdir().unwrap(); + let world = World::open(temp.path()).unwrap(); + std::fs::create_dir_all(temp.path().join("world/bin")).unwrap(); + std::fs::copy(guest, temp.path().join("world/bin/guest.wasm")).unwrap(); + let runtime = Runtime::new(world).unwrap(); + let (url, task) = upstream(None, 4 * 1024 * 1024, 302).await; + let mut out = Vec::new(); + let code = runtime + .exec( + Command::Argv(vec![ + "/bin/guest.wasm".into(), + url, + "POST".into(), + (4 * 1024 * 1024).to_string(), + ]), + Options { + cwd: "/".into(), + timeout: Some(std::time::Duration::from_secs(30)), + ..Default::default() + }, + &mut |_, b| out.extend_from_slice(b), + ) + .await + .unwrap(); + assert_eq!(code, 0); + assert!(String::from_utf8(out).unwrap().contains("status=302")); + task.await.unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + runtime + .network + .deny_host_port(listener.local_addr().unwrap().port()); + assert!( + run( + &runtime, + format!("http://{}/", listener.local_addr().unwrap()), + vec![] + ) + .await + .contains("error") + ); +} diff --git a/host/tests/hook_git.rs b/host/tests/hook_git.rs new file mode 100644 index 0000000..463cce1 --- /dev/null +++ b/host/tests/hook_git.rs @@ -0,0 +1,208 @@ +//! A real git component pushes an incompressible commit through the hook to native git's HTTP backend. +use sausage::{ + exec::{Command, Exec, Options}, + process::Runtime, + world::World, +}; +use std::{ + io::Write, + path::Path, + process::Stdio, + sync::{Arc, OnceLock}, +}; +fn git(cwd: &Path, args: &[&str]) { + assert!( + std::process::Command::new("git") + .args(args) + .current_dir(cwd) + .status() + .unwrap() + .success() + ); +} +#[tokio::test] +async fn several_mb_git_push() { + static BUILT: OnceLock = OnceLock::new(); + let component = BUILT.get_or_init(|| { + let target = Path::new(env!("CARGO_TARGET_TMPDIR")).join("hook-git"); + let userland = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland"); + if !userland.join("cwd-init/init.o").is_file() { + assert!( + std::process::Command::new("nu") + .args(["--no-config-file", "cwd-init/build.nu"]) + .current_dir(&userland) + .status() + .unwrap() + .success() + ); + } + let manifest = userland.join("Cargo.toml"); + assert!( + std::process::Command::new("cargo") + .args(["build", "--release", "-p", "git", "--manifest-path"]) + .arg(manifest) + .current_dir(&userland) + .args(["--target", "wasm32-wasip2", "--target-dir"]) + .arg(&target) + .args(["-j", "2", "--locked"]) + .status() + .unwrap() + .success() + ); + target.join("wasm32-wasip2/release/git.wasm") + }); + let temp = tempfile::tempdir().unwrap(); + let world = World::open(temp.path()).unwrap(); + let root = temp.path().join("world"); + std::fs::create_dir_all(root.join("bin")).unwrap(); + std::fs::copy(component, root.join("bin/git.wasm")).unwrap(); + let repo = root.join("repo"); + std::fs::create_dir_all(&repo).unwrap(); + git(&repo, &["init", "-b", "main"]); + let mut data = vec![0; 4 * 1024 * 1024]; + openssl::rand::rand_bytes(&mut data).unwrap(); + std::fs::write(repo.join("large"), data).unwrap(); + git(&repo, &["add", "large"]); + git( + &repo, + &[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "-m", + "large commit", + ], + ); + let remote = temp.path().join("remote"); + std::fs::create_dir_all(&remote).unwrap(); + git(&remote, &["init", "--bare", "destination.git"]); + git( + &remote.join("destination.git"), + &["config", "http.receivepack", "true"], + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + git( + &repo, + &[ + "remote", + "add", + "origin", + &format!("http://127.0.0.1:{port}/destination.git"), + ], + ); + let uploaded = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counter = uploaded.clone(); + let server = tokio::spawn(async move { + loop { + let (socket, _) = listener.accept().await.unwrap(); + let remote = remote.clone(); + let counter = counter.clone(); + let service = + hyper::service::service_fn(move |request: http::Request| { + let remote = remote.clone(); + let counter = counter.clone(); + async move { + use http_body_util::{BodyExt, Full}; + let (parts, body) = request.into_parts(); + let body = body.collect().await.unwrap().to_bytes(); + counter.fetch_add(body.len(), std::sync::atomic::Ordering::Relaxed); + let response = tokio::task::spawn_blocking(move || { + let mut process = std::process::Command::new("git") + .arg("http-backend") + .env("GIT_PROJECT_ROOT", remote) + .env("GIT_HTTP_EXPORT_ALL", "1") + .env("REQUEST_METHOD", parts.method.as_str()) + .env("PATH_INFO", parts.uri.path()) + .env("QUERY_STRING", parts.uri.query().unwrap_or("")) + .env( + "CONTENT_TYPE", + parts + .headers + .get("content-type") + .map(|h| h.to_str().unwrap()) + .unwrap_or(""), + ) + .env("CONTENT_LENGTH", body.len().to_string()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let mut stdin = process.stdin.take().unwrap(); + let output = std::thread::scope(|scope| { + scope.spawn(move || { + stdin.write_all(&body).unwrap(); + }); + process.wait_with_output().unwrap() + }); + assert!(output.status.success()); + let split = output + .stdout + .windows(4) + .position(|b| b == b"\r\n\r\n") + .unwrap(); + let headers = std::str::from_utf8(&output.stdout[..split]).unwrap(); + let mut response = http::Response::builder(); + for line in headers.split("\r\n") { + let (name, value) = line.split_once(':').unwrap(); + if name.eq_ignore_ascii_case("status") { + response = response.status( + value + .trim() + .split(' ') + .next() + .unwrap() + .parse::() + .unwrap(), + ); + } else { + response = response.header(name, value.trim()); + } + } + response + .body(Full::new(bytes::Bytes::copy_from_slice( + &output.stdout[split + 4..], + ))) + .unwrap() + }) + .await + .unwrap(); + Ok::<_, std::convert::Infallible>(response) + } + }); + hyper::server::conn::http1::Builder::new() + .keep_alive(false) + .serve_connection(hyper_util::rt::TokioIo::new(socket), service) + .await + .unwrap(); + } + }); + let runtime = Runtime::new(world).unwrap(); + let mut output = Vec::new(); + let code = runtime + .exec( + Command::Argv(vec![ + "/bin/git.wasm".into(), + "push".into(), + "origin".into(), + "main".into(), + ]), + Options { + cwd: "/repo".into(), + timeout: Some(std::time::Duration::from_secs(60)), + ..Default::default() + }, + &mut |_, b| output.extend_from_slice(b), + ) + .await + .unwrap(); + server.abort(); + assert_eq!(code, 0, "{}", String::from_utf8_lossy(&output)); + assert!(uploaded.load(std::sync::atomic::Ordering::Relaxed) > 3 * 1024 * 1024); + git( + &temp.path().join("remote/destination.git"), + &["cat-file", "-e", "refs/heads/main:large"], + ); +} diff --git a/host/tests/hook_guest.rs b/host/tests/hook_guest.rs new file mode 100644 index 0000000..0344d34 --- /dev/null +++ b/host/tests/hook_guest.rs @@ -0,0 +1,64 @@ +//! Persistent test component shared by HTTP and socket acceptance tests. +use std::{ + path::{Path, PathBuf}, + sync::OnceLock, +}; +fn write(path: PathBuf, text: &str) -> std::io::Result<()> { + if std::fs::read_to_string(&path).ok().as_deref() != Some(text) { + std::fs::write(path, text)?; + } + Ok(()) +} +pub fn component() -> &'static Path { + static PATH: OnceLock = OnceLock::new(); + PATH.get_or_init(|| { + let directory = Path::new(env!("CARGO_TARGET_TMPDIR")).join("hook-guest"); + std::fs::create_dir_all(directory.join("src")).unwrap(); + std::fs::create_dir_all(directory.join("wit/deps")).unwrap(); + write(directory.join("wit/process.wit"), include_str!("../wit/process.wit")).unwrap(); + write(directory.join("wit/deps/io.wit"), include_str!("../wit/deps/io.wit")).unwrap(); + write(directory.join("Cargo.toml"), "[package]\nname='hook-guest'\nversion='0.1.0'\nedition='2024'\n[workspace]\n[dependencies]\nwstd='=0.6.8'\nanyhow='1'\nfutures-lite='2'\nwit-bindgen='0.46'\n").unwrap(); + write(directory.join("src/main.rs"), r#" +use wstd::http::{Body, BodyExt, Client, Request}; +mod bindings { wit_bindgen::generate!({path:"wit",world:"program",generate_all}); } +#[wstd::main] +async fn main() -> anyhow::Result<()> { + let args: Vec<_> = std::env::args().collect(); + match args[1].as_str() { + "spawn" => { + use bindings::sausage::process::host::{self, Request, Stdio}; + let child=host::spawn(&Request {program:"/bin/guest.wasm".into(),argv:vec!["guest".into(),args[2].clone(),"GET".into()],cwd:"/".into(),env:vec![],group:None,stdin:Stdio::Null,stdout:Stdio::Inherit,stderr:Stdio::Inherit}).unwrap(); + println!("child={}",child.pid()); + assert_eq!(child.wait(),0); + }, + "listen" => { + let listener = std::net::TcpListener::bind(&args[2])?; + println!("ready"); + let (mut stream, _) = listener.accept()?; + use std::io::{Read, Write}; + let mut bytes = [0;4]; stream.read_exact(&mut bytes)?; + stream.write_all(&bytes)?; + }, + "connect" => { + use std::io::{Read, Write}; + match std::net::TcpStream::connect(&args[2]) { + Ok(mut stream) => { stream.write_all(b"ping")?; let mut bytes=[0;4]; stream.read_exact(&mut bytes)?; println!("{}", std::str::from_utf8(&bytes)?); }, + Err(_) => println!("denied"), + } + }, + _ => { + let body = if args.len() > 3 { Body::from_stream(futures_lite::stream::iter((0..args[3].parse::()?/8192).map(|_| vec![b'x';8192]))) } else { Body::empty() }; + let request = Request::builder().uri(&args[1]).method(args[2].as_str()).body(body)?; + match Client::new().send(request).await { + Ok(response) => { println!("status={}", response.status().as_u16()); let bytes=response.into_body().into_boxed_body().collect().await?.to_bytes(); println!("{}",String::from_utf8_lossy(&bytes)); }, + Err(_) => println!("error"), + } + } + } + Ok(()) +} +"#).unwrap(); + assert!(std::process::Command::new("cargo").args(["build", "--manifest-path"]).arg(directory.join("Cargo.toml")).args(["--target", "wasm32-wasip2", "--release", "-j", "2"]).env("CARGO_TARGET_DIR", directory.join("target")).status().unwrap().success(), "test guest build failed"); + directory.join("target/wasm32-wasip2/release/hook-guest.wasm") + }) +} diff --git a/host/tests/net.rs b/host/tests/net.rs new file mode 100644 index 0000000..b923957 --- /dev/null +++ b/host/tests/net.rs @@ -0,0 +1,125 @@ +//! Real components can accept TCP connections. Host port registration and host-only config deny access. +mod hook_guest; +use sausage::{ + exec::{Command, Exec, Options}, + process::{Runtime, net::NetPolicy}, + world::World, +}; +use std::{sync::Arc, time::Duration}; +#[test] +fn host_policy() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("network.toml"), + "deny = ['127.0.0.1:9001', '[::1]:9002']", + ) + .unwrap(); + let policy = NetPolicy::open(temp.path()).unwrap(); + policy.deny_host_port(9000); + for address in [ + "127.0.0.1:9000", + "0.0.0.0:9000", + "[::1]:9000", + "[::ffff:127.0.0.1]:9000", + "127.0.0.1:9001", + "[::1]:9002", + ] { + assert!(!policy.allows(address.parse().unwrap())); + } + assert!(policy.allows("0.0.0.0:8080".parse().unwrap())); + for interface in if_addrs::get_if_addrs().unwrap() { + assert!(!policy.allows(std::net::SocketAddr::new(interface.ip(), 9000))); + } + assert!(!policy.allows("[::ffff:127.0.0.1]:9001".parse().unwrap())); +} +async fn run(runtime: &Arc, mode: &str, address: String) -> String { + let mut output = Vec::new(); + let code = runtime + .exec( + Command::Argv(vec!["/bin/guest.wasm".into(), mode.into(), address]), + Options { + cwd: "/".into(), + timeout: Some(Duration::from_secs(30)), + ..Default::default() + }, + &mut |_, bytes| output.extend_from_slice(bytes), + ) + .await + .unwrap(); + assert_eq!(code, 0); + String::from_utf8(output).unwrap() +} +#[tokio::test] +async fn guest_tcp_and_host_denial() { + let guest = hook_guest::component(); + let temp = tempfile::tempdir().unwrap(); + let world = World::open(temp.path()).unwrap(); + std::fs::create_dir_all(temp.path().join("world/bin")).unwrap(); + std::fs::copy(guest, temp.path().join("world/bin/guest.wasm")).unwrap(); + let configured = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + std::fs::write( + world.host_dir.join("network.toml"), + format!("deny = ['{}']", configured.local_addr().unwrap()), + ) + .unwrap(); + let runtime = Runtime::new(world).unwrap(); + assert!( + run( + &runtime, + "connect", + configured.local_addr().unwrap().to_string() + ) + .await + .contains("denied") + ); + let native = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let denied = native.local_addr().unwrap(); + runtime.network.deny_host_port(denied.port()); + assert!( + run(&runtime, "connect", denied.to_string()) + .await + .contains("denied") + ); + let ephemeral = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = ephemeral.local_addr().unwrap(); + drop(ephemeral); + let server_runtime = runtime.clone(); + let (tx, rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let mut tx = Some(tx); + let mut bytes = Vec::new(); + server_runtime + .exec( + Command::Argv(vec![ + "/bin/guest.wasm".into(), + "listen".into(), + address.to_string(), + ]), + Options { + cwd: "/".into(), + timeout: Some(Duration::from_secs(30)), + ..Default::default() + }, + &mut |_, chunk| { + bytes.extend_from_slice(chunk); + if bytes.windows(5).any(|w| w == b"ready") { + if let Some(tx) = tx.take() { + let _ = tx.send(()); + } + } + }, + ) + .await + .unwrap() + }); + tokio::time::timeout(Duration::from_secs(30), rx) + .await + .unwrap() + .unwrap(); + assert!( + run(&runtime, "connect", format!("localhost:{}", address.port())) + .await + .contains("ping") + ); + assert_eq!(server.await.unwrap(), 0); +} diff --git a/host/tests/process.rs b/host/tests/process.rs index d980b7a..c91961a 100644 --- a/host/tests/process.rs +++ b/host/tests/process.rs @@ -271,7 +271,7 @@ mod features { exec.exec( Command::Shell("sleep 60 | cat".into()), Options { - conversation: Some("c1".into()), + conversations: vec!["c1".into()], ..Default::default() }, &mut |_, _| {}, From bb162abd4d116263b3e5b91c496bd62b989f4912 Mon Sep 17 00:00:00 2001 From: Surma Date: Fri, 6 Nov 2026 23:38:21 +0000 Subject: [PATCH 2/3] T29: use QuickJS and verify the HTTP hook and guest networking --- host/src/process/http.rs | 16 +++++--- host/src/process/mod.rs | 12 ++---- host/tests/harness.rs | 2 +- host/tests/hook.rs | 85 +++++++++++++++++++++++++++------------- host/tests/hook_guest.rs | 9 +++++ host/tests/net.rs | 10 ++--- 6 files changed, 86 insertions(+), 48 deletions(-) diff --git a/host/src/process/http.rs b/host/src/process/http.rs index 2c3e9cd..e72380b 100644 --- a/host/src/process/http.rs +++ b/host/src/process/http.rs @@ -37,7 +37,9 @@ fn response(response: Response) -> (Response, Io) { } impl WasiHttpHooks for Hooks { // Admit every scheme until the URI supplies the host name. - fn is_supported_scheme(&mut self, _: &http::uri::Scheme) -> bool { true } + fn is_supported_scheme(&mut self, _: &http::uri::Scheme) -> bool { + true + } fn send_request( &mut self, request: Request, @@ -61,7 +63,9 @@ impl WasiHttpHooks for Hooks { return Ok(response(router(&caller, Request::from_parts(parts, bytes)))); } if !matches!(parts.uri.scheme_str(), Some("http" | "https")) { - while let Some(frame) = body.frame().await { frame?; } + while let Some(frame) = body.frame().await { + frame?; + } return Err(Error::HttpRequestUriInvalid); } let result = @@ -144,14 +148,14 @@ impl TokioStream for T where T: AsyncRead + AsyncWrite + Send + Sync + Unpin /// Preserve Wasmtime's HTTP/1 transport and request deadlines, but connect only to checked addresses. /// The returned future drives the connection after the response headers arrive. -async fn send_request( - mut req: Request + Send + 'static>, +async fn send_request + Send + 'static>( + mut req: Request, options: Option, policy: &super::net::NetPolicy, ) -> Result< ( - Response>, - impl Future> + Send, + Response + use>, + impl Future> + Send + use, ), Error, > { diff --git a/host/src/process/mod.rs b/host/src/process/mod.rs index c995588..ae3e35b 100644 --- a/host/src/process/mod.rs +++ b/host/src/process/mod.rs @@ -47,10 +47,11 @@ mod bindings { use bindings::sausage::process::host::{Host, HostChild, Request, Stdio}; mod cache; mod http; +pub use http::Router; pub mod net; -/// Temporary default until T28 supplies the bounded QuickJS evaluator. +/// Credential scripts use T28's bounded QuickJS component. pub fn default_script() -> Arc { - Arc::new(crate::cred::v8::V8) + Arc::new(crate::cred::quickjs::QuickJs::default()) } pub use cache::configure as configure_cache; mod services; @@ -167,12 +168,7 @@ impl Runtime { world: World, cred: Arc, script: Arc, - router: Option< - fn( - &crate::cred::Caller, - ::http::Request, - ) -> ::http::Response, - >, + router: Option, ) -> anyhow::Result> { Self::with_hooks( world, diff --git a/host/tests/harness.rs b/host/tests/harness.rs index 2c68791..d4c08ae 100644 --- a/host/tests/harness.rs +++ b/host/tests/harness.rs @@ -16,7 +16,7 @@ impl Exec for FakeExec { options: Options, output: &mut (dyn for<'chunk> FnMut(Stream, &'chunk [u8]) + Send), ) -> anyhow::Result { - assert!(options.conversation.is_some(), "missing conversation id"); + assert!(!options.conversations.is_empty(), "missing conversation id"); let Command::Shell(command) = command else { anyhow::bail!("expected shell command") }; diff --git a/host/tests/hook.rs b/host/tests/hook.rs index a25e407..d75d76d 100644 --- a/host/tests/hook.rs +++ b/host/tests/hook.rs @@ -51,6 +51,8 @@ async fn identity_injection_denial_and_ambiguity() { let world = World::open(temp.path()).unwrap(); std::fs::create_dir_all(temp.path().join("world/bin")).unwrap(); std::fs::copy(guest, temp.path().join("world/bin/guest.wasm")).unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); let c = Arc::new(cred::Cred::open(&world.host_dir).unwrap()); let caller = cred::Caller::default(); let item = c @@ -61,6 +63,7 @@ async fn identity_injection_denial_and_ambiguity() { version: 0, free_use: true, rules: vec![Rule { + port: Some(port), scheme: "http".into(), hosts: vec!["127.0.0.1".into()], path_prefixes: vec!["/free".into()], @@ -79,12 +82,18 @@ async fn identity_injection_denial_and_ambiguity() { }], }, &caller, - "test", + c.unlocked().unwrap(), ) .unwrap(); let token = uuid::Uuid::new_v4().to_string(); - c.set_field(&item.id, &item.fields[0].field_id, &token, &caller, "test") - .unwrap(); + c.set_field( + &item.id, + &item.fields[0].field_id, + &token, + &caller, + c.unlocked().unwrap(), + ) + .unwrap(); let runtime = Runtime::new_with_hooks(world, c.clone(), Arc::new(Fake), None).unwrap(); let identity = run( &runtime, @@ -117,8 +126,6 @@ async fn identity_injection_denial_and_ambiguity() { assert_eq!(code, 0); let alias_out = String::from_utf8(alias_out).unwrap(); assert!(alias_out.contains("/bin/guest.wasm") && !alias_out.contains("/bin/alias")); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); let server_token = token.clone(); let server = tokio::spawn(async move { let (mut socket, _) = listener.accept().await.unwrap(); @@ -149,23 +156,35 @@ async fn identity_injection_denial_and_ambiguity() { let out = run(&runtime, url.clone(), vec!["root".into()]).await; assert!(out.contains("status=200") && !out.contains(&token)); server.await.unwrap(); - let (redirect_url, redirect) = upstream(Some(token.clone()), 0, 302).await; - assert!( - run(&runtime, format!("{redirect_url}/free"), vec![]) - .await - .contains("status=302") - ); - redirect.await.unwrap(); - let (public_url, public) = upstream(None, 0, 200).await; - assert!( - run( - &runtime, - format!("{}/public", public_url.replace("127.0.0.1", "localhost")), - vec![] + let (public_url, public) = upstream(None, 0, 200, 0, None).await; + let location = format!("{}/public", public_url.replace("127.0.0.1", "localhost")); + let (redirect_url, redirect) = + upstream(Some(token.clone()), 0, 302, port, Some(location)).await; + let mut follow_out = Vec::new(); + let code = runtime + .exec( + Command::Argv(vec![ + "/bin/guest.wasm".into(), + "follow".into(), + format!("{redirect_url}/free"), + ]), + Options { + cwd: "/".into(), + timeout: Some(std::time::Duration::from_secs(30)), + ..Default::default() + }, + &mut |_, b| follow_out.extend_from_slice(b), ) .await - .contains("status=200") + .unwrap(); + assert_eq!(code, 0); + let follow_out = String::from_utf8(follow_out).unwrap(); + assert!( + follow_out.contains("first-status=302") + && follow_out.contains("status=200") + && !follow_out.contains(&token) ); + redirect.await.unwrap(); public.await.unwrap(); let mut changed = item.clone(); changed.free_use = false; @@ -173,7 +192,9 @@ async fn identity_injection_denial_and_ambiguity() { changed.id = String::new(); changed.name = "second".into(); changed.rules[0].path_prefixes = vec!["/free".into(), "/deny".into()]; - let second = c.create_item(changed, &caller, "test").unwrap(); + let second = c + .create_item(changed, &caller, c.unlocked().unwrap()) + .unwrap(); assert!( run(&runtime, url.clone(), vec![]) .await @@ -190,17 +211,17 @@ async fn identity_injection_denial_and_ambiguity() { &second.fields[0].field_id, &token, &caller, - "test", + c.unlocked().unwrap(), ) .unwrap(); c.approve( &c.request_list().unwrap()[0].id, - "allow", + cred::webauthn::Decision::Approve, cred::requests::Overrides::default(), - Some("test"), + c.unlocked().unwrap(), ) .unwrap(); - let (url, task) = upstream(Some(token.clone()), 0, 200).await; + let (url, task) = upstream(Some(token.clone()), 0, 200, port, None).await; let out = run( &runtime, format!("{url}/deny"), @@ -209,7 +230,7 @@ async fn identity_injection_denial_and_ambiguity() { .await; assert!(out.contains("status=200") && !out.contains(&token)); task.await.unwrap(); - let (child_url, task) = upstream(Some(token.clone()), 0, 200).await; + let (child_url, task) = upstream(Some(token.clone()), 0, 200, port, None).await; let mut child_out = Vec::new(); let code = runtime .exec( @@ -240,15 +261,20 @@ async fn upstream( token: Option, length: usize, status: u16, + port: u16, + location: Option, ) -> (String, tokio::task::JoinHandle<()>) { use http_body_util::{BodyExt, Full}; - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port)) + .await + .unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); let task = tokio::spawn(async move { let (socket, _) = listener.accept().await.unwrap(); let service = hyper::service::service_fn(move |request: http::Request| { let token = token.clone(); + let location = location.clone(); async move { match token { Some(token) => assert!( @@ -271,7 +297,10 @@ async fn upstream( Ok::<_, std::convert::Infallible>( http::Response::builder() .status(status) - .header("location", "http://localhost/public") + .header( + "location", + location.as_deref().unwrap_or("http://localhost/public"), + ) .body(Full::new(bytes::Bytes::from_static(b"ok"))) .unwrap(), ) @@ -294,7 +323,7 @@ async fn streamed_upload_redirect_and_http_admin_denial() { std::fs::create_dir_all(temp.path().join("world/bin")).unwrap(); std::fs::copy(guest, temp.path().join("world/bin/guest.wasm")).unwrap(); let runtime = Runtime::new(world).unwrap(); - let (url, task) = upstream(None, 4 * 1024 * 1024, 302).await; + let (url, task) = upstream(None, 4 * 1024 * 1024, 302, 0, None).await; let mut out = Vec::new(); let code = runtime .exec( diff --git a/host/tests/hook_guest.rs b/host/tests/hook_guest.rs index 0344d34..d14adb8 100644 --- a/host/tests/hook_guest.rs +++ b/host/tests/hook_guest.rs @@ -25,6 +25,15 @@ mod bindings { wit_bindgen::generate!({path:"wit",world:"program",generate_all}) async fn main() -> anyhow::Result<()> { let args: Vec<_> = std::env::args().collect(); match args[1].as_str() { + "follow" => { + let response=Client::new().send(Request::get(&args[2]).body(Body::empty())?).await?; + println!("first-status={}",response.status().as_u16()); + let location=response.headers()["location"].to_str()?.to_owned(); + response.into_body().into_boxed_body().collect().await?; + let response=Client::new().send(Request::get(location).body(Body::empty())?).await?; + println!("status={}",response.status().as_u16()); + response.into_body().into_boxed_body().collect().await?; + }, "spawn" => { use bindings::sausage::process::host::{self, Request, Stdio}; let child=host::spawn(&Request {program:"/bin/guest.wasm".into(),argv:vec!["guest".into(),args[2].clone(),"GET".into()],cwd:"/".into(),env:vec![],group:None,stdin:Stdio::Null,stdout:Stdio::Inherit,stderr:Stdio::Inherit}).unwrap(); diff --git a/host/tests/net.rs b/host/tests/net.rs index b923957..51a6998 100644 --- a/host/tests/net.rs +++ b/host/tests/net.rs @@ -93,7 +93,7 @@ async fn guest_tcp_and_host_denial() { Command::Argv(vec![ "/bin/guest.wasm".into(), "listen".into(), - address.to_string(), + format!("0.0.0.0:{}", address.port()), ]), Options { cwd: "/".into(), @@ -102,10 +102,10 @@ async fn guest_tcp_and_host_denial() { }, &mut |_, chunk| { bytes.extend_from_slice(chunk); - if bytes.windows(5).any(|w| w == b"ready") { - if let Some(tx) = tx.take() { - let _ = tx.send(()); - } + if bytes.windows(5).any(|w| w == b"ready") + && let Some(tx) = tx.take() + { + let _ = tx.send(()); } }, ) From c91ecbf7a4e23299dcf907f968bb4b385ee64fa1 Mon Sep 17 00:00:00 2001 From: Surma Date: Sat, 7 Nov 2026 00:23:57 +0000 Subject: [PATCH 3/3] Share owned host API state between HTTP hooks and native pages --- host/src/api/README.md | 12 ++-- host/src/api/auth.rs | 6 +- host/src/api/mod.rs | 34 +++++++--- host/src/api/pages.rs | 2 +- host/src/api/server.rs | 132 +++++++++++++++++++------------------- host/src/api/ssh.rs | 2 +- host/src/cred/webauthn.rs | 18 ++++-- host/src/process/http.rs | 23 ++----- host/src/process/mod.rs | 27 +++++--- host/tests/api.rs | 8 +-- host/tests/api_auth.rs | 19 +++--- host/tests/cred_proofs.rs | 7 +- host/tests/hook.rs | 2 + host/tests/hook_api.rs | 102 +++++++++++++++++++++++++++++ 14 files changed, 261 insertions(+), 133 deletions(-) create mode 100644 host/tests/hook_api.rs diff --git a/host/src/api/README.md b/host/src/api/README.md index a48639c..e292fd3 100644 --- a/host/src/api/README.md +++ b/host/src/api/README.md @@ -1,7 +1,7 @@ # Host API and host pages `handle(&Api, Caller, Request)` answers requests without a transport. -The later `wasi:http` hook supplies the caller from the process table. +The `wasi:http` hook supplies the caller from the process table. It must not accept caller identity from headers, environment variables, or JSON. Reads are open to every caller. JSON routes return 200 on success. @@ -11,11 +11,13 @@ Conflicts return 409. The filing budget returns 429. ## Host listener -`sausage run WORLD` starts the console and the host listener with one shared `Arc`. -The listener owns its passkey connection and handles requests on a dedicated thread. -The console receives the same credentials store, including its decision notifications. +`sausage run WORLD` creates one `Cred`, one `Arc`, and one process runtime. +The API owns its passkey connection and retains ceremony and rate-limit state. +The hook and the host listener share that API. +The listener handles requests on a dedicated thread. +The console shares the credential store, including its decision notifications. +After the listener binds, the runtime denies its port on every local address. The listener stops when the console returns. -No protected harness or process module needs a change. Options on `sausage run`: diff --git a/host/src/api/auth.rs b/host/src/api/auth.rs index b7d3e8d..0e137f1 100644 --- a/host/src/api/auth.rs +++ b/host/src/api/auth.rs @@ -15,7 +15,7 @@ fn choice(body: &Value) -> Result<(Decision, Overrides)> { } Ok((decision, overrides)) } -fn verify(ctx: &Api<'_>, body: &Value) -> Result { +fn verify(ctx: &Api, body: &Value) -> Result { ctx.passkeys .lock() .map_err(|_| Error(500, "storage"))? @@ -25,7 +25,7 @@ fn verify(ctx: &Api<'_>, body: &Value) -> Result { ) .map_err(|_| Error(403, "assertion_failed")) } -pub(super) fn field(ctx: &Api<'_>, item: &str, field: &str) -> Result { +pub(super) fn field(ctx: &Api, item: &str, field: &str) -> Result { let field = ctx .cred .item_get(item)? @@ -39,7 +39,7 @@ pub(super) fn field(ctx: &Api<'_>, item: &str, field: &str) -> Result, + ctx: &Api, caller: &Caller, method: &str, path: &[&str], diff --git a/host/src/api/mod.rs b/host/src/api/mod.rs index 123148a..310ee52 100644 --- a/host/src/api/mod.rs +++ b/host/src/api/mod.rs @@ -17,16 +17,34 @@ use std::{ time::{Duration, Instant}, }; -pub struct Api<'a> { +pub struct Api { pub cred: Arc, script: cred::quickjs::QuickJs, - pub passkeys: Mutex>, + pub passkeys: Mutex>, pub entry: EntryKey, requests: Mutex>, } -impl<'a> Api<'a> { +impl Api { + /// Own the connection and ceremony state shared by both transports. + pub fn open( + cred: Arc, + host_dir: &std::path::Path, + rp_id: &str, + origin: &str, + ) -> anyhow::Result { + let db = rusqlite::Connection::open(host_dir.join("cred.sqlite"))?; + Ok(Self::new( + cred, + Passkeys::new(db, rp_id, origin)?, + EntryKey::open(&host_dir.join("entry.key"))?, + )) + } /// Passkeys owns the RP configuration. Its database must remain host-only. - pub fn new(cred: impl Into>, passkeys: Passkeys<'a>, entry: EntryKey) -> Self { + pub fn new( + cred: impl Into>, + passkeys: Passkeys, + entry: EntryKey, + ) -> Self { Self { cred: cred.into(), script: cred::quickjs::QuickJs::default(), @@ -113,10 +131,10 @@ fn request_view(request: cred::requests::Request) -> Result { } /// Handle a request without a network transport. Errors contain only static error kinds. -pub async fn handle(ctx: &Api<'_>, caller: Caller, request: Request) -> Response { +pub async fn handle(ctx: &Api, caller: Caller, request: Request) -> Response { respond(ctx, caller, request) } -fn respond(ctx: &Api<'_>, caller: Caller, request: Request) -> Response { +fn respond(ctx: &Api, caller: Caller, request: Request) -> Response { let result = if request.method() == http::Method::GET { match pages::get(ctx, request.uri().path()) { Ok(Some(response)) => return response, @@ -143,7 +161,7 @@ fn respond(ctx: &Api<'_>, caller: Caller, request: Request) -> Response, caller: &Caller, request: Request) -> Result { +fn route(ctx: &Api, caller: &Caller, request: Request) -> Result { let path: Vec<_> = request.uri().path().trim_matches('/').split('/').collect(); let method = request.method().as_str(); let body: Value = if request.body().is_empty() { @@ -206,7 +224,7 @@ mod tests { let db = rusqlite::Connection::open_in_memory()?; let api = Api::new( Cred::open(dir.path())?, - Passkeys::new(&db, "localhost", "http://localhost:8080")?, + Passkeys::new(db, "localhost", "http://localhost:8080")?, EntryKey::open(&dir.path().join("entry.key"))?, ); let caller = Caller::default(); diff --git a/host/src/api/pages.rs b/host/src/api/pages.rs index 0bc796c..ec9ece8 100644 --- a/host/src/api/pages.rs +++ b/host/src/api/pages.rs @@ -10,7 +10,7 @@ fn escape(text: &str) -> String { .replace('"', """) .replace('\'', "'") } -pub(super) fn get(ctx: &Api<'_>, path: &str) -> Result>> { +pub(super) fn get(ctx: &Api, path: &str) -> Result>> { let parts: Vec<_> = path.trim_matches('/').split('/').collect(); let (mime, text) = match parts.as_slice() { ["app.js"] => ( diff --git a/host/src/api/server.rs b/host/src/api/server.rs index 3adec06..8393225 100644 --- a/host/src/api/server.rs +++ b/host/src/api/server.rs @@ -1,6 +1,6 @@ -//! The host HTTP listener and console startup with one shared credentials store. +//! The host HTTP listener and console startup share one owned API and credentials store. use super::{Api, respond}; -use crate::cred::{Caller, Cred, entry::EntryKey, webauthn::Passkeys}; +use crate::cred::{Caller, Cred}; use std::{net::SocketAddr, path::Path, sync::Arc}; #[derive(Clone, Debug, clap::Args)] @@ -22,71 +22,58 @@ pub struct Listener { } impl Listener { pub fn start(cred: Arc, host_dir: &Path, options: Options) -> anyhow::Result { + let api = Arc::new(Api::open( + cred, + host_dir, + &options.api_rp_id, + &options.api_origin, + )?); + Self::start_with_api(api, options) + } + pub fn start_with_api(api: Arc, options: Options) -> anyhow::Result { let server = Arc::new( tiny_http::Server::http(options.api_listen) .map_err(|_| anyhow::anyhow!("host API address unavailable"))?, ); let serving = server.clone(); - let host_dir = host_dir.to_owned(); - let (ready, started) = std::sync::mpsc::sync_channel(1); let thread = std::thread::spawn(move || { - let setup = || -> anyhow::Result<_> { - Ok(( - rusqlite::Connection::open(host_dir.join("cred.sqlite"))?, - EntryKey::open(&host_dir.join("entry.key"))?, - )) - }; - let serve = || -> anyhow::Result<()> { - let (db, entry) = setup()?; - let passkeys = Passkeys::new(&db, &options.api_rp_id, &options.api_origin)?; - let api = Api::new(cred, passkeys, entry); - let _ = ready.send(true); - for mut request in serving.incoming_requests() { - let response = (|| -> anyhow::Result<_> { - let mut body = Vec::new(); - request.as_reader().read_to_end(&mut body)?; - let request = http::Request::builder() - .method(request.method().as_str()) - .uri(request.url()) - .body(bytes::Bytes::from(body))?; - Ok(respond( - &api, - Caller { - program: "/host/browser".into(), - pid: std::process::id(), - conversations: Vec::new(), - }, - request, + for mut request in serving.incoming_requests() { + let response = (|| -> anyhow::Result<_> { + let mut body = Vec::new(); + request.as_reader().read_to_end(&mut body)?; + let request = http::Request::builder() + .method(request.method().as_str()) + .uri(request.url()) + .body(bytes::Bytes::from(body))?; + Ok(respond( + &api, + Caller { + program: "/host/browser".into(), + pid: std::process::id(), + conversations: Vec::new(), + }, + request, + )) + })(); + let response = response.unwrap_or_else(|_| { + http::Response::builder() + .status(400) + .header("content-type", "application/json") + .body(bytes::Bytes::from_static( + b"{\"error\":\"invalid_request\",\"message\":\"invalid_request\"}", )) - })(); - let response = response.unwrap_or_else(|_| { - http::Response::builder() - .status(400) - .header("content-type", "application/json") - .body(bytes::Bytes::from_static( - b"{\"error\":\"invalid_request\",\"message\":\"invalid_request\"}", - )) - .expect("static response") - }); - let content_type = response.headers()["content-type"].as_bytes(); - let reply = tiny_http::Response::from_data(response.body().to_vec()) - .with_status_code(response.status().as_u16()) - .with_header( - tiny_http::Header::from_bytes(b"Content-Type", content_type) - .expect("static header"), - ); - let _ = request.respond(reply); - } - Ok(()) - }; - if serve().is_err() { - let _ = ready.send(false); + .expect("static response") + }); + let content_type = response.headers()["content-type"].as_bytes(); + let reply = tiny_http::Response::from_data(response.body().to_vec()) + .with_status_code(response.status().as_u16()) + .with_header( + tiny_http::Header::from_bytes(b"Content-Type", content_type) + .expect("static header"), + ); + let _ = request.respond(reply); } }); - if !started.recv().unwrap_or(false) { - let _ = thread.join(); - anyhow::bail!("host API initialization failed"); - } Ok(Self { server, thread: Some(thread), @@ -105,7 +92,7 @@ impl Drop for Listener { } } -/// Preserve the console lifecycle while sharing Cred with the host page listener. +/// Preserve the console lifecycle with one API for guests and host pages. pub async fn run_with_userland( path: &Path, userland: Option<&Path>, @@ -113,12 +100,26 @@ pub async fn run_with_userland( ) -> anyhow::Result<()> { let world = crate::world::World::open_with_userland(path, userland)?; let cred = Arc::new(Cred::open(&world.host_dir)?); - let _listener = Listener::start(cred.clone(), &world.host_dir, options)?; - let runtime = crate::process::Runtime::new(crate::world::World { - root: world.root.clone(), - host_dir: world.host_dir.clone(), - processes: world.processes.clone(), - })?; + let api = Arc::new(Api::open( + cred.clone(), + &world.host_dir, + &options.api_rp_id, + &options.api_origin, + )?); + let runtime = crate::process::Runtime::new_with_hooks( + crate::world::World { + root: world.root.clone(), + host_dir: world.host_dir.clone(), + processes: world.processes.clone(), + }, + cred.clone(), + crate::process::default_script(), + Some(api.clone()), + )?; + let listener = Listener::start_with_api(api, options)?; + runtime + .network_policy() + .deny_host_port(listener.address().port()); let services = crate::process::supervise(runtime.clone()); let result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> { tokio::runtime::Builder::new_current_thread() @@ -146,5 +147,6 @@ pub async fn run_with_userland( anyhow::anyhow!(text) }); services.shutdown().await; + drop(listener); result } diff --git a/host/src/api/ssh.rs b/host/src/api/ssh.rs index 51bfed3..a6e66ee 100644 --- a/host/src/api/ssh.rs +++ b/host/src/api/ssh.rs @@ -4,7 +4,7 @@ use crate::cred::{self, Caller, script::Script, types::FieldType}; use base64::{Engine, engine::general_purpose::STANDARD}; use serde_json::{Value, json}; -pub(super) fn handle(ctx: &Api<'_>, caller: &Caller, action: &str, body: &Value) -> Result { +pub(super) fn handle(ctx: &Api, caller: &Caller, action: &str, body: &Value) -> Result { let cred = &ctx.cred; let host = string(body, "host")?; let mut items = cred.item_list()?.into_iter().filter(|item| { diff --git a/host/src/cred/webauthn.rs b/host/src/cred/webauthn.rs index adbefbb..8e3e310 100644 --- a/host/src/cred/webauthn.rs +++ b/host/src/cred/webauthn.rs @@ -155,17 +155,18 @@ struct Pending { deadline: Instant, value: T, } -pub struct Passkeys<'a> { - db: &'a Connection, +/// Own the connection for a shared API, or borrow it for a local caller. +pub struct Passkeys> { + db: D, webauthn: Webauthn, registrations: HashMap>, assertions: HashMap>, } -impl<'a> Passkeys<'a> { - pub fn new(db: &'a Connection, rp_id: &str, origin: &str) -> Result { +impl> Passkeys { + pub fn new(db: D, rp_id: &str, origin: &str) -> Result { let origin = Url::parse(origin)?; let webauthn = WebauthnBuilder::new(rp_id, &origin)?.build()?; - db.execute_batch("CREATE TABLE IF NOT EXISTS passkey_credentials(id TEXT PRIMARY KEY,label TEXT NOT NULL,credential TEXT NOT NULL)")?; + db.borrow().execute_batch("CREATE TABLE IF NOT EXISTS passkey_credentials(id TEXT PRIMARY KEY,label TEXT NOT NULL,credential TEXT NOT NULL)")?; Ok(Self { db, webauthn, @@ -176,6 +177,7 @@ impl<'a> Passkeys<'a> { /// Database errors leave the manager locked. pub fn is_unlocked(&self) -> bool { self.db + .borrow() .query_row( "SELECT NOT EXISTS(SELECT 1 FROM passkey_credentials)", [], @@ -187,6 +189,7 @@ impl<'a> Passkeys<'a> { pub fn list(&self) -> Result> { let mut statement = self .db + .borrow() .prepare("SELECT id,label FROM passkey_credentials ORDER BY rowid")?; let rows = statement.query_map([], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) @@ -201,6 +204,7 @@ impl<'a> Passkeys<'a> { fn credentials(&self) -> Result> { let mut q = self .db + .borrow() .prepare("SELECT credential FROM passkey_credentials")?; q.query_map([], |r| r.get::<_, String>(0))? .map(|r| Ok(serde_json::from_str(&r?)?)) @@ -261,7 +265,7 @@ impl<'a> Passkeys<'a> { let credential = self .webauthn .finish_passkey_registration(&response, state)?; - self.db.execute( + self.db.borrow().execute( "INSERT INTO passkey_credentials(id,label,credential) VALUES(?1,?2,?3)", params![ serde_json::to_string(credential.cred_id())?, @@ -313,7 +317,7 @@ impl<'a> Passkeys<'a> { bail!("passkey counter did not increase"); } credential.update_credential(&result); - self.db.execute( + self.db.borrow().execute( "UPDATE passkey_credentials SET credential=?1 WHERE id=?2", params![ serde_json::to_string(credential)?, diff --git a/host/src/process/http.rs b/host/src/process/http.rs index e72380b..7db1772 100644 --- a/host/src/process/http.rs +++ b/host/src/process/http.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use wasmtime_wasi_http::{Error, RequestOptions}; use wasmtime_wasi_http::{WasiBody, WasiHttpHooks}; type Io = Box> + Send>; -pub type Router = fn(&Caller, Request) -> Response; +pub type Router = Arc; pub(super) struct Hooks { pub caller: Caller, pub cred: Arc, @@ -14,21 +14,6 @@ pub(super) struct Hooks { pub policy: Arc, pub router: Router, } -pub fn whoami(caller: &Caller, request: Request) -> Response { - let (status, body) = if request.uri().path() == "/whoami" { - (200, serde_json::to_vec(caller).unwrap()) - } else { - ( - 404, - serde_json::to_vec(&serde_json::json!({"error":"unknown host API route"})).unwrap(), - ) - }; - Response::builder() - .status(status) - .header("content-type", "application/json") - .body(Bytes::from(body)) - .unwrap() -} fn response(response: Response) -> (Response, Io) { ( response.map(|b| Full::new(b).map_err(|n| match n {}).boxed_unsync()), @@ -51,7 +36,7 @@ impl WasiHttpHooks for Hooks { let cred = self.cred.clone(); let script = self.script.clone(); let policy = self.policy.clone(); - let router = self.router; + let router = self.router.clone(); Box::new(async move { let (parts, mut body) = request.into_parts(); if parts @@ -60,7 +45,9 @@ impl WasiHttpHooks for Hooks { .is_some_and(|h| h.eq_ignore_ascii_case("host.sausage")) { let bytes = body.collect().await?.to_bytes(); - return Ok(response(router(&caller, Request::from_parts(parts, bytes)))); + return Ok(response( + crate::api::handle(&router, caller, Request::from_parts(parts, bytes)).await, + )); } if !matches!(parts.uri.scheme_str(), Some("http" | "https")) { while let Some(frame) = body.frame().await { diff --git a/host/src/process/mod.rs b/host/src/process/mod.rs index 2efb082..5397a81 100644 --- a/host/src/process/mod.rs +++ b/host/src/process/mod.rs @@ -164,7 +164,7 @@ impl Runtime { } fn with_compiler(world: World, compiler: Arc) -> anyhow::Result> { let cred = Arc::new(crate::cred::Cred::open(&world.host_dir)?); - Self::with_hooks(world, compiler, cred, default_script(), http::whoami) + Self::with_hooks(world, compiler, cred, default_script(), None) } /// Share credentials and the host API router with the host's other subsystems. pub fn new_with_hooks( @@ -173,21 +173,24 @@ impl Runtime { script: Arc, router: Option, ) -> anyhow::Result> { - Self::with_hooks( - world, - cache::shared()?, - cred, - script, - router.unwrap_or(http::whoami), - ) + Self::with_hooks(world, cache::shared()?, cred, script, router) } fn with_hooks( world: World, compiler: Arc, cred: Arc, script: Arc, - router: http::Router, + router: Option, ) -> anyhow::Result> { + let router = match router { + Some(api) => api, + None => Arc::new(crate::api::Api::open( + cred.clone(), + &world.host_dir, + "localhost", + "http://localhost:8081", + )?), + }; let network = Arc::new(net::NetPolicy::open(&world.host_dir)?); let engine = compiler.engine.clone(); let alive = Arc::new(AtomicBool::new(true)); @@ -215,6 +218,10 @@ impl Runtime { cache::precompile(runtime.compiler.clone(), runtime.world.root.clone()); Ok(runtime) } + /// Register native listener ports through the shared network policy. + pub fn network_policy(&self) -> &net::NetPolicy { + &self.network + } /// Number of distinct program contents compiled by this runtime. pub fn compiled_programs(&self) -> usize { self.cache.lock().unwrap().len() @@ -428,7 +435,7 @@ impl Runtime { cred: self.cred.clone(), script: self.script.clone(), policy: self.network.clone(), - router: self.router, + router: self.router.clone(), }, }, ); diff --git a/host/tests/api.rs b/host/tests/api.rs index d5a7987..cf37e3a 100644 --- a/host/tests/api.rs +++ b/host/tests/api.rs @@ -15,7 +15,7 @@ fn caller() -> Caller { conversations: vec!["conversation".into()], } } -async fn send(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<(u16, Value)> { +async fn send(api: &Api, method: &str, path: &str, body: Value) -> Result<(u16, Value)> { let request = http::Request::builder() .method(method) .uri(path) @@ -31,7 +31,7 @@ async fn send(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<(u serde_json::from_slice(response.body())?, )) } -async fn ok(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result { +async fn ok(api: &Api, method: &str, path: &str, body: Value) -> Result { let (status, value) = send(api, method, path, body).await?; assert_eq!(status, 200, "unexpected API status"); Ok(value) @@ -55,7 +55,7 @@ async fn public_routes_and_ssh() -> Result<()> { )?; let api = Api::new( cred, - Passkeys::new(&db, "localhost", "http://localhost:8080")?, + Passkeys::new(db, "localhost", "http://localhost:8080")?, EntryKey::open(&dir.path().join("entry.key"))?, ); let who = ok( @@ -221,7 +221,7 @@ async fn ssh_rejects_ambiguous_items() -> Result<()> { item(&cred, "two")?; let api = Api::new( cred, - Passkeys::new(&db, "localhost", "http://localhost:8080")?, + Passkeys::new(db, "localhost", "http://localhost:8080")?, EntryKey::open(&dir.path().join("entry.key"))?, ); assert_eq!( diff --git a/host/tests/api_auth.rs b/host/tests/api_auth.rs index 48fc7bd..58dcb6e 100644 --- a/host/tests/api_auth.rs +++ b/host/tests/api_auth.rs @@ -21,7 +21,7 @@ fn caller() -> Caller { conversations: vec!["conversation".into()], } } -async fn send(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<(u16, Value)> { +async fn send(api: &Api, method: &str, path: &str, body: Value) -> Result<(u16, Value)> { let response = handle( api, caller(), @@ -37,12 +37,12 @@ async fn send(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<(u serde_json::from_slice(response.body())?, )) } -async fn ok(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result { +async fn ok(api: &Api, method: &str, path: &str, body: Value) -> Result { let (status, value) = send(api, method, path, body).await?; assert_eq!(status, 200, "unexpected API status"); Ok(value) } -async fn page(api: &Api<'_>, path: &str) -> Result { +async fn page(api: &Api, path: &str) -> Result { let response = handle( api, caller(), @@ -62,7 +62,7 @@ async fn page(api: &Api<'_>, path: &str) -> Result { fn item(cred: &Cred, name: &str) -> Result { Ok(cred.create_item(serde_json::from_value(json!({"name":name,"rules":[],"transform_js":"","fields":[{"name":"first","field_type":"text"},{"name":"second","field_type":"text"}]}))?,&caller(),cred.unlocked()?)?) } -async fn file(api: &Api<'_>, item: &str, key: &str) -> Result { +async fn file(api: &Api, item: &str, key: &str) -> Result { let result = ok( api, "POST", @@ -72,7 +72,7 @@ async fn file(api: &Api<'_>, item: &str, key: &str) -> Result { .await?; Ok(result["request"].as_str().unwrap().into()) } -async fn register(api: &Api<'_>) -> Result> { +async fn register(api: &Api) -> Result> { let start: Challenge = serde_json::from_value( ok( api, @@ -94,7 +94,7 @@ async fn register(api: &Api<'_>) -> Result> { Ok(auth) } async fn assertion( - api: &Api<'_>, + api: &Api, auth: &mut WebauthnAuthenticator, path: &str, body: Value, @@ -114,7 +114,7 @@ async fn approvals_registration_and_revocation() -> Result<()> { let second = item(&cred, "review ")?; let api = Api::new( cred, - Passkeys::new(&db, "localhost", ORIGIN)?, + Passkeys::new(db, "localhost", ORIGIN)?, EntryKey::open(&dir.path().join("entry.key"))?, ); page(&api, "/register").await?; @@ -314,7 +314,7 @@ async fn approvals_registration_and_revocation() -> Result<()> { Ok(()) } -async fn encrypt(api: &Api<'_>, public_key: Value) -> Result { +async fn encrypt(api: &Api, public_key: Value) -> Result { use deno_core::{JsRuntime, RuntimeOptions}; let script = handle( api, @@ -358,7 +358,7 @@ async fn encrypted_entry_uses_the_host_page_code() -> Result<()> { let item = item(&cred, "secret")?; let api = Api::new( cred, - Passkeys::new(&db, "localhost", ORIGIN)?, + Passkeys::new(db, "localhost", ORIGIN)?, EntryKey::open(&dir.path().join("entry.key"))?, ); let mut auth = register(&api).await?; @@ -408,6 +408,7 @@ async fn encrypted_entry_uses_the_host_page_code() -> Result<()> { assert!(!api.cred.item_get(&item.id)?.fields[0].needs_value); ok(&api, "GET", &format!("/cred/items/{}", item.id), json!({})).await?; let master = std::fs::read(dir.path().join("cred.key"))?; + let db = rusqlite::Connection::open(dir.path().join("cred.sqlite"))?; let cipher: Vec = db.query_row( "SELECT ciphertext FROM fields WHERE item=?", [&item.id], diff --git a/host/tests/cred_proofs.rs b/host/tests/cred_proofs.rs index 26bedc9..c4a6cf9 100644 --- a/host/tests/cred_proofs.rs +++ b/host/tests/cred_proofs.rs @@ -42,7 +42,10 @@ fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) { .unwrap(); (dir, c, caller, item) } -fn register(host: &mut Passkeys<'_>, auth: &mut WebauthnAuthenticator) { +fn register( + host: &mut Passkeys<&rusqlite::Connection>, + auth: &mut WebauthnAuthenticator, +) { let start = host.start_registration("test".into(), None).unwrap(); let response = auth .do_registration(Url::parse("http://localhost:8000").unwrap(), start.options) @@ -50,7 +53,7 @@ fn register(host: &mut Passkeys<'_>, auth: &mut WebauthnAuthenticator, + host: &mut Passkeys<&rusqlite::Connection>, auth: &mut WebauthnAuthenticator, binding: Binding, ) -> webauthn::Verified { diff --git a/host/tests/hook.rs b/host/tests/hook.rs index d75d76d..2abe6c0 100644 --- a/host/tests/hook.rs +++ b/host/tests/hook.rs @@ -126,6 +126,8 @@ async fn identity_injection_denial_and_ambiguity() { assert_eq!(code, 0); let alias_out = String::from_utf8(alias_out).unwrap(); assert!(alias_out.contains("/bin/guest.wasm") && !alias_out.contains("/bin/alias")); + let items = run(&runtime, "http://host.sausage/cred/items".into(), vec![]).await; + assert!(items.contains("test item") && !items.contains(&token)); let server_token = token.clone(); let server = tokio::spawn(async move { let (mut socket, _) = listener.accept().await.unwrap(); diff --git a/host/tests/hook_api.rs b/host/tests/hook_api.rs new file mode 100644 index 0000000..bbec3f1 --- /dev/null +++ b/host/tests/hook_api.rs @@ -0,0 +1,102 @@ +//! The hook and native listener share ceremony state without exposing the native listener to guests. +use sausage::{ + api::{Api, Listener, Options as ApiOptions}, + cred::{Caller, Cred}, + exec::{Command, Exec, Options}, + process::Runtime, + world::World, +}; +use std::{ + io::{Read, Write}, + sync::Arc, +}; +#[path = "hook_guest.rs"] +mod hook_guest; + +#[tokio::test] +async fn one_api_for_pages_and_guest_requests() -> anyhow::Result<()> { + use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey}; + let dir = tempfile::tempdir()?; + let world = World::open(dir.path())?; + std::fs::create_dir_all(dir.path().join("world/bin"))?; + std::fs::copy( + hook_guest::component(), + dir.path().join("world/bin/guest.wasm"), + )?; + let cred = Arc::new(Cred::open(&world.host_dir)?); + let origin = "http://localhost:8081"; + let api = Arc::new(Api::open( + cred.clone(), + &world.host_dir, + "localhost", + origin, + )?); + let runtime = Runtime::new_with_hooks( + world, + cred, + sausage::process::default_script(), + Some(api.clone()), + )?; + let listener = Listener::start_with_api( + api.clone(), + ApiOptions { + api_listen: "127.0.0.1:0".parse()?, + api_rp_id: "localhost".into(), + api_origin: origin.into(), + }, + )?; + runtime + .network_policy() + .deny_host_port(listener.address().port()); + let response = sausage::api::handle( + &api, + Caller { + program: "/bin/guest.wasm".into(), + pid: 1, + conversations: vec![], + }, + http::Request::post("http://host.sausage/cred/passkeys/register/start") + .body(bytes::Bytes::from_static(b"{\"label\":\"shared\"}"))?, + ) + .await; + assert_eq!(response.status(), 200); + let challenge: serde_json::Value = serde_json::from_slice(response.body())?; + let mut authenticator = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let registration = authenticator.do_registration( + url::Url::parse(origin)?, + serde_json::from_value(challenge["options"].clone())?, + )?; + let body = serde_json::to_vec( + &serde_json::json!({"challenge_id":challenge["id"], "response": registration}), + )?; + let mut connection = std::net::TcpStream::connect(listener.address())?; + write!( + connection, + "POST /cred/passkeys/register/finish HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Length: {}\r\n\r\n", + body.len() + )?; + connection.write_all(&body)?; + let mut reply = String::new(); + connection.read_to_string(&mut reply)?; + assert!(reply.starts_with("HTTP/1.1 200")); + for (uri, expected) in [ + ("http://host.sausage/cred/passkeys".to_owned(), "shared"), + (format!("http://{}/whoami", listener.address()), "error"), + ] { + let mut out = Vec::new(); + let code = runtime + .exec( + Command::Argv(vec!["/bin/guest.wasm".into(), uri, "GET".into()]), + Options { + cwd: "/".into(), + timeout: Some(std::time::Duration::from_secs(30)), + ..Default::default() + }, + &mut |_, b| out.extend_from_slice(b), + ) + .await?; + assert_eq!(code, 0); + assert!(String::from_utf8(out)?.contains(expected)); + } + Ok(()) +}