Gunnar

nordisk/heimdall

crates/heimdall-wasm/tests/link.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! The linker offers `emit-file` and nothing else. A guest importing WASI (or
//! any other host function) is refused at load, before any of its code runs;
//! so are core modules, junk bytes, and components without `run`.
//! Heimdall is a defensive sandbox.

mod common;
use common::*;
use heimdall_wasm::{LoadRefusal, RunLimits, WIT};

const WASI_CLI: &str = r#"
package wasi:cli@0.2.0;
interface environment {
  get-environment: func() -> list<tuple<string, string>>;
}
"#;

const WASI_FS: &str = r#"
package wasi:filesystem@0.2.0;
interface preopens {
  get-directories: func() -> list<string>;
}
"#;

fn job_plus(import: &str) -> String {
    format!(
        r#"package heimdall:attack@0.1.0;
world job-plus {{
  record param {{ key: string, value: string }}
  record output {{ note: string }}
  record refusal {{ reason: string }}
  import emit-file: func(name: string, bytes: list<u8>);
  {import}
  export run: func(input: list<u8>, params: list<param>) -> result<output, refusal>;
}}"#
    )
}

fn import_refused(r: Result<heimdall_wasm::Guest, LoadRefusal>) -> Vec<String> {
    match r {
        Err(LoadRefusal::ImportRefused { imports }) => imports,
        Err(e) => panic!("refused for another reason: {e}"),
        Ok(_) => panic!("a guest with a foreign import loaded"),
    }
}

#[test]
fn a_guest_importing_wasi_environment_is_refused_at_link() {
    let kit = Kit::new("wasi-env");
    let core = Wat::new("(call $env (i32.const 2048)) (call $ok (i32.const 0) (i32.const 0))")
        .extra(r#"(import "wasi:cli/environment@0.2.0" "get-environment" (func $env (param i32)))"#)
        .text();
    // Imports must come first in a core module.
    let core = hoist_imports(&core);
    let bytes = component_with(
        &core,
        &[
            ("wasi-cli.wit", WASI_CLI),
            (
                "attack.wit",
                &job_plus("import wasi:cli/environment@0.2.0;"),
            ),
        ],
        "job-plus",
    );
    let imports = import_refused(kit.try_load("wasi-env", &bytes));
    assert!(
        imports.iter().any(|i| i.contains("wasi:cli/environment")),
        "{imports:?}"
    );
}

#[test]
fn a_guest_importing_wasi_filesystem_is_refused_at_link() {
    let kit = Kit::new("wasi-fs");
    let core = hoist_imports(
        &Wat::new("(call $dirs (i32.const 2048)) (call $ok (i32.const 0) (i32.const 0))")
            .extra(r#"(import "wasi:filesystem/preopens@0.2.0" "get-directories" (func $dirs (param i32)))"#)
            .text(),
    );
    let bytes = component_with(
        &core,
        &[
            ("wasi-fs.wit", WASI_FS),
            (
                "attack.wit",
                &job_plus("import wasi:filesystem/preopens@0.2.0;"),
            ),
        ],
        "job-plus",
    );
    let imports = import_refused(kit.try_load("wasi-fs", &bytes));
    assert!(
        imports
            .iter()
            .any(|i| i.contains("wasi:filesystem/preopens")),
        "{imports:?}"
    );
}

#[test]
fn a_guest_importing_an_unknown_root_function_is_refused_at_link() {
    let kit = Kit::new("spawn");
    let core = hoist_imports(
        &Wat::new(
            "(call $spawn (i32.const 0) (i32.const 0)) (call $ok (i32.const 0) (i32.const 0))",
        )
        .extra(r#"(import "$root" "spawn" (func $spawn (param i32 i32)))"#)
        .text(),
    );
    let bytes = component_with(
        &core,
        &[("attack.wit", &job_plus("import spawn: func(cmd: string);"))],
        "job-plus",
    );
    assert_eq!(
        import_refused(kit.try_load("spawn", &bytes)),
        vec!["spawn".to_string()]
    );
}

#[test]
fn a_guest_whose_emit_file_has_another_type_is_refused_at_link() {
    let kit = Kit::new("emit-type");
    let wit = r#"package heimdall:attack@0.1.0;
world job-bad {
  record param { key: string, value: string }
  record output { note: string }
  record refusal { reason: string }
  import emit-file: func(name: string) -> u32;
  export run: func(input: list<u8>, params: list<param>) -> result<output, refusal>;
}"#;
    let core = Wat::new("(call $ok (i32.const 0) (i32.const 0))")
        .text()
        .replace(
            r#"(import "$root" "emit-file" (func $emit (param i32 i32 i32 i32)))"#,
            r#"(import "$root" "emit-file" (func $emit (param i32 i32) (result i32)))"#,
        );
    let r = kit.try_load(
        "emittype",
        &component_with(&core, &[("bad.wit", wit)], "job-bad"),
    );
    assert!(matches!(r, Err(LoadRefusal::Link { .. })), "{:?}", r.err());
}

#[test]
fn a_core_module_is_not_a_guest() {
    let kit = Kit::new("core");
    let core = wat::parse_str(r#"(module (func (export "run")))"#).unwrap();
    assert!(matches!(
        kit.try_load("core", &core),
        Err(LoadRefusal::NotAComponent)
    ));
}

#[test]
fn junk_bytes_are_not_a_guest() {
    let kit = Kit::new("junk");
    assert!(matches!(
        kit.try_load("junk", b"\0asm\x0d\0\x01\0 junk junk"),
        Err(LoadRefusal::Invalid { .. })
    ));
    assert!(matches!(
        kit.try_load("junk2", b"MZ not wasm at all"),
        Err(LoadRefusal::Invalid { .. })
    ));
    assert!(matches!(
        kit.try_load("empty", b""),
        Err(LoadRefusal::Invalid { .. })
    ));
}

#[test]
fn a_component_without_run_is_refused() {
    let kit = Kit::new("norun");
    let wit = "package heimdall:attack@0.1.0;\nworld other { export ping: func(); }";
    let core = r#"(module (memory (export "memory") 1) (func (export "ping")))"#;
    let r = kit.try_load("norun", &component_with(core, &[("o.wit", wit)], "other"));
    assert!(
        matches!(r, Err(LoadRefusal::MissingExport { ref name }) if name == "run"),
        "{:?}",
        r.err()
    );
}

#[test]
fn a_run_export_with_another_signature_is_refused_when_it_runs() {
    let kit = Kit::new("runtype");
    let wit = "package heimdall:attack@0.1.0;\nworld other { export run: func() -> u32; }";
    let core =
        r#"(module (memory (export "memory") 1) (func (export "run") (result i32) (i32.const 7)))"#;
    let g = kit
        .try_load("runtype", &component_with(core, &[("o.wit", wit)], "other"))
        .expect("loads");
    let o = g.run(b"", &none(), &RunLimits::default());
    assert!(
        o.verdict.reason().unwrap().contains("interface"),
        "{}",
        o.verdict
    );
}

#[test]
fn the_published_wit_is_the_one_the_host_links() {
    assert!(WIT.contains("import emit-file: func(name: string, bytes: list<u8>);"));
    assert!(WIT.contains(
        "export run: func(input: list<u8>, params: list<param>) -> result<output, refusal>;"
    ));
    assert!(
        !WIT.contains("wasi"),
        "the guest world must not mention WASI"
    );
}

/// Move `(import ...)` lines that `extra` added below the funcs up to the top.
fn hoist_imports(wat: &str) -> String {
    let mut imports = Vec::new();
    let mut rest = Vec::new();
    for line in wat.lines() {
        let t = line.trim_start();
        if t.starts_with("(import ") && !t.contains("\"emit-file\"") {
            imports.push(line.to_string());
        } else {
            rest.push(line.to_string());
        }
    }
    let mut out = Vec::new();
    for line in rest {
        out.push(line.clone());
        if line
            .trim_start()
            .starts_with("(import \"$root\" \"emit-file\"")
        {
            out.append(&mut imports);
        }
    }
    out.join("\n")
}