fizzyx 0.1.0

Safe, ergonomic Rust bindings for the Fizzy WebAssembly interpreter.
Documentation
use fizzyx::{
    Config, Engine, Error, ExternKind, FuncType, Linker, Module, Mutability, Val, ValType,
};

fn wasm(wat: &str) -> Vec<u8> {
    wat::parse_str(wat).expect("failed to assemble WAT")
}

fn instantiate(wat: &str) -> (Engine, fizzyx::Instance) {
    let engine = Engine::default();
    let module = Module::new(&wasm(wat)).expect("failed to parse module");
    let linker = Linker::new(&engine);
    let instance = linker.instantiate(&module).expect("failed to instantiate");
    (engine, instance)
}

#[test]
fn validate_accepts_and_rejects() {
    assert!(Module::validate(&wasm("(module)")).is_ok());
    // truncated magic number
    assert!(matches!(
        Module::validate(&[0x00, 0x61, 0x73]),
        Err(Error::Invalid(_))
    ));
}

#[test]
fn parse_rejects_garbage() {
    assert!(matches!(
        Module::new(&[0xde, 0xad, 0xbe, 0xef]),
        Err(Error::Malformed(_))
    ));
}

#[test]
fn call_across_all_numeric_types() {
    let (_engine, mut instance) = instantiate(
        r#"(module
            (func (export "add_i32") (param i32 i32) (result i32)
                local.get 0 local.get 1 i32.add)
            (func (export "add_i64") (param i64 i64) (result i64)
                local.get 0 local.get 1 i64.add)
            (func (export "add_f32") (param f32 f32) (result f32)
                local.get 0 local.get 1 f32.add)
            (func (export "add_f64") (param f64 f64) (result f64)
                local.get 0 local.get 1 f64.add))"#,
    );

    let i32_add = instance.get_func("add_i32").unwrap();
    let mut out = [Val::I32(0)];
    i32_add
        .call(&mut instance, &[Val::I32(40), Val::I32(2)], &mut out)
        .unwrap();
    assert_eq!(out[0], Val::I32(42));

    let i64_add = instance.get_func("add_i64").unwrap();
    let mut out = [Val::I64(0)];
    i64_add
        .call(&mut instance, &[Val::I64(1), Val::I64(2)], &mut out)
        .unwrap();
    assert_eq!(out[0], Val::I64(3));

    let f32_add = instance.get_func("add_f32").unwrap();
    let mut out = [Val::F32(0.0)];
    f32_add
        .call(&mut instance, &[Val::F32(1.5), Val::F32(2.25)], &mut out)
        .unwrap();
    assert_eq!(out[0], Val::F32(3.75));

    let f64_add = instance.get_func("add_f64").unwrap();
    let mut out = [Val::F64(0.0)];
    f64_add
        .call(&mut instance, &[Val::F64(1.5), Val::F64(2.25)], &mut out)
        .unwrap();
    assert_eq!(out[0], Val::F64(3.75));
}

#[test]
fn func_with_no_result() {
    let (_engine, mut instance) = instantiate(
        r#"(module (global $g (mut i32) (i32.const 0))
            (func (export "set") (param i32) global.get 0 drop local.get 0 global.set 0)
            (global (export "g") (mut i32) (i32.const 0)))"#,
    );
    // Just ensure a call with zero results succeeds.
    let set = instance.get_func("set").unwrap();
    assert!(set.ty().results().is_empty());
    set.call(&mut instance, &[Val::I32(7)], &mut []).unwrap();
}

#[test]
fn arity_and_type_mismatch_are_reported() {
    let (_engine, mut instance) =
        instantiate(r#"(module (func (export "id") (param i32) (result i32) local.get 0))"#);
    let id = instance.get_func("id").unwrap();

    let mut out = [Val::I32(0)];
    assert!(matches!(
        id.call(&mut instance, &[], &mut out),
        Err(Error::ArityMismatch { .. })
    ));
    assert!(matches!(
        id.call(&mut instance, &[Val::I64(1)], &mut out),
        Err(Error::TypeMismatch { .. })
    ));
}

#[test]
fn missing_export_returns_none() {
    let (_engine, instance) = instantiate(r#"(module (func (export "a")))"#);
    assert!(instance.get_func("does_not_exist").is_none());
    assert!(instance.get_memory("mem").is_none());
    assert!(instance.get_global("g").is_none());
}

#[test]
fn trap_is_reported() {
    let (_engine, mut instance) =
        instantiate(r#"(module (func (export "boom") (result i32) unreachable))"#);
    let boom = instance.get_func("boom").unwrap();
    let mut out = [Val::I32(0)];
    assert!(matches!(
        boom.call(&mut instance, &[], &mut out),
        Err(Error::Trap)
    ));

    // Division by zero also traps.
    let (_engine, mut instance) = instantiate(
        r#"(module (func (export "div") (param i32 i32) (result i32)
            local.get 0 local.get 1 i32.div_s))"#,
    );
    let div = instance.get_func("div").unwrap();
    assert!(matches!(
        div.call(&mut instance, &[Val::I32(1), Val::I32(0)], &mut out),
        Err(Error::Trap)
    ));
}

#[test]
fn host_function_import() {
    let engine = Engine::default();
    let module = Module::new(&wasm(
        r#"(module
            (import "env" "mul" (func $mul (param i32 i32) (result i32)))
            (func (export "run") (param i32 i32) (result i32)
                local.get 0 local.get 1 call $mul))"#,
    ))
    .unwrap();

    let mut linker = Linker::new(&engine);
    linker
        .func_new(
            "env",
            "mul",
            FuncType::new([ValType::I32, ValType::I32], [ValType::I32]),
            |params, results| {
                let a = params[0].i32().unwrap();
                let b = params[1].i32().unwrap();
                results[0] = Val::I32(a * b);
            },
        )
        .unwrap();

    let mut instance = linker.instantiate(&module).unwrap();
    let run = instance.get_func("run").unwrap();
    let mut out = [Val::I32(0)];
    run.call(&mut instance, &[Val::I32(6), Val::I32(7)], &mut out)
        .unwrap();
    assert_eq!(out[0], Val::I32(42));
}

#[test]
fn host_function_panic_becomes_trap() {
    let engine = Engine::default();
    let module = Module::new(&wasm(
        r#"(module
            (import "env" "boom" (func $boom (result i32)))
            (func (export "run") (result i32) call $boom))"#,
    ))
    .unwrap();

    let mut linker = Linker::new(&engine);
    linker
        .func_new(
            "env",
            "boom",
            FuncType::new([], [ValType::I32]),
            |_params, _results| panic!("host function exploded"),
        )
        .unwrap();

    let mut instance = linker.instantiate(&module).unwrap();
    let run = instance.get_func("run").unwrap();
    let mut out = [Val::I32(0)];
    assert!(matches!(
        run.call(&mut instance, &[], &mut out),
        Err(Error::Trap)
    ));
}

#[test]
fn unresolved_import_fails_instantiation() {
    let engine = Engine::default();
    let module = Module::new(&wasm(r#"(module (import "env" "missing" (func)))"#)).unwrap();
    let linker = Linker::new(&engine);
    assert!(matches!(
        linker.instantiate(&module),
        Err(Error::Instantiation(_))
    ));
}

#[test]
fn memory_read_write_roundtrip() {
    let (_engine, mut instance) = instantiate(
        r#"(module
            (memory (export "memory") 1)
            (func (export "load") (param i32) (result i32) local.get 0 i32.load))"#,
    );

    let memory = instance.get_memory("memory").unwrap();
    assert_eq!(memory.size(&instance), 1);
    assert_eq!(memory.data_size(&instance), 65536);

    memory
        .write(&mut instance, 16, &[0x2a, 0x00, 0x00, 0x00])
        .unwrap();

    let mut buf = [0u8; 4];
    memory.read(&instance, 16, &mut buf).unwrap();
    assert_eq!(buf, [0x2a, 0x00, 0x00, 0x00]);

    // Wasm reads the same bytes back as an i32.
    let load = instance.get_func("load").unwrap();
    let mut out = [Val::I32(0)];
    load.call(&mut instance, &[Val::I32(16)], &mut out).unwrap();
    assert_eq!(out[0], Val::I32(42));
}

#[test]
fn memory_out_of_bounds_is_reported() {
    let (_engine, mut instance) = instantiate(r#"(module (memory (export "memory") 1))"#);
    let memory = instance.get_memory("memory").unwrap();

    let mut buf = [0u8; 8];
    assert!(matches!(
        memory.read(&instance, 65_534, &mut buf),
        Err(Error::MemoryOutOfBounds { .. })
    ));
    assert!(matches!(
        memory.write(&mut instance, 65_534, &buf),
        Err(Error::MemoryOutOfBounds { .. })
    ));
}

#[test]
fn global_get_and_set() {
    let (_engine, mut instance) = instantiate(
        r#"(module
            (global (export "counter") (mut i32) (i32.const 10))
            (global (export "answer") i32 (i32.const 42)))"#,
    );

    let counter = instance.get_global("counter").unwrap();
    assert_eq!(counter.ty().content(), ValType::I32);
    assert_eq!(counter.ty().mutability(), Mutability::Mutable);
    assert_eq!(counter.get(&instance), Val::I32(10));
    counter.set(&mut instance, Val::I32(11)).unwrap();
    assert_eq!(counter.get(&instance), Val::I32(11));

    let answer = instance.get_global("answer").unwrap();
    assert_eq!(answer.get(&instance), Val::I32(42));
    assert!(matches!(
        answer.set(&mut instance, Val::I32(0)),
        Err(Error::GlobalImmutable)
    ));
    assert!(matches!(
        counter.set(&mut instance, Val::I64(0)),
        Err(Error::TypeMismatch { .. })
    ));
}

#[test]
fn module_introspection() {
    let module = Module::new(&wasm(
        r#"(module
            (import "env" "log" (func (param i32)))
            (import "env" "buf" (global i32))
            (func (export "run"))
            (memory (export "mem") 1)
            (global (export "g") (mut i64) (i64.const 0)))"#,
    ))
    .unwrap();

    let imports = module.imports();
    assert_eq!(imports.len(), 2);
    assert!(imports.iter().any(|i| i.module() == "env"
        && i.name() == "log"
        && matches!(i.ty(), fizzyx::ExternType::Func(_))));
    assert!(
        imports
            .iter()
            .any(|i| i.name() == "buf" && matches!(i.ty(), fizzyx::ExternType::Global(_)))
    );

    let exports = module.exports();
    assert!(
        exports
            .iter()
            .any(|e| e.name() == "run" && e.kind() == ExternKind::Func)
    );
    assert!(
        exports
            .iter()
            .any(|e| e.name() == "mem" && e.kind() == ExternKind::Memory)
    );
    assert!(
        exports
            .iter()
            .any(|e| e.name() == "g" && e.kind() == ExternKind::Global)
    );

    assert!(module.func_type("run").is_some());
    assert!(module.func_type("missing").is_none());
}

#[test]
fn module_is_reusable_across_instances() {
    let engine = Engine::default();
    let module = Module::new(&wasm(
        r#"(module (func (export "answer") (result i32) i32.const 42))"#,
    ))
    .unwrap();
    let linker = Linker::new(&engine);

    for _ in 0..3 {
        let mut instance = linker.instantiate(&module).unwrap();
        let f = instance.get_func("answer").unwrap();
        let mut out = [Val::I32(0)];
        f.call(&mut instance, &[], &mut out).unwrap();
        assert_eq!(out[0], Val::I32(42));
    }
}

#[test]
fn custom_config_is_accepted() {
    let mut config = Config::new();
    config.memory_pages_limit(1);
    let engine = Engine::new(&config);
    assert_eq!(engine.config().get_memory_pages_limit(), 1);

    let module = Module::new(&wasm(r#"(module (memory (export "m") 1))"#)).unwrap();
    let linker = Linker::new(&engine);
    // A single page is within the limit.
    assert!(linker.instantiate(&module).is_ok());

    // Two pages exceed the one-page limit.
    let module = Module::new(&wasm(r#"(module (memory (export "m") 2))"#)).unwrap();
    assert!(matches!(
        linker.instantiate(&module),
        Err(Error::Instantiation(_))
    ));
}