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")
}