nordisk/heimdall
crates/heimdall-wasm/tests/common/mod.rs
// SPDX-License-Identifier: AGPL-3.0-only
//! Test kit: tiny guests written as core WAT, turned into components with the
//! real `wit/heimdall.wit`, signed with a throwaway CA exactly as a release
//! door signs holger's plugins (`.wasm.p7s` CMS over blake3 + `.wasm.blake3`).
//!
//! Heimdall is a defensive sandbox; these guests attack only its own limits.
#![allow(dead_code)]
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use guest_host::verify::sign::{dev, sign_module, Ed25519Signer};
use heimdall_wasm::{Guest, Host, HostConfig, LoadRefusal, RunLimits, WIT, WORLD};
use wit_component::{ComponentEncoder, StringEncoding};
use wit_parser::Resolve;
static NEXT: AtomicUsize = AtomicUsize::new(0);
/// A fresh scratch directory under cargo's per-target tmp dir.
pub fn scratch(name: &str) -> PathBuf {
let n = NEXT.fetch_add(1, Ordering::SeqCst);
let dir = Path::new(env!("CARGO_TARGET_TMPDIR"))
.join(format!("hw-{name}-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// A throwaway CA, a release signer under it, and a host trusting the CA.
pub struct Kit {
pub dir: PathBuf,
pub root: PathBuf,
pub signer: Ed25519Signer,
}
impl Kit {
pub fn new(name: &str) -> Kit {
let dir = scratch(name);
let (ca_key, ca_der) = dev::mint_ca("heimdall-test-ca").unwrap();
let root = dir.join("root.der");
std::fs::write(&root, &ca_der).unwrap();
let signer = dev::new_ed25519_signer(&ca_key, &ca_der, "heimdall-test-release").unwrap();
Kit { dir, root, signer }
}
pub fn config(&self) -> HostConfig {
HostConfig::new([self.root.clone()])
}
pub fn host(&self) -> Host {
Host::new(self.config()).expect("host")
}
/// Write `bytes` as `<name>.wasm` with its `.p7s` and `.blake3` beside it.
pub fn place(&self, name: &str, bytes: &[u8]) -> PathBuf {
let module = self.dir.join(format!("{name}.wasm"));
std::fs::write(&module, bytes).unwrap();
let cms = sign_module(bytes, &self.signer).unwrap();
std::fs::write(sidecar(&module, "p7s"), cms).unwrap();
std::fs::write(
sidecar(&module, "blake3"),
format!("{}\n", blake3::hash(bytes).to_hex()),
)
.unwrap();
module
}
/// Place a WAT guest and load it; panics on a load refusal.
pub fn guest(&self, name: &str, core_wat: &str) -> Guest {
let path = self.place(name, &component(core_wat));
self.host()
.load(&path)
.unwrap_or_else(|e| panic!("{name}: {e}"))
}
/// Place raw bytes (signed) and try to load them.
pub fn try_load(&self, name: &str, bytes: &[u8]) -> Result<Guest, LoadRefusal> {
let path = self.place(name, bytes);
self.host().load(&path)
}
}
pub fn sidecar(module: &Path, ext: &str) -> PathBuf {
let mut n = module.file_name().unwrap().to_os_string();
n.push(".");
n.push(ext);
module.with_file_name(n)
}
/// Componentize a core module against heimdall's own WIT world.
pub fn component(core_wat: &str) -> Vec<u8> {
component_with(core_wat, &[("heimdall.wit", WIT)], WORLD)
}
/// Componentize a core module against any WIT packages and world.
pub fn component_with(core_wat: &str, wit: &[(&str, &str)], world: &str) -> Vec<u8> {
let mut resolve = Resolve::default();
let mut main = None;
for (path, src) in wit {
main = Some(resolve.push_str(path, src).unwrap());
}
let world = resolve.select_world(&[main.unwrap()], Some(world)).unwrap();
let mut core =
wat::parse_str(core_wat).unwrap_or_else(|e| panic!("bad test WAT: {e}\n{core_wat}"));
wit_component::embed_component_metadata(&mut core, &resolve, world, StringEncoding::UTF8)
.unwrap();
ComponentEncoder::default()
.module(&core)
.unwrap()
.validate(true)
.encode()
.unwrap()
}
/// The data area guests put constant strings in starts here.
pub const DATA: u32 = 16;
/// A core module implementing `run` with the canonical ABI by hand.
///
/// Layout: constant data from 16, the result record at 1024 (tag u8 @1024,
/// string ptr @1028, len @1032), a bump heap from 4096 served by
/// `cabi_realloc` (which grows memory as needed). `$ok`/`$refuse` take a
/// (ptr, len) string and return the result pointer. `$emit` is the host's
/// emit-file (name ptr, name len, bytes ptr, bytes len). `$hexname` writes
/// eight hex digits of its argument at 64 (a unique file name per counter).
pub struct Wat {
pub pages: u32,
pub extra: String,
pub body: String,
pub realloc_prelude: String,
}
impl Wat {
pub fn new(body: &str) -> Wat {
Wat {
pages: 1,
extra: String::new(),
body: body.to_string(),
realloc_prelude: String::new(),
}
}
pub fn pages(mut self, pages: u32) -> Wat {
self.pages = pages;
self
}
pub fn extra(mut self, extra: &str) -> Wat {
self.extra.push_str(extra);
self
}
/// Code run at the start of every `cabi_realloc` call.
pub fn realloc_prelude(mut self, code: &str) -> Wat {
self.realloc_prelude = code.to_string();
self
}
pub fn text(&self) -> String {
format!(
r#"(module
(import "$root" "emit-file" (func $emit (param i32 i32 i32 i32)))
(memory (export "memory") {pages})
(global $heap (mut i32) (i32.const 4096))
(func $realloc (export "cabi_realloc") (param $old i32) (param $old_size i32) (param $align i32) (param $size i32) (result i32)
(local $p i32)
{prelude}
(local.set $p (i32.and (i32.add (global.get $heap) (i32.sub (local.get $align) (i32.const 1)))
(i32.xor (i32.sub (local.get $align) (i32.const 1)) (i32.const -1))))
(global.set $heap (i32.add (local.get $p) (local.get $size)))
(block $done
(loop $grow
(br_if $done (i64.le_u (i64.extend_i32_u (global.get $heap)) (i64.mul (i64.extend_i32_u (memory.size)) (i64.const 65536))))
(if (i32.eq (memory.grow (i32.const 1)) (i32.const -1)) (then unreachable))
(br $grow)))
(local.get $p))
(func $ok (param $p i32) (param $l i32) (result i32)
(i32.store8 (i32.const 1024) (i32.const 0))
(i32.store (i32.const 1028) (local.get $p))
(i32.store (i32.const 1032) (local.get $l))
(i32.const 1024))
(func $refuse (param $p i32) (param $l i32) (result i32)
(i32.store8 (i32.const 1024) (i32.const 1))
(i32.store (i32.const 1028) (local.get $p))
(i32.store (i32.const 1032) (local.get $l))
(i32.const 1024))
(func $hexname (param $n i32)
(local $i i32) (local $d i32)
(local.set $i (i32.const 7))
(loop $l
(local.set $d (i32.and (local.get $n) (i32.const 15)))
(i32.store8 (i32.add (i32.const 64) (local.get $i))
(i32.add (local.get $d) (select (i32.const 87) (i32.const 48) (i32.ge_u (local.get $d) (i32.const 10)))))
(local.set $n (i32.shr_u (local.get $n) (i32.const 4)))
(local.set $i (i32.sub (local.get $i) (i32.const 1)))
(br_if $l (i32.ge_s (local.get $i) (i32.const 0)))))
{extra}
(func (export "run") (param $in i32) (param $in_len i32) (param $params i32) (param $params_len i32) (result i32)
{body})
)"#,
pages = self.pages,
prelude = self.realloc_prelude,
extra = self.extra,
body = self.body
)
}
}
/// The well-behaved guest. Input starting with 'T' is refused by the guest
/// ("input signature mismatch"), 'M' traps (malformed input), anything else
/// is emitted as `report.txt` and accepted with the note "accepted".
pub fn good_wat() -> String {
Wat::new(
r#"(if (i32.gt_u (local.get $in_len) (i32.const 0))
(then
(if (i32.eq (i32.load8_u (local.get $in)) (i32.const 84))
(then (return (call $refuse (i32.const 48) (i32.const 24)))))
(if (i32.eq (i32.load8_u (local.get $in)) (i32.const 77))
(then unreachable))))
(call $emit (i32.const 16) (i32.const 10) (local.get $in) (local.get $in_len))
(call $ok (i32.const 32) (i32.const 8))"#,
)
.extra(r#"(data (i32.const 16) "report.txt") (data (i32.const 32) "accepted") (data (i32.const 48) "input signature mismatch")"#)
.text()
}
/// Defaults, but small enough that a bomb is caught quickly.
pub fn tight() -> RunLimits {
RunLimits {
fuel: 50_000_000,
timeout: std::time::Duration::from_secs(5),
memory_bytes: 16 << 20,
..RunLimits::default()
}
}
pub fn reason(o: &heimdall_wasm::GuestOutcome) -> String {
o.verdict
.reason()
.unwrap_or_else(|| panic!("expected a refusal, got {}", o.verdict))
.to_string()
}
pub fn assert_trapped(o: &heimdall_wasm::GuestOutcome, needle: &str) {
let r = reason(o);
assert!(r.starts_with("guest trapped ("), "not a trap refusal: {r}");
assert!(
r.contains(needle),
"the refusal does not name {needle:?}: {r}"
);
assert!(
o.files.is_empty(),
"a trapped run handed on {} file(s)",
o.files.len()
);
}
/// No job parameters.
pub fn none() -> heimdall_core::Params {
heimdall_core::Params::new()
}
/// Job parameters from pairs.
pub fn params(pairs: &[(&str, &str)]) -> heimdall_core::Params {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}