Files
sausage/spikes/s5-http/src/main.rs
T
surma 9e9cde5241 spikes: six v3 spikes (V8 harness, WASI shell, git, builder, http hook, esbuild)
Throwaway prototypes for Sausage v3. Plan and decisions:
~/research/sausage-v3/PLAN.md. Results and evidence: the spike READMEs.
2026-11-06 14:03:20 +00:00

388 lines
14 KiB
Rust

//! Runs the HTTP hook and verifies real WASI guests against a local test server.
use anyhow::Result;
use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper::{server::conn::http1, service::service_fn};
use hyper_util::rt::TokioIo;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use wasmtime::component::{Component, Linker, ResourceTable};
use wasmtime::{Engine, Store};
use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
use wasmtime_wasi_http::{WasiBody, WasiHttpCtx, WasiHttpCtxView, WasiHttpHooks, WasiHttpView};
type Io = Box<dyn Future<Output = Result<(), wasmtime_wasi_http::Error>> + Send>;
struct Item {
name: &'static str,
scheme: &'static str,
hosts: Vec<&'static str>,
prefixes: Vec<&'static str>,
methods: Option<Vec<http::Method>>,
free: bool,
}
impl Item {
fn matches(&self, request: &http::Request<WasiBody>) -> bool {
let uri = request.uri();
uri.scheme_str() == Some(self.scheme)
&& self
.hosts
.iter()
.any(|pattern| host_matches(pattern, uri.host().unwrap_or("")))
&& self.prefixes.iter().any(|p| uri.path().starts_with(p))
&& self
.methods
.as_ref()
.is_none_or(|m| m.contains(request.method()))
}
}
fn host_matches(pattern: &str, host: &str) -> bool {
match pattern.strip_prefix("*.") {
Some(suffix) => host
.strip_suffix(suffix)
.is_some_and(|p| p.ends_with('.') && p.len() > 1),
None => pattern == host,
}
}
struct Hooks {
id: String,
grant: bool,
token: Arc<String>,
items: Arc<Vec<Item>>,
outgoing: Arc<AtomicUsize>,
}
fn response(
status: u16,
text: String,
) -> wasmtime_wasi_http::Result<(http::Response<WasiBody>, Io)> {
let body = Full::new(Bytes::from(text))
.map_err(|never| match never {})
.boxed_unsync();
Ok((
http::Response::builder().status(status).body(body).unwrap(),
Box::new(async { Ok(()) }),
))
}
impl WasiHttpHooks for Hooks {
fn send_request(
&mut self,
mut request: http::Request<WasiBody>,
options: Option<wasmtime_wasi_http::RequestOptions>,
fut: Io,
) -> Box<dyn Future<Output = wasmtime_wasi_http::Result<(http::Response<WasiBody>, Io)>> + Send>
{
drop(fut);
if request.uri().host() == Some("host.sausage") {
let result = response(200, format!("caller={}", self.id));
return Box::new(async move {
request.into_body().collect().await?;
result
});
}
let matches: Vec<_> = self.items.iter().filter(|i| i.matches(&request)).collect();
if matches.len() > 1 {
return Box::new(async move {
request.into_body().collect().await?;
Err(wasmtime_wasi_http::Error::InternalError(Some(
"two credential items match".into(),
)))
});
}
if let Some(item) = matches.first() {
if !self.grant && !item.free {
let result = response(403, format!("grant required: {}", item.name));
return Box::new(async move {
request.into_body().collect().await?;
result
});
}
// Stand-in for the isolated JavaScript transform. Only the host owns the token.
let transform = |r: &mut http::Request<WasiBody>| {
r.headers_mut().insert(
http::header::AUTHORIZATION,
format!("Bearer {}", self.token).parse().unwrap(),
);
};
transform(&mut request);
}
self.outgoing.fetch_add(1, Ordering::SeqCst);
Box::new(async move {
// Hyper drops unknown-length GET bodies before the guest can finish its WASI stream.
// Complete the body first. Collected preserves its data and trailers.
let (parts, body) = request.into_parts();
let body = body.collect().await?.map_err(|never| match never {});
let request = http::Request::from_parts(parts, body);
let (res, io) = wasmtime_wasi_http::default_send_request(request, options).await?;
Ok((res.map(BodyExt::boxed_unsync), Box::new(io) as Io))
})
}
}
struct State {
ctx: WasiCtx,
table: ResourceTable,
http: WasiHttpCtx,
hooks: Hooks,
}
impl WasiView for State {
fn ctx(&mut self) -> WasiCtxView<'_> {
WasiCtxView {
ctx: &mut self.ctx,
table: &mut self.table,
}
}
}
impl WasiHttpView for State {
fn http(&mut self) -> WasiHttpCtxView<'_> {
WasiHttpCtxView {
ctx: &mut self.http,
table: &mut self.table,
hooks: &mut self.hooks,
}
}
}
#[tokio::main]
async fn main() -> Result<()> {
let component_path = std::env::args().nth(1).expect("guest component path");
std::fs::create_dir_all("/tmp/s5-http/world")?;
std::fs::write("/tmp/s5-http/world/public.txt", "public fixture")?;
// A fresh host-only token is absent from guest source, argv, env, and preopened files.
let token = Arc::new(format!(
"s5-secret-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_nanos()
));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let port = listener.local_addr()?.port();
let received = Arc::new(AtomicUsize::new(0));
let count = received.clone();
let server_token = token.clone();
let server = tokio::spawn(async move {
loop {
let (socket, _) = listener.accept().await.unwrap();
let count = count.clone();
let token = server_token.clone();
let service = service_fn(move |request: hyper::Request<hyper::body::Incoming>| {
let count = count.clone();
let token = token.clone();
async move {
let (parts, body) = request.into_parts();
// Read through the terminal chunk before answering and closing the connection.
body.collect().await.unwrap();
assert_eq!(parts.headers["x-guest"], "unchanged");
let text = match parts.headers.get(http::header::AUTHORIZATION) {
Some(value) => {
assert_eq!(value.to_str().unwrap(), format!("Bearer {}", token));
"authorization=present; token=correct; x-guest=unchanged"
}
None => "authorization=absent; x-guest=unchanged",
};
count.fetch_add(1, Ordering::SeqCst);
Ok::<_, std::convert::Infallible>(http::Response::new(Full::new(
Bytes::from_static(text.as_bytes()),
)))
}
});
http1::Builder::new()
.keep_alive(false)
.serve_connection(TokioIo::new(socket), service)
.await
.unwrap();
}
});
let items = Arc::new(vec![
Item {
name: "test-token",
scheme: "http",
hosts: vec!["127.0.0.1"],
prefixes: vec!["/private"],
methods: Some(vec![http::Method::GET]),
free: false,
},
Item {
name: "collision",
scheme: "http",
hosts: vec!["127.0.0.1"],
prefixes: vec!["/private/overlap"],
methods: None,
free: false,
},
Item {
name: "free-token",
scheme: "http",
hosts: vec!["127.0.0.1"],
prefixes: vec!["/free"],
methods: None,
free: true,
},
]);
let engine = Engine::default();
let component = Component::from_file(&engine, component_path)?;
let mut linker = Linker::<State>::new(&engine);
wasmtime_wasi::p2::add_to_linker_async(&mut linker)?;
wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?;
let outgoing = Arc::new(AtomicUsize::new(0));
let cases = [
(
"identity-a",
"http://host.sausage/whoami".to_string(),
"GET",
false,
"caller=instance-1",
0,
),
(
"identity-b",
"http://host.sausage/whoami".to_string(),
"GET",
false,
"caller=instance-2",
0,
),
(
"granted",
format!("http://127.0.0.1:{port}/private"),
"GET",
true,
"authorization=present; token=correct",
1,
),
(
"unmatched",
format!("http://127.0.0.1:{port}/public"),
"GET",
false,
"authorization=absent",
1,
),
(
"denied",
format!("http://127.0.0.1:{port}/private"),
"GET",
false,
"status=403\nresponse-headers={}\nbody=grant required: test-token",
0,
),
(
"ambiguous",
format!("http://127.0.0.1:{port}/private/overlap"),
"GET",
true,
"two credential items match",
0,
),
(
"method-mismatch",
format!("http://127.0.0.1:{port}/private"),
"POST",
false,
"authorization=absent",
1,
),
(
"free-use",
format!("http://127.0.0.1:{port}/free"),
"POST",
false,
"authorization=present; token=correct",
1,
),
];
for (index, (name, url, method, grant, expected, sockets)) in cases.into_iter().enumerate() {
let before = outgoing.load(Ordering::SeqCst);
let stdout = wasmtime_wasi::p2::pipe::MemoryOutputPipe::new(16384);
let stderr = wasmtime_wasi::p2::pipe::MemoryOutputPipe::new(16384);
let mut ctx = WasiCtx::builder();
ctx.args(&["s5-curl", &url, method])
.env("PUBLIC", "visible")
.stdout(stdout.clone())
.stderr(stderr.clone());
ctx.preopened_dir("/tmp/s5-http/world", "/", wasmtime_wasi::FsPerms::ReadOnly)?;
let state = State {
ctx: ctx.build(),
table: ResourceTable::new(),
http: WasiHttpCtx::new(),
hooks: Hooks {
id: format!("instance-{}", index + 1),
grant,
token: token.clone(),
items: items.clone(),
outgoing: outgoing.clone(),
},
};
let mut store = Store::new(&engine, state);
let command = wasmtime_wasi::p2::bindings::Command::instantiate_async(
&mut store, &component, &linker,
)
.await?;
assert!(command.wasi_cli_run().call_run(&mut store).await?.is_ok());
let out = String::from_utf8(stdout.contents().to_vec())?;
let err = String::from_utf8(stderr.contents().to_vec())?;
assert!(
!out.contains(token.as_str()) && !err.contains(token.as_str()),
"secret leaked"
);
assert!(
out.contains("environment=[(\"PUBLIC\", \"visible\")]"),
"{out}"
);
assert!(out.contains("files=[\"public.txt\"]"), "{out}");
assert!(out.contains("file=public fixture"), "{out}");
assert!(
out.contains("request-headers={\"x-guest\": \"unchanged\"}"),
"{out}"
);
assert!(out.contains(expected), "{name}: {out}\n{err}");
assert_eq!(outgoing.load(Ordering::SeqCst) - before, sockets, "{name}");
println!("PASS {name}: {expected}; transport calls={sockets}; guest secret absent");
}
assert_eq!(received.load(Ordering::SeqCst), 4);
server.abort();
println!("PASS all 8 guest cases; local server received exactly 4 requests");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rule_dimensions() {
let item = Item {
name: "test",
scheme: "http",
hosts: vec!["*.example.test"],
prefixes: vec!["/api"],
methods: Some(vec![http::Method::GET]),
free: false,
};
for (url, method, expected) in [
("http://api.example.test/api/v1", "GET", true),
("https://api.example.test/api/v1", "GET", false),
("http://example.test/api/v1", "GET", false),
("http://api.example.test/public", "GET", false),
("http://api.example.test/api/v1", "POST", false),
] {
let body = Full::new(Bytes::new())
.map_err(|n| match n {})
.boxed_unsync();
let request = http::Request::builder()
.uri(url)
.method(method)
.body(body)
.unwrap();
assert_eq!(item.matches(&request), expected, "{url} {method}");
}
}
#[test]
fn host_patterns() {
assert!(host_matches("*.example.test", "api.example.test"));
assert!(host_matches("*.example.test", "a.b.example.test"));
assert!(!host_matches("*.example.test", "example.test"));
assert!(!host_matches("*.example.test", "badexample.test"));
assert!(host_matches("example.test", "example.test"));
assert!(!host_matches("example.test", "api.example.test"));
}
}