crates/knut-wasm-host/src/tests.rs
//! The host's own gates. Every one of them is about the host's *contract* — a
//! directory of bytes, a content type per extension, one redirect, one frame
//! stream — and NONE of them names a component, a framework or a build tool: those
//! strings all arrive from the caller, and the tests assert they come back out
//! verbatim.
use std::path::{Path, PathBuf};
use hyper::StatusCode;
use crate::*;
/// A caller's spec, standing in for knut's: three required files, an env var, and a
/// recipe string the host cannot possibly have known.
fn spec() -> BundleSpec {
BundleSpec::new(
"testhost: the widget bundle is not available.",
["index.html", "glue.js", "widget_bg.wasm"],
"run `cargo xtask wrap-wasm` in the widget checkout, then point testhost at dist/",
)
.with_env_var("WIDGET_DIST")
}
/// A scratch directory holding exactly `files`.
fn dir_with(tag: &str, files: &[(&str, &[u8])]) -> PathBuf {
let dir = std::env::temp_dir().join(format!("knut-wasm-host-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for (name, bytes) in files {
std::fs::write(dir.join(name), bytes).unwrap();
}
dir
}
/// RED 1 — a missing directory, and an incomplete one, must refuse LOUDLY: every
/// file that is not there by name, the directory that was looked in, and the
/// CALLER'S recipe for producing them. Not a blank page, not a bare `io::Error`.
#[test]
fn a_missing_or_incomplete_directory_refuses_with_the_callers_recipe() {
let spec = spec();
// (a) the directory does not exist at all.
let gone = std::env::temp_dir().join("knut-wasm-host-nothing-here-at-all");
let _ = std::fs::remove_dir_all(&gone);
let e = spec.load(&gone).unwrap_err().to_string();
for f in ["index.html", "glue.js", "widget_bg.wasm"] {
assert!(e.contains(f), "refusal does not name {f}: {e}");
}
assert!(e.contains(&gone.display().to_string()), "refusal does not name the dir: {e}");
assert!(e.contains("cargo xtask wrap-wasm"), "refusal does not carry the caller's recipe: {e}");
assert!(e.contains("WIDGET_DIST"), "refusal does not name the caller's env var: {e}");
// (b) the directory exists but one file is missing and one is empty.
let dir = dir_with(
"incomplete",
&[("index.html", b"<!doctype html><script src=./glue.js>"), ("glue.js", b"")],
);
let e = spec.load(&dir).unwrap_err().to_string();
assert!(e.contains("glue.js: empty file"), "{e}");
assert!(e.contains("widget_bg.wasm:"), "{e}");
// index.html was fine, so it is not accused of anything.
assert!(!e.contains("index.html:"), "{e}");
}
/// RED 2 — a file called `.wasm` that is not a WebAssembly module is rejected by
/// name. The browser's `instantiateStreaming` would have failed on it anyway, in a
/// console nobody is reading; the host says it at load time instead. This is the
/// ONE thing the host knows about the payload, and its name says why.
#[test]
fn a_wasm_file_whose_bytes_are_not_wasm_is_rejected() {
let dir = dir_with(
"notwasm",
&[
("index.html", b"<!doctype html><script type=module src=./glue.js></script>"),
("glue.js", b"export default async function init() {}\n"),
// A 404 page saved over the wasm — the classic way a bundle dir rots.
("widget_bg.wasm", b"<!doctype html><title>404</title>"),
],
);
let e = spec().load(&dir).unwrap_err().to_string();
assert!(e.contains("widget_bg.wasm: not a WebAssembly module"), "{e}");
// The other two files were fine and are not accused.
assert!(!e.contains("index.html:"), "{e}");
assert!(!e.contains("glue.js:"), "{e}");
// The real magic passes.
let ok = dir_with(
"iswasm",
&[
("index.html", b"<!doctype html><script type=module src=./glue.js></script>"),
("glue.js", b"export default async function init() {}\n"),
("widget_bg.wasm", b"\0asm\x01\0\0\0"),
],
);
let bundle = spec().load(&ok).expect("a bundle");
assert!(bundle.file("widget_bg.wasm").unwrap().starts_with(&WASM_MAGIC));
}
/// RED 3 — content type comes from the EXTENSION, off a small honest table, and an
/// extension that is not in the table is REFUSED. Guessing `application/octet-stream`
/// would be a lie a browser acts on; guessing `text/html` would be a hole. The host
/// has no idea what `facet_wrapped.js` means and never looks at a file NAME.
#[test]
fn an_unknown_extension_is_refused_rather_than_guessed() {
// The table: the handful a wasm bundle actually ships.
assert_eq!(content_type(Path::new("anything.html")), Some("text/html; charset=utf-8"));
assert_eq!(content_type(Path::new("a/b/anything.js")), Some("text/javascript; charset=utf-8"));
assert_eq!(content_type(Path::new("anything.mjs")), Some("text/javascript; charset=utf-8"));
assert_eq!(content_type(Path::new("anything.wasm")), Some("application/wasm"));
assert_eq!(content_type(Path::new("anything.json")), Some("application/json"));
assert_eq!(content_type(Path::new("anything.css")), Some("text/css; charset=utf-8"));
// Not in the table: refused, both directions of "clever".
for hostile in ["payload.exe", "notes.md", "archive.tar.gz", "README", "cgi.php", "x.htm."] {
assert_eq!(content_type(Path::new(hostile)), None, "{hostile} was given a content type");
}
// A dot-file is a name, not an extension — `.env` must not read as "env".
assert_eq!(content_type(Path::new(".env")), None);
// The table is honest: every entry it advertises resolves.
for (ext, ty) in MEDIA_TYPES {
assert_eq!(content_type(Path::new(&format!("f.{ext}"))), Some(ty));
}
}
/// RED 4 — the entry redirect. `GET /` goes to the loader page with two strings the
/// CALLER supplied: which component to mount, and where its data comes from. The
/// host builds the URL and carries both verbatim — it has no opinion about what
/// `pipeline` means or what lives at `/events`.
#[test]
fn the_entry_redirect_carries_the_callers_component_and_data_url_verbatim() {
let entry = EntryPoint::new("/index.html", "pipeline", "/events");
assert_eq!(entry.url(), "/index.html?component=pipeline&events=/events");
assert_eq!(entry.loader_path(), "/index.html");
assert_eq!(entry.component(), "pipeline");
assert_eq!(entry.data_url(), "/events");
// Verbatim really means verbatim: a different loader file name, a component
// this crate has never heard of, an absolute data URL on another origin.
let other = EntryPoint::new("/facet/loader.htm", "map-3d", "https://data.example/x?y=1");
assert_eq!(
other.url(),
"/facet/loader.htm?component=map-3d&events=https://data.example/x?y=1"
);
// And the redirect the host actually serves says the same thing: 303, Location.
let resp = entry.redirect();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let location = resp.headers()[hyper::header::LOCATION].to_str().unwrap();
assert_eq!(location, entry.url());
assert!(location.contains("component=pipeline") && location.contains("events=/events"));
}
/// RED 5 — the SSE body. In: already-encoded frame strings. Out: `text/event-stream`.
/// The host frames what it is given, in order, and stops on the CALLER'S terminal
/// frame — it does not know that `{"done":true}` means done, only that the caller
/// sent it last.
#[tokio::test]
async fn the_sse_body_frames_what_it_is_given_and_ends_on_the_callers_terminal_frame() {
use http_body_util::BodyExt;
let (tx, body) = sse_channel();
tx.frame(r#"{"stages":[]}"#);
tx.frame(r#"{"stages":[{"status":"running"}]}"#);
// The caller's own terminal frame — this crate never spells one.
tx.finish(r#"{"done":true}"#);
drop(tx);
let bytes = body.collect().await.unwrap().to_bytes();
let text = String::from_utf8(bytes.to_vec()).unwrap();
// Wire format: `data: <frame>` then a blank line, one block per frame.
assert_eq!(
text,
"data: {\"stages\":[]}\n\n\
data: {\"stages\":[{\"status\":\"running\"}]}\n\n\
data: {\"done\":true}\n\n"
);
let frames: Vec<&str> = text.lines().filter_map(|l| l.strip_prefix("data:")).map(str::trim).collect();
assert_eq!(frames.len(), 3);
assert_eq!(*frames.last().unwrap(), r#"{"done":true}"#);
// The error terminal frame is just as much the caller's string.
let (tx, body) = sse_channel();
tx.frame(r#"{"stages":[]}"#);
tx.finish(r#"{"error":"stream broke"}"#);
drop(tx);
let text = String::from_utf8(body.collect().await.unwrap().to_bytes().to_vec()).unwrap();
assert!(text.ends_with("data: {\"error\":\"stream broke\"}\n\n"), "{text:?}");
// A frame with an embedded newline is still ONE event: SSE says every line of
// the payload carries its own `data:` prefix, and a lone `\n` would otherwise
// end the event early.
let (tx, body) = sse_channel();
tx.frame("two\nlines");
drop(tx);
let text = String::from_utf8(body.collect().await.unwrap().to_bytes().to_vec()).unwrap();
assert_eq!(text, "data: two\ndata: lines\n\n");
assert_eq!(SSE_CONTENT_TYPE, "text/event-stream");
}
// ─────────────────────────────────────────────────────────────────────────────
// The mounted host.
// ─────────────────────────────────────────────────────────────────────────────
/// A loaded stub bundle: a loader page, a glue module, a real-magic wasm.
fn stub_host(tag: &str) -> WasmHost {
let dir = dir_with(
tag,
&[
("index.html", b"<!doctype html><script type=module src=./glue.js></script>"),
("glue.js", b"export default async function init() {}\n"),
("widget_bg.wasm", b"\0asm\x01\0\0\0"),
],
);
WasmHost::new(spec().load(&dir), EntryPoint::new("/index.html", "pipeline", "/events"))
}
async fn body_of(resp: hyper::http::Response<HostBody>) -> String {
use http_body_util::BodyExt;
String::from_utf8(resp.into_body().collect().await.unwrap().to_bytes().to_vec()).unwrap()
}
fn ct(resp: &hyper::http::Response<HostBody>) -> &str {
resp.headers()[hyper::header::CONTENT_TYPE].to_str().unwrap()
}
/// Files come out with the type their EXTENSION says, and the wasm comes out whole.
#[tokio::test]
async fn the_host_serves_each_file_with_the_type_its_extension_says() {
let host = stub_host("serves");
let get = |p: &str| host.respond(&hyper::Method::GET, p);
let resp = get("/index.html");
assert_eq!(resp.status(), StatusCode::OK);
assert!(ct(&resp).starts_with("text/html"));
let resp = get("/glue.js");
assert_eq!(resp.status(), StatusCode::OK);
assert!(ct(&resp).starts_with("text/javascript"));
let resp = get("/widget_bg.wasm");
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(ct(&resp), "application/wasm");
assert!(body_of(resp).await.starts_with("\0asm"));
// `/` is the redirect; a HEAD keeps the headers and drops the body.
let resp = get("/");
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let resp = host.respond(&hyper::Method::HEAD, "/index.html");
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(body_of(resp).await, "");
// Anything but GET/HEAD is refused, not silently read.
assert_eq!(get_status(&host, hyper::Method::POST, "/index.html"), StatusCode::METHOD_NOT_ALLOWED);
}
fn get_status(host: &WasmHost, method: hyper::Method, path: &str) -> StatusCode {
host.respond(&method, path).status()
}
/// The traversal guard, and the "not part of this bundle" floor: a path is rebuilt
/// from plain segments or it is a 404 before anything is opened.
#[test]
fn the_host_refuses_traversal_dot_files_and_files_it_was_never_given() {
assert_eq!(relative_file("/glue.js"), Some(PathBuf::from("glue.js")));
assert_eq!(relative_file("/a/./b/c.js"), Some(PathBuf::from("a/b/c.js")));
for hostile in ["/../Cargo.toml", "/a/../../etc/passwd", "/.git/config", "/", "/a/.ssh/id"] {
assert_eq!(relative_file(hostile), None, "{hostile}");
}
// `//etc/passwd` is NOT traversal: the extra slash folds away and what is left
// is still a path INSIDE the bundle dir. The guard exists to stop an escape,
// and this does not escape — refusing it would be theatre.
assert_eq!(relative_file("//etc/passwd"), Some(PathBuf::from("etc/passwd")));
let host = stub_host("traversal");
for hostile in ["/../Cargo.toml", "/.env", "/a/../../etc/passwd"] {
assert_eq!(get_status(&host, hyper::Method::GET, hostile), StatusCode::NOT_FOUND, "{hostile}");
}
// A file with a known extension that is simply not in the bundle: 404, and the
// default host does not go to disk for it at all.
assert_eq!(get_status(&host, hyper::Method::GET, "/nope.js"), StatusCode::NOT_FOUND);
// A known-to-exist file with an extension outside the table stays unserved.
assert_eq!(get_status(&host, hyper::Method::GET, "/index.rs"), StatusCode::NOT_FOUND);
}
/// No bundle → every path under the mount answers 503 with the WHOLE refusal: the
/// missing files, the directory, the caller's recipe. Never a blank page.
#[tokio::test]
async fn a_host_with_no_bundle_refuses_loudly_on_every_path() {
let gone = std::env::temp_dir().join("knut-wasm-host-not-a-directory");
let _ = std::fs::remove_dir_all(&gone);
let host = WasmHost::new(
spec().load(&gone),
EntryPoint::new("/index.html", "pipeline", "/events"),
);
assert!(!host.is_available());
for path in ["/", "/index.html", "/glue.js", "/widget_bg.wasm"] {
let resp = host.respond(&hyper::Method::GET, path);
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE, "{path}");
assert!(ct(&resp).starts_with("text/plain"), "{path}");
let body = body_of(resp).await;
assert!(body.contains("widget_bg.wasm") && body.contains("wrap-wasm"), "{path}: {body}");
}
}
/// Mounted under a prefix, the host answers there and nowhere else — what a server
/// that also carries gRPC verbs on the same listener needs.
#[test]
fn a_prefixed_mount_answers_only_under_its_prefix() {
let host = stub_host("prefix").with_prefix("/facet");
assert_eq!(host.prefix(), "/facet");
assert_eq!(get_status(&host, hyper::Method::GET, "/facet/glue.js"), StatusCode::OK);
assert_eq!(get_status(&host, hyper::Method::GET, "/facet/"), StatusCode::SEE_OTHER);
assert_eq!(get_status(&host, hyper::Method::GET, "/facet"), StatusCode::SEE_OTHER);
// Not ours, and not a chance to walk out of the mount either.
assert_eq!(get_status(&host, hyper::Method::GET, "/glue.js"), StatusCode::NOT_FOUND);
assert_eq!(get_status(&host, hyper::Method::GET, "/facetish/glue.js"), StatusCode::NOT_FOUND);
// `with_prefix` normalises: bare, leading and trailing slashes all mean one mount.
for spelling in ["facet", "/facet", "/facet/"] {
assert_eq!(stub_host("prefix").with_prefix(spelling).prefix(), "/facet");
}
}
/// The host IS a `hyper::service::Service` — that is the whole point of the
/// dependency list. A hyper-based server (korp's, under tonic) mounts it with no
/// web framework anywhere in the graph.
#[tokio::test]
async fn the_host_is_a_hyper_service() {
use hyper::service::Service as _;
let host = stub_host("hyperservice");
let req = hyper::Request::builder()
.uri("/widget_bg.wasm")
.body(String::new())
.unwrap();
let resp = host.call(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(ct(&resp), "application/wasm");
}
/// The optional `axum` wrapper: a fallback-only Router, so a caller's own data
/// routes merge in and WIN, while `/` and every bundle file fall through to the
/// host. This is the shape knut's own viewer mounts.
#[cfg(feature = "axum")]
#[tokio::test]
async fn the_axum_router_is_a_fallback_that_a_callers_data_routes_win_over() {
use axum::body::Body as AxumBody;
use http_body_util::BodyExt;
use tower::ServiceExt;
let data = axum::Router::new().route(
"/events",
axum::routing::get(|| async { crate::sse_response(demo_stream()) }),
);
let app = data.merge(crate::router(stub_host("axum")));
let get = |uri: &str| {
let app = app.clone();
let uri = uri.to_string();
async move {
app.oneshot(hyper::Request::builder().uri(uri).body(AxumBody::empty()).unwrap())
.await
.unwrap()
}
};
// The caller's route wins over the fallback, and carries the SSE body.
let resp = get("/events").await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(resp.headers()[hyper::header::CONTENT_TYPE], SSE_CONTENT_TYPE);
let text = String::from_utf8(resp.into_body().collect().await.unwrap().to_bytes().to_vec()).unwrap();
assert_eq!(text, "data: {\"stages\":[]}\n\ndata: {\"done\":true}\n\n");
// `/` and the bundle files come off the host.
let resp = get("/").await;
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
resp.headers()[hyper::header::LOCATION],
"/index.html?component=pipeline&events=/events"
);
let resp = get("/widget_bg.wasm").await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(resp.headers()[hyper::header::CONTENT_TYPE], "application/wasm");
// And an extension the table does not carry is still refused, even here.
assert_eq!(get("/secrets.env.exe").await.status(), StatusCode::NOT_FOUND);
}
/// Two frames and the caller's terminal frame, already queued — a stand-in for a
/// producer task.
#[cfg(feature = "axum")]
fn demo_stream() -> SseBody {
let (tx, body) = sse_channel();
tx.frame(r#"{"stages":[]}"#);
tx.finish(r#"{"done":true}"#);
body
}