nordisk/heimdall
crates/heimdall-wasm/tests/emit_file.rs
// SPDX-License-Identifier: AGPL-3.0-only
//! `emit-file` is the guest's only way out. Bad names (path traversal,
//! separators, hidden files, control bytes, non-ASCII, invalid UTF-8),
//! duplicates, too many files, too-large files and out-of-bounds pointers are
//! all refusals, and a trap after emitting hands on nothing.
//! Heimdall is a defensive sandbox.
mod common;
use common::*;
use heimdall_wasm::{Guest, RunLimits};
/// Emits ONE file whose name is the input bytes, then accepts.
fn named(kit: &Kit) -> Guest {
kit.guest(
"named",
&Wat::new(
r#"(call $emit (local.get $in) (local.get $in_len) (i32.const 16) (i32.const 1))
(call $ok (i32.const 16) (i32.const 1))"#,
)
.extra(r#"(data (i32.const 16) "x")"#)
.text(),
)
}
/// Input = u32 LE count, u32 LE size: emits `count` files named by an 8-digit
/// hex counter, each `size` bytes (memory grows to fit), then accepts.
fn sized(kit: &Kit) -> Guest {
kit.guest(
"sized",
&Wat::new(
r#"(local.set $n (i32.load (local.get $in)))
(local.set $size (i32.load offset=4 (local.get $in)))
(local.set $buf (call $realloc (i32.const 0) (i32.const 0) (i32.const 1) (local.get $size)))
(local.set $i (i32.const 0))
(block $done
(loop $l
(br_if $done (i32.ge_u (local.get $i) (local.get $n)))
(call $hexname (local.get $i))
(call $emit (i32.const 64) (i32.const 8) (local.get $buf) (local.get $size))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br $l)))
(call $ok (i32.const 16) (i32.const 4))"#,
)
.extra(r#"(data (i32.const 16) "done")"#)
.text()
.replace("(param $params_len i32) (result i32)", "(param $params_len i32) (result i32) (local $n i32) (local $size i32) (local $buf i32) (local $i i32)"),
)
}
fn count_size(n: u32, size: u32) -> Vec<u8> {
let mut v = n.to_le_bytes().to_vec();
v.extend_from_slice(&size.to_le_bytes());
v
}
#[test]
fn a_plain_name_is_emitted() {
let kit = Kit::new("plain");
let o = named(&kit).run(b"result-1.json", &none(), &RunLimits::default());
assert!(o.verdict.is_accepted(), "{}", o.verdict);
assert_eq!(o.files[0].name, "result-1.json");
}
#[test]
fn path_traversal_and_every_other_bad_name_is_refused() {
let kit = Kit::new("badnames");
let g = named(&kit);
let long = "a".repeat(256);
let bad: &[&[u8]] = &[
b"../etc/passwd",
b"..",
b".",
b"",
b"/etc/passwd",
b"a/b",
b"a\\b",
b"..\\..\\x",
b".hidden",
b"nul\0byte",
b"line\nbreak",
b"tab\tname",
b"del\x7f",
"åäö.txt".as_bytes(),
"a\u{202e}txt.exe".as_bytes(),
b"space name",
b"colon:name",
b"star*",
long.as_bytes(),
];
for name in bad {
let o = g.run(name, &none(), &RunLimits::default());
assert_trapped(&o, "emit-file");
assert!(
!o.verdict.is_accepted(),
"{:?} was accepted",
String::from_utf8_lossy(name)
);
}
}
#[test]
fn a_name_that_is_not_utf8_is_refused() {
let kit = Kit::new("utf8");
let o = named(&kit).run(
&[0x66, 0xff, 0xfe, 0x2e, 0x74],
&none(),
&RunLimits::default(),
);
assert_trapped(&o, "UTF-8");
}
#[test]
fn the_longest_allowed_name_is_accepted() {
let kit = Kit::new("longest");
let name = "a".repeat(heimdall_wasm::MAX_NAME_BYTES);
let o = named(&kit).run(name.as_bytes(), &none(), &RunLimits::default());
assert!(o.verdict.is_accepted(), "{}", o.verdict);
}
#[test]
fn the_same_name_twice_is_refused() {
let kit = Kit::new("dup");
let g = kit.guest(
"dup",
&Wat::new(
r#"(call $emit (i32.const 16) (i32.const 5) (i32.const 16) (i32.const 1))
(call $emit (i32.const 16) (i32.const 5) (i32.const 16) (i32.const 1))
(call $ok (i32.const 16) (i32.const 5))"#,
)
.extra(r#"(data (i32.const 16) "a.txt")"#)
.text(),
);
assert_trapped(
&g.run(b"", &none(), &RunLimits::default()),
"already emitted",
);
}
#[test]
fn files_up_to_the_count_limit_are_accepted() {
let kit = Kit::new("count-ok");
let limits = RunLimits {
max_files: 50,
..RunLimits::default()
};
let o = sized(&kit).run(&count_size(50, 3), &none(), &limits);
assert!(o.verdict.is_accepted(), "{}", o.verdict);
assert_eq!(o.files.len(), 50);
assert_eq!(o.files[49].name, "00000031");
}
#[test]
fn one_file_over_the_count_limit_is_refused() {
let kit = Kit::new("count-over");
let limits = RunLimits {
max_files: 50,
..RunLimits::default()
};
assert_trapped(
&sized(&kit).run(&count_size(51, 3), &none(), &limits),
"too many files",
);
}
#[test]
fn an_endless_stream_of_emit_calls_is_stopped() {
let kit = Kit::new("endless");
// Ten million tiny files: the count limit (256 by default) fires first.
assert_trapped(
&sized(&kit).run(&count_size(10_000_000, 1), &none(), &RunLimits::default()),
"too many files",
);
}
#[test]
fn a_file_over_the_size_limit_is_refused() {
let kit = Kit::new("bigfile");
let limits = RunLimits {
max_file_bytes: 1 << 20,
..RunLimits::default()
};
assert!(sized(&kit)
.run(&count_size(1, 1 << 20), &none(), &limits)
.verdict
.is_accepted());
assert_trapped(
&sized(&kit).run(&count_size(1, (1 << 20) + 1), &none(), &limits),
"file too large",
);
}
#[test]
fn files_over_the_total_size_limit_are_refused() {
let kit = Kit::new("total");
let limits = RunLimits {
max_file_bytes: 1 << 20,
max_emit_bytes: 3 << 20,
..RunLimits::default()
};
assert!(sized(&kit)
.run(&count_size(3, 1 << 20), &none(), &limits)
.verdict
.is_accepted());
assert_trapped(
&sized(&kit).run(&count_size(4, 1 << 20), &none(), &limits),
"total",
);
}
#[test]
fn a_trap_in_the_middle_of_output_hands_on_no_files() {
let kit = Kit::new("midtrap");
let g = kit.guest(
"midtrap",
&Wat::new(
r#"(call $emit (i32.const 16) (i32.const 9) (i32.const 16) (i32.const 9))
unreachable"#,
)
.extra(r#"(data (i32.const 16) "part1.txt")"#)
.text(),
);
assert_trapped(&g.run(b"", &none(), &RunLimits::default()), "unreachable");
}
#[test]
fn a_limit_hit_after_good_files_hands_on_none_of_them() {
let kit = Kit::new("partial");
let limits = RunLimits {
max_files: 5,
..RunLimits::default()
};
let o = sized(&kit).run(&count_size(6, 10), &none(), &limits);
assert_trapped(&o, "too many files");
}
#[test]
fn an_emit_with_a_pointer_outside_memory_is_refused() {
let kit = Kit::new("oob");
let g = kit.guest(
"oob",
&Wat::new(
r#"(call $emit (i32.const 16) (i32.const 1) (i32.const 0xfffff000) (i32.const 0x10000))
(call $ok (i32.const 16) (i32.const 1))"#,
)
.extra(r#"(data (i32.const 16) "x")"#)
.text(),
);
assert_trapped(&g.run(b"", &none(), &RunLimits::default()), "out of bounds");
}
#[test]
fn an_emit_with_a_name_pointer_outside_memory_is_refused() {
let kit = Kit::new("oob-name");
let g = kit.guest(
"oobname",
&Wat::new(
r#"(call $emit (i32.const 0xffffff00) (i32.const 0x200) (i32.const 0) (i32.const 1))
(call $ok (i32.const 16) (i32.const 1))"#,
)
.text(),
);
assert_trapped(&g.run(b"", &none(), &RunLimits::default()), "out of bounds");
}