Gunnar

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