Gunnar

nordisk/heimdall

fuzz/fuzz_targets/wasm_emit_file.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! Heimdall is a defensive sandbox. Fuzz the wasm host's emit-file path: one
//! signed guest runs a fuzzer-written script of emit-file calls (arbitrary
//! name bytes, arbitrary sizes, traps and refusals mid-output) under small
//! limits. The outcome must match a model: every emitted name is one plain
//! file name accepted by heimdall-core too, no name twice, every count and
//! size limit holds, a trap or a limit hands on no files, and no run ends in
//! a host fault.
#![no_main]
use libfuzzer_sys::fuzz_target;

#[path = "../common/wasm_kit.rs"]
mod wasm_kit;

use heimdall_core::Output;
use heimdall_wasm::{check_file_name, codes, Guest, RunLimits, Verdict};
use std::collections::BTreeSet;
use std::sync::OnceLock;
use std::time::Duration;

fn guest() -> &'static Guest {
    static G: OnceLock<Guest> = OnceLock::new();
    G.get_or_init(|| {
        let kit = wasm_kit::kit();
        let path = kit.place_signed("emit", &wasm_kit::component(wasm_kit::EMIT_SCRIPT_WAT));
        kit.host.load(&path).expect("emit-script guest loads")
    })
}

fn limits() -> RunLimits {
    RunLimits {
        fuel: 50_000_000,
        timeout: Duration::from_secs(5),
        max_files: 8,
        max_file_bytes: 4096,
        max_emit_bytes: 12_288,
        ..RunLimits::default()
    }
}

/// What the run should give, when the script stays inside the input.
enum Expect {
    Accepted(Vec<(String, usize)>),
    GuestRefused(Vec<(String, usize)>),
    Stopped,
    Unknown,
}

fn model(script: &[u8], l: &RunLimits) -> Expect {
    let mut files = Vec::new();
    let mut seen = BTreeSet::new();
    let mut total = 0usize;
    let mut p = 0usize;
    while p < script.len() {
        let n = script[p] as usize;
        match n {
            255 => return Expect::Stopped,
            254 => return Expect::GuestRefused(files),
            _ => {}
        }
        p += 1;
        // A name or size reaching past the input reads guest memory beyond
        // it: still bounded by the host, but not modelled here.
        if p + n + 2 > script.len() {
            return Expect::Unknown;
        }
        let size = u16::from_le_bytes([script[p + n], script[p + n + 1]]) as usize;
        let Ok(name) = std::str::from_utf8(&script[p..p + n]) else {
            return Expect::Stopped;
        };
        if check_file_name(name).is_err()
            || !seen.insert(name.to_string())
            || files.len() >= l.max_files
            || size > l.max_file_bytes
            || total + size > l.max_emit_bytes
        {
            return Expect::Stopped;
        }
        total += size;
        files.push((name.to_string(), size));
        p += n + 2;
    }
    Expect::Accepted(files)
}

fuzz_target!(|data: &[u8]| {
    let l = limits();
    let out = guest().run(data, &Default::default(), &l);
    assert_ne!(
        out.verdict.code(),
        Some(codes::HOST_FAULT),
        "{}",
        out.verdict
    );

    // Whatever came out obeys every rule, whatever the script was.
    let mut names = BTreeSet::new();
    let mut total = 0usize;
    assert!(out.files.len() <= l.max_files);
    for f in &out.files {
        assert_eq!(check_file_name(&f.name), Ok(()), "{:?}", f.name);
        assert!(
            heimdall_core::validate_output_name(&f.name).is_ok(),
            "{:?}",
            f.name
        );
        assert!(names.insert(f.name.clone()), "{:?} twice", f.name);
        assert!(f.bytes.len() <= l.max_file_bytes);
        total += f.bytes.len();
        assert_eq!(f.entry(), Output::of(f.name.clone(), &f.bytes));
    }
    assert!(total <= l.max_emit_bytes);
    if let Verdict::Refused { code, .. } = &out.verdict {
        if *code != codes::GUEST_REFUSED {
            assert!(out.files.is_empty(), "a trap or limit handed on files");
        }
    }

    let got: Vec<(String, usize)> = out
        .files
        .iter()
        .map(|f| (f.name.clone(), f.bytes.len()))
        .collect();
    match model(data, &l) {
        Expect::Accepted(want) => {
            assert!(out.verdict.is_accepted(), "{}", out.verdict);
            assert_eq!(got, want);
        }
        Expect::GuestRefused(want) => {
            assert_eq!(
                out.verdict.code(),
                Some(codes::GUEST_REFUSED),
                "{}",
                out.verdict
            );
            assert_eq!(got, want);
        }
        Expect::Stopped => {
            assert!(
                !out.verdict.is_accepted(),
                "accepted a script that must stop"
            );
            assert_ne!(
                out.verdict.code(),
                Some(codes::GUEST_REFUSED),
                "{}",
                out.verdict
            );
            assert!(got.is_empty());
        }
        Expect::Unknown => {}
    }
});