Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/knut

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
}