From 4b88557d82b83679185f41598c0710ce303795f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 20:21:39 +0000 Subject: [PATCH] encore_vm: validate bytecode at load time The interpreter reads the code stream with get_unchecked, so a truncated stream, a bad jump target or an out-of-range operand in a .encr file caused undefined behaviour or a panic. Vm::load now runs Program::validate before carving globals or running anything. It is a no_std, allocation-free pass that returns VmError::Invalid { pc, reason } (or InvalidOpcode) when: - an opcode is unknown or its operands run past the code; - the last instruction can fall through past the end; - a MATCH/BRANCH/CLOSURE/FUNCTION target or global entry point is outside the code or not on an instruction boundary (targets are batched, sorted and merged against a walk of the instruction starts); - a PACK/UNPACK tag is outside the arity table, or UNPACK writes past the register file; - a GLOBAL/GLOBAL_W index is >= n_globals; - an EXTERN slot is >= MAX_EXTERN. Program::parse still checks only the header, so the disassembler can open broken files. ENCORE range-checks dynamic call targets, which catches calls to the NULL continuation (0xFFFF). call_global_raw rejects out-of-range global indices instead of panicking. Code is now crate-private, Code::new is unsafe, and a SAFETY comment states the invariant. VM.md documents the rules and the remaining trust assumption: values are not type-checked at run time. Tests: rule-by-rule cases, deterministic fuzzing of parse + validate (random bytes, random code, mutated programs), and a check that every example compiles to code that validates, with and without the optimizer. Closes #17 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WELBLatovBrmXgkbHCMBgR --- VM.md | 22 +- .../encore_fleche/tests/validate_examples.rs | 29 ++ crates/encore_vm/src/code.rs | 27 +- crates/encore_vm/src/error.rs | 6 + crates/encore_vm/src/lib.rs | 3 +- crates/encore_vm/src/program.rs | 9 + crates/encore_vm/src/validate.rs | 257 ++++++++++++++ crates/encore_vm/src/vm.rs | 35 +- crates/encore_vm/tests/validate_tests.rs | 316 ++++++++++++++++++ 9 files changed, 692 insertions(+), 12 deletions(-) create mode 100644 crates/encore_fleche/tests/validate_examples.rs create mode 100644 crates/encore_vm/src/validate.rs create mode 100644 crates/encore_vm/tests/validate_tests.rs diff --git a/VM.md b/VM.md index 19f38b4..c0d1adf 100644 --- a/VM.md +++ b/VM.md @@ -216,10 +216,30 @@ Section 2 — global/define names: | Byte-string literal | 255 bytes (`u8` length of `BYTES`) | `CompileError::BytesLiteralTooLong` | | Heap | 65,535 words (`u16` addresses, `0xFFFF` = `NULL`) | a larger buffer is used only up to that size | +## Load-time validation + +The interpreter reads the code stream without bounds checks, so `Vm::load` first runs `Program::validate`, a single `no_std`, allocation-free pass that returns `VmError::Invalid { pc, reason }` (or `VmError::InvalidOpcode`) for malformed code. `Program::parse` checks only the header, so the disassembler can still open broken files. + +Validation rejects a program when: + +- an opcode is unknown, or an instruction's operands run past `code_len`; +- the last instruction can fall through (it must be `FIN`, `ENCORE`, `MATCH` or `BRANCH`); +- a static jump target (a `MATCH` table entry, a `BRANCH` target, a `CLOSURE`/`FUNCTION` code pointer, or a global entry point) is outside the code or does not land on an instruction boundary; +- a `PACK`/`UNPACK` tag is `>= n_arities`, or `UNPACK` would write past the register file (`rd + arity > 256`); +- a `GLOBAL`/`GLOBAL_W` index is `>= n_globals`; +- an `EXTERN` slot is `>= 32`; +- the code is longer than `0xFFFF` bytes (code pointers are 16-bit, and `0xFFFF` is the `NULL` continuation). + +`MATCH`/`BRANCH` tags are only compared, never used as indices, so they are not checked against the arity table. + +At run time, `ENCORE` also checks that a dynamic call target lies inside the code. This catches a call to the `NULL` continuation. Together, the two checks keep the program counter on an instruction boundary of validated code. This invariant backs the unchecked reads in `Code`. + +**Remaining trust assumption:** values are not type-checked at run time. The compiler only emits `FIELD`/`UNPACK` after a `MATCH` on a constructor, `CAPTURE` inside a closure body with enough captures, and so on. A hand-crafted file can still apply `FIELD`, `CAPTURE`, `ENCORE` or a bytes operation to a value of the wrong type or shape (for example `FIELD` on an integer, a field index past the constructor's arity, or a capture index past the closure's environment). The resulting heap access is unchecked. Only load bytecode produced by the Encore compiler, or bytecode from a source you trust to the same degree. + ## Entry points - **`Vm::init(mem)`** — create a VM instance with a heap arena. -- **`vm.load(&prog)`** — parse a program binary, initialize globals by running each define's thunk. +- **`vm.load(&prog)`** — validate the program (see above), then initialize globals by running each define's thunk. - **`vm.call(global_idx, arg)`** — call a global function with an argument, return the result. - **`vm.call_value(func, arg)`** — call an arbitrary function value with an argument. - **`vm.register_extern(slot, f)`** — register a host function at a given extern slot. diff --git a/crates/encore_fleche/tests/validate_examples.rs b/crates/encore_fleche/tests/validate_examples.rs new file mode 100644 index 0000000..a51ad28 --- /dev/null +++ b/crates/encore_fleche/tests/validate_examples.rs @@ -0,0 +1,29 @@ +//! Every example program must pass the VM's load-time validation, with and +//! without the optimizer. + +use std::path::Path; + +use encore_compiler::pass::cps_optimize::OptimizeConfig; +use encore_compiler::pipeline; +use encore_vm::program::Program; + +#[test] +fn test_examples_validate() { + let examples = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples"); + let mut seen = 0; + for entry in std::fs::read_dir(&examples).unwrap() { + let dir = entry.unwrap().path(); + let name = dir.file_name().unwrap().to_str().unwrap().to_string(); + let src_file = dir.join(format!("{name}.fleche")); + let Ok(src) = std::fs::read_to_string(&src_file) else { continue }; + for config in [None, Some(OptimizeConfig::default())] { + let binary = pipeline::compile_module(encore_fleche::parse(&src), config, None).unwrap(); + let prog = Program::parse(&binary).unwrap(); + if let Err(e) = prog.validate() { + panic!("{}: {e}", src_file.display()); + } + } + seen += 1; + } + assert!(seen > 0, "no examples found in {}", examples.display()); +} diff --git a/crates/encore_vm/src/code.rs b/crates/encore_vm/src/code.rs index 6745f2d..4dacf15 100644 --- a/crates/encore_vm/src/code.rs +++ b/crates/encore_vm/src/code.rs @@ -1,15 +1,38 @@ +//! Cursor over the code stream, read without bounds checks. +//! +//! SAFETY: a `Code` is only built over bytes that passed +//! [`validate`](crate::validate), and the interpreter only moves `pc` by +//! decoding one instruction at a time from a boundary or by jumping to a +//! validated target (static targets are checked at load, dynamic `ENCORE` +//! targets by `Vm::resolve_code_ptr`). Validation guarantees every operand of +//! an instruction starting at a boundary lies inside the code and that no +//! instruction falls through past its end, so each unchecked read below is +//! in bounds. + use crate::value::{CodeAddress, Reg}; -pub struct Code<'a> { +pub(crate) struct Code<'a> { bytes: &'a [u8], pc: usize, } impl<'a> Code<'a> { - pub fn new(bytes: &'a [u8]) -> Self { + /// A cursor over no code. Nothing can execute: every call target is + /// out of range. + pub fn empty() -> Self { + Self { bytes: &[], pc: 0 } + } + + /// # Safety + /// `bytes` must be the code of a program that passed validation. + pub unsafe fn new(bytes: &'a [u8]) -> Self { Self { bytes, pc: 0 } } + pub fn len(&self) -> usize { + self.bytes.len() + } + pub fn read_u8(&mut self) -> u8 { let b = unsafe { *self.bytes.get_unchecked(self.pc) }; self.pc += 1; diff --git a/crates/encore_vm/src/error.rs b/crates/encore_vm/src/error.rs index 5b6c386..62533e5 100644 --- a/crates/encore_vm/src/error.rs +++ b/crates/encore_vm/src/error.rs @@ -52,6 +52,9 @@ pub enum VmError { Extern { error: ExternError, slot: u16, pc: u16 }, TypeError { expected: &'static str, got: &'static str }, GlobalsOverflow { needed: usize, available: usize }, + /// Malformed bytecode, rejected at load time (or a call to a target + /// outside the code). `pc` is the offending code offset. + Invalid { pc: u16, reason: &'static str }, } impl VmError { @@ -67,6 +70,7 @@ impl VmError { VmError::Extern { .. } => "extern call failed", VmError::TypeError { .. } => "type error", VmError::GlobalsOverflow { .. } => "globals do not fit in the heap", + VmError::Invalid { reason, .. } => *reason, } } } @@ -91,6 +95,8 @@ impl fmt::Display for VmError { write!(f, "type error: expected {expected}, got {got}"), VmError::GlobalsOverflow { needed, available } => write!(f, "globals overflow: program needs {needed} global slots, heap has {available} free words"), + VmError::Invalid { pc, reason } => + write!(f, "invalid bytecode at pc=0x{pc:04x}: {reason}"), } } } diff --git a/crates/encore_vm/src/lib.rs b/crates/encore_vm/src/lib.rs index 30b5185..439e6da 100644 --- a/crates/encore_vm/src/lib.rs +++ b/crates/encore_vm/src/lib.rs @@ -12,7 +12,7 @@ macro_rules! stat { ($($t:tt)*) => {} } pub mod arena; pub mod builtins; -pub mod code; +mod code; pub mod error; pub mod ffi; pub mod gc; @@ -22,6 +22,7 @@ pub mod program; mod registers; #[cfg(feature = "stats")] pub mod stats; +pub mod validate; pub mod value; pub mod vm; diff --git a/crates/encore_vm/src/program.rs b/crates/encore_vm/src/program.rs index 5941f50..d3232a6 100644 --- a/crates/encore_vm/src/program.rs +++ b/crates/encore_vm/src/program.rs @@ -19,6 +19,10 @@ pub const MAGIC: [u8; 4] = *b"ENCR"; /// Section 2 - global/define names: /// [n_globals: u16 LE] /// For each: [idx: u16 LE] [name_len: u8] [name: name_len bytes, UTF-8] +/// +/// `parse` checks the header only, so tools such as the disassembler can +/// still open malformed code. [`Vm::load`](crate::vm::Vm::load) runs the full +/// [`validate`](Self::validate) pass before executing anything. #[derive(Debug)] pub struct Program<'a> { pub arity_table: &'a [u8], @@ -85,6 +89,11 @@ impl<'a> Program<'a> { } } + /// Check that the code is safe to execute; see [`crate::validate`]. + pub fn validate(&self) -> Result<(), VmError> { + crate::validate::validate(self) + } + pub fn has_metadata(&self) -> bool { self.metadata.len() >= 2 } diff --git a/crates/encore_vm/src/validate.rs b/crates/encore_vm/src/validate.rs new file mode 100644 index 0000000..1d0cbbc --- /dev/null +++ b/crates/encore_vm/src/validate.rs @@ -0,0 +1,257 @@ +//! Load-time bytecode validation. +//! +//! The interpreter reads the code stream with unchecked accesses, so it must +//! only ever run code that has passed [`validate`]. A validated program +//! guarantees: +//! +//! - every instruction reachable by falling through from an instruction +//! boundary decodes to a known opcode whose operands lie inside the code; +//! - the last instruction never falls through past the end of the code +//! (it is `FIN`, `ENCORE`, `MATCH` or `BRANCH`); +//! - every static jump target (`MATCH` table entries, `BRANCH`, +//! `CLOSURE`/`FUNCTION` code pointers, global entry points) is inside the +//! code and lands on an instruction boundary; +//! - `PACK`/`UNPACK` tags index the arity table, and `UNPACK` never writes +//! past the register file; +//! - `GLOBAL`/`GLOBAL_W` indices are `< n_globals`, and `EXTERN` slots are +//! `< MAX_EXTERN`. +//! +//! Together with the check in `ENCORE` that a dynamic call target lies inside +//! the code, this means the program counter only ever sits on an +//! instruction boundary of validated code. +//! +//! The pass is `no_std` and allocation-free. Boundary checks collect jump +//! targets into a fixed buffer, sort it, and merge it against a walk over +//! the instruction starts, one walk per full buffer. + +use crate::error::VmError; +use crate::opcode; +use crate::program::Program; +use crate::vm::MAX_EXTERN; + +/// Largest code the VM can address: code pointers are 16-bit, and 0xFFFF is +/// the `NULL` continuation sentinel, which must never be a valid target. +pub const MAX_CODE_LEN: usize = 0xFFFF; + +/// Jump targets checked per walk over the instruction starts. +const PENDING: usize = 128; + +fn invalid(pc: usize, reason: &'static str) -> VmError { + VmError::Invalid { pc: pc as u16, reason } +} + +/// Validate `prog`; see the module docs for the guarantees. +pub fn validate(prog: &Program) -> Result<(), VmError> { + let v = Validator { code: prog.code, arity_table: prog.arity_table, n_globals: prog.n_globals() }; + v.check_header()?; + v.check_instructions()?; + v.check_boundaries(prog) +} + +struct Validator<'p> { + code: &'p [u8], + arity_table: &'p [u8], + n_globals: usize, +} + +/// Decoded shape of one instruction. +struct Insn { + len: usize, + /// `false` when execution never continues at `pc + len`. + falls_through: bool, +} + +impl Validator<'_> { + fn check_header(&self) -> Result<(), VmError> { + if self.code.len() > MAX_CODE_LEN { + return Err(invalid(0, "code too long")); + } + Ok(()) + } + + /// Decode every instruction once: opcodes, operand extents, operand + /// ranges, and that the code does not fall off its end. + fn check_instructions(&self) -> Result<(), VmError> { + let len = self.code.len(); + let mut pc = 0; + let mut falls_through = false; + while pc < len { + let insn = self.decode(pc, &mut |from, target| { + if (target as usize) < len { Ok(()) } else { Err(invalid(from, "jump target outside code")) } + })?; + falls_through = insn.falls_through; + pc += insn.len; + } + if falls_through { + return Err(invalid(len, "execution falls off the end of code")); + } + Ok(()) + } + + /// Check that every static jump target is an instruction start. + /// Assumes `check_instructions` succeeded. + fn check_boundaries(&self, prog: &Program) -> Result<(), VmError> { + let mut pending = [(0u16, 0u16); PENDING]; + let mut n = 0; + let mut push = |from: usize, target: u16| -> Result<(), VmError> { + pending[n] = (target, from as u16); + n += 1; + if n == PENDING { + self.flush(&mut pending)?; + n = 0; + } + Ok(()) + }; + for i in 0..self.n_globals { + let entry = prog.global(i).raw(); + if entry as usize >= self.code.len() { + return Err(invalid(entry as usize, "global entry point outside code")); + } + push(entry as usize, entry)?; + } + let mut pc = 0; + while pc < self.code.len() { + pc += self.decode(pc, &mut push)?.len; + } + self.flush(&mut pending[..n]) + } + + /// Merge sorted `targets` against the instruction starts. + fn flush(&self, targets: &mut [(u16, u16)]) -> Result<(), VmError> { + targets.sort_unstable_by_key(|&(target, _)| target); + let mut pc = 0; + for &(target, from) in targets.iter() { + let target = target as usize; + while pc < target { + pc += self.decode(pc, &mut |_, _| Ok(()))?.len; + } + if pc != target { + return Err(invalid(from as usize, "jump target not on an instruction boundary")); + } + } + Ok(()) + } + + fn tag_arity(&self, pc: usize, tag: u8) -> Result { + match self.arity_table.get(tag as usize) { + Some(&arity) => Ok(arity as usize), + None => Err(invalid(pc, "constructor tag outside the arity table")), + } + } + + /// Decode the instruction at `pc`, reporting each static jump target to + /// `on_target(pc, target)`. + fn decode( + &self, + pc: usize, + on_target: &mut dyn FnMut(usize, u16) -> Result<(), VmError>, + ) -> Result { + let code = self.code; + let need = |n: usize| -> Result<(), VmError> { + if pc + n <= code.len() { Ok(()) } else { Err(invalid(pc, "instruction runs past end of code")) } + }; + let addr = |off: usize| u16::from_le_bytes([code[pc + off], code[pc + off + 1]]); + let next = |len: usize| Ok(Insn { len, falls_through: true }); + let last = |len: usize| Ok(Insn { len, falls_through: false }); + + let op = code[pc]; + match op { + opcode::FIN => { need(2)?; last(2) } + opcode::ENCORE => { need(3)?; last(3) } + + opcode::MOV | opcode::CAPTURE | opcode::INT_BYTE + | opcode::BYTES_LEN => { need(3)?; next(3) } + + opcode::INT_0 | opcode::INT_1 | opcode::INT_2 => { need(2)?; next(2) } + + opcode::FIELD | opcode::INT_ADD | opcode::INT_SUB | opcode::INT_MUL + | opcode::INT_EQ | opcode::INT_LT | opcode::INT_LE | opcode::INT_DIV + | opcode::INT_MOD | opcode::INT_SUB_SAT | opcode::INT_AND | opcode::INT_OR + | opcode::INT_XOR | opcode::INT_SHL | opcode::INT_SHR | opcode::BYTES_GET + | opcode::BYTES_CONCAT | opcode::BYTES_EQ => { need(4)?; next(4) } + + opcode::INT | opcode::BYTES_SLICE => { need(5)?; next(5) } + + opcode::GLOBAL => { + need(3)?; + if code[pc + 2] as usize >= self.n_globals { + return Err(invalid(pc, "global index out of range")); + } + next(3) + } + + opcode::GLOBAL_W => { + need(4)?; + if addr(2) as usize >= self.n_globals { + return Err(invalid(pc, "global index out of range")); + } + next(4) + } + + opcode::EXTERN => { + need(5)?; + if addr(3) as usize >= MAX_EXTERN { + return Err(invalid(pc, "extern slot out of range")); + } + next(5) + } + + opcode::FUNCTION => { + need(4)?; + on_target(pc, addr(2))?; + next(4) + } + + opcode::CLOSURE => { + need(5)?; + let len = 5 + code[pc + 4] as usize; + need(len)?; + on_target(pc, addr(2))?; + next(len) + } + + opcode::PACK => { + need(3)?; + let len = 3 + self.tag_arity(pc, code[pc + 2])?; + need(len)?; + next(len) + } + + opcode::UNPACK => { + need(4)?; + let arity = self.tag_arity(pc, code[pc + 2])?; + if code[pc + 1] as usize + arity > 256 { + return Err(invalid(pc, "UNPACK writes past the register file")); + } + next(4) + } + + opcode::BYTES => { + need(3)?; + let len = 3 + code[pc + 2] as usize; + need(len)?; + next(len) + } + + opcode::MATCH => { + need(4)?; + let n = code[pc + 3] as usize; + let len = 4 + 2 * n; + need(len)?; + for i in 0..n { + on_target(pc, addr(4 + 2 * i))?; + } + last(len) + } + + opcode::BRANCH => { + need(7)?; + on_target(pc, addr(3))?; + on_target(pc, addr(5))?; + last(7) + } + + _ => Err(VmError::InvalidOpcode { opcode: op, pc: pc as u16 }), + } + } +} diff --git a/crates/encore_vm/src/vm.rs b/crates/encore_vm/src/vm.rs index 4a0649c..692fa30 100644 --- a/crates/encore_vm/src/vm.rs +++ b/crates/encore_vm/src/vm.rs @@ -16,7 +16,7 @@ const CONT: Reg = Reg::new(1); const A1: Reg = Reg::new(2); pub type ExternFn = fn(&mut Vm, Value) -> Result; -const MAX_EXTERN: usize = 32; +pub const MAX_EXTERN: usize = 32; fn unregistered(_: &mut Vm, _: Value) -> Result { Err(ExternError::Unregistered) @@ -42,7 +42,7 @@ pub struct Vm<'a> { impl<'a> Vm<'a> { pub fn init(mem: &'a mut [Value]) -> Self { Self { - code: Code::new(&[]), + code: Code::empty(), arity_table: &[], globals: &mut [], extern_fns: [unregistered; MAX_EXTERN], @@ -98,7 +98,11 @@ impl<'a> Vm<'a> { /// so there is no fixed global limit: a program whose globals do not fit /// beside what is already allocated fails with [`VmError::GlobalsOverflow`]. /// Globals of a previous `load` are not reclaimed. + /// + /// `prog` is validated first; malformed code fails with + /// [`VmError::Invalid`] or [`VmError::InvalidOpcode`] before anything runs. pub fn load(&mut self, prog: &Program<'a>) -> Result<(), VmError> { + prog.validate()?; let n = prog.n_globals(); let mem = core::mem::take(&mut self.arena.mem); let len = mem.len(); @@ -110,7 +114,9 @@ impl<'a> Vm<'a> { globals.fill(Value::int(0)); self.arena.mem = heap; self.globals = globals; - self.code = Code::new(prog.code); + // SAFETY: `prog` passed validation above, which is the invariant + // `Code::new` requires. + self.code = unsafe { Code::new(prog.code) }; self.arity_table = prog.arity_table; for i in 0..n { let addr = prog.global(i); @@ -204,18 +210,28 @@ impl<'a> Vm<'a> { self.arena.try_alloc(n) } - fn resolve_code_ptr(&self, func: Value) -> CodeAddress { - if func.is_function() { + /// Code pointer of a function or closure. Static code pointers are + /// validated at load time, but a register can also hold the `NULL` + /// continuation (0xFFFF) or `RETURN_CONT` over empty code, so the target + /// is range-checked here to keep `pc` inside the code. + #[inline(always)] + fn resolve_code_ptr(&self, func: Value, pc: u16) -> Result { + let code_ptr = if func.is_function() { func.code_ptr() } else { self.arena[func.closure_addr() + 1].header_code_ptr() + }; + if (code_ptr.raw() as usize) < self.code.len() { + Ok(code_ptr) + } else { + Err(VmError::Invalid { pc, reason: "call target outside code" }) } } const RETURN_CONT: Value = Value::function_const(0); fn call_raw(&mut self, func: Value, args: &[Value]) -> Result { - let code_ptr = self.resolve_code_ptr(func); + let code_ptr = self.resolve_code_ptr(func, self.code.pc() as u16)?; self.registers[SELF] = func; self.registers[CONT] = Self::RETURN_CONT; for (i, arg) in args.iter().enumerate() { @@ -226,7 +242,9 @@ impl<'a> Vm<'a> { } pub fn call_global_raw(&mut self, global_idx: GlobalAddress, args: &[Value]) -> Result { - let func = self.globals[global_idx.raw() as usize]; + let Some(&func) = self.globals.get(global_idx.raw() as usize) else { + return Err(VmError::Invalid { pc: 0, reason: "global index out of range" }); + }; self.call_raw(func, args) } @@ -269,6 +287,7 @@ impl<'a> Vm<'a> { O::decode(self, raw).map_err(ExternError::from) } + /// `entry` must be a validated global entry point. fn call_address(&mut self, entry: CodeAddress, arg: Value) -> Result { self.registers[SELF] = Value::function(entry); self.registers[CONT] = Self::RETURN_CONT; @@ -443,7 +462,7 @@ impl<'a> Vm<'a> { let rk = self.code.read_reg(); let fun = self.registers[rf]; let cont = self.registers[rk]; - let code_ptr = self.resolve_code_ptr(fun); + let code_ptr = self.resolve_code_ptr(fun, pc)?; self.registers[SELF] = fun; self.registers[CONT] = cont; self.code.jump(code_ptr); diff --git a/crates/encore_vm/tests/validate_tests.rs b/crates/encore_vm/tests/validate_tests.rs new file mode 100644 index 0000000..97f3695 --- /dev/null +++ b/crates/encore_vm/tests/validate_tests.rs @@ -0,0 +1,316 @@ +use encore_vm::error::VmError; +use encore_vm::opcode::*; +use encore_vm::program::{Program, MAGIC}; +use encore_vm::value::{CodeAddress, GlobalAddress, Value}; +use encore_vm::vm::Vm; + +const A1: u8 = 2; +const X01: u8 = 10; + +fn validate(code: &[u8], arities: &[u8], globals: &[u16]) -> Result<(), VmError> { + let globals: Vec = globals.iter().map(|&g| CodeAddress::new(g)).collect(); + Program::new(code, arities, &globals).validate() +} + +fn reason(result: Result<(), VmError>) -> &'static str { + match result { + Err(VmError::Invalid { reason, .. }) => reason, + other => panic!("expected VmError::Invalid, got {other:?}"), + } +} + +// -- Accepted programs -- + +#[test] +fn test_empty_program_is_valid() { + assert_eq!(validate(&[], &[], &[]), Ok(())); +} + +#[test] +fn test_every_opcode_is_valid() { + // Every jump targets the FIN at 0; the rest is only ever decoded. + let code = [ + FIN, A1, + MOV, X01, A1, + CAPTURE, X01, 0, + GLOBAL, X01, 0, + CLOSURE, X01, 0, 0, 2, X01, A1, + FUNCTION, X01, 0, 0, + PACK, X01, 1, A1, + FIELD, X01, A1, 0, + UNPACK, X01, 1, A1, + INT, X01, 1, 2, 3, + INT_0, X01, INT_1, X01, INT_2, X01, + INT_ADD, X01, A1, A1, INT_SUB, X01, A1, A1, INT_MUL, X01, A1, A1, + INT_EQ, X01, A1, A1, INT_LT, X01, A1, A1, INT_BYTE, X01, A1, + INT_LE, X01, A1, A1, INT_DIV, X01, A1, A1, INT_MOD, X01, A1, A1, + INT_SUB_SAT, X01, A1, A1, INT_AND, X01, A1, A1, INT_OR, X01, A1, A1, + INT_XOR, X01, A1, A1, INT_SHL, X01, A1, A1, INT_SHR, X01, A1, A1, + GLOBAL_W, X01, 0, 0, + EXTERN, X01, A1, 31, 0, + BYTES, X01, 3, b'a', b'b', b'c', + BYTES_LEN, X01, A1, BYTES_GET, X01, A1, A1, + BYTES_CONCAT, X01, A1, A1, BYTES_SLICE, X01, A1, A1, A1, BYTES_EQ, X01, A1, A1, + BRANCH, A1, 0, 0, 0, 0, 0, + MATCH, A1, 0, 2, 0, 0, 0, 0, + ENCORE, A1, CONT_REG, + ]; + assert_eq!(validate(&code, &[0, 1], &[0]), Ok(())); +} +const CONT_REG: u8 = 1; + +// -- Rejected programs -- + +#[test] +fn test_unknown_opcode() { + assert_eq!(validate(&[0x3F, 0], &[], &[]), Err(VmError::InvalidOpcode { opcode: 0x3F, pc: 0 })); + assert_eq!(validate(&[FIN, A1, NULL], &[], &[]), Err(VmError::InvalidOpcode { opcode: NULL, pc: 2 })); +} + +#[test] +fn test_truncated_operands() { + for code in [ + &[FIN][..], + &[MOV, X01], + &[INT, X01, 1, 2], + &[CLOSURE, X01, 0, 0, 2, A1], + &[BYTES, X01, 3, b'a'], + &[MATCH, A1, 0, 2, 0, 0, 0], + &[BRANCH, A1, 0, 0, 0, 0], + &[PACK, X01, 0, A1], + ] { + assert_eq!(reason(validate(code, &[2], &[])), "instruction runs past end of code", "{code:?}"); + } +} + +#[test] +fn test_falls_off_end() { + assert_eq!(reason(validate(&[MOV, X01, A1], &[], &[])), "execution falls off the end of code"); + assert_eq!(reason(validate(&[FIN, A1, INT_0, X01], &[], &[])), "execution falls off the end of code"); +} + +#[test] +fn test_jump_target_outside_code() { + let code = [FUNCTION, X01, 6, 0, FIN, X01]; + assert_eq!(validate(&code, &[], &[]), Err(VmError::Invalid { pc: 0, reason: "jump target outside code" })); + let code = [FIN, A1, BRANCH, A1, 0, 0, 0, 9, 0]; + assert_eq!(validate(&code, &[], &[]), Err(VmError::Invalid { pc: 2, reason: "jump target outside code" })); +} + +#[test] +fn test_jump_target_mid_instruction() { + // FUNCTION points at the operand byte of FIN. + let code = [FUNCTION, X01, 5, 0, FIN, X01]; + assert_eq!(validate(&code, &[], &[]), + Err(VmError::Invalid { pc: 0, reason: "jump target not on an instruction boundary" })); + // MATCH table entry into the middle of its own table. + let code = [MATCH, A1, 0, 2, 8, 0, 5, 0, FIN, A1]; + assert_eq!(reason(validate(&code, &[], &[])), "jump target not on an instruction boundary"); + // CLOSURE code pointer. + let code = [CLOSURE, X01, 1, 0, 0, FIN, X01]; + assert_eq!(reason(validate(&code, &[], &[])), "jump target not on an instruction boundary"); +} + +#[test] +fn test_many_jump_targets() { + // More targets than one boundary walk checks, all valid, then one bad one last. + let mut code = Vec::new(); + for _ in 0..300 { + code.extend_from_slice(&[FUNCTION, X01, 0, 0]); + } + code.extend_from_slice(&[FIN, X01]); + assert_eq!(validate(&code, &[], &[0]), Ok(())); + let n = code.len(); + code[n - 2 - 4 + 2] = 1; // last FUNCTION -> address 1 + assert_eq!(validate(&code, &[], &[0]), + Err(VmError::Invalid { pc: (n - 6) as u16, reason: "jump target not on an instruction boundary" })); +} + +#[test] +fn test_global_entry_points() { + let code = [FIN, A1, FIN, A1]; + assert_eq!(validate(&code, &[], &[0, 2]), Ok(())); + assert_eq!(reason(validate(&code, &[], &[1])), "jump target not on an instruction boundary"); + assert_eq!(reason(validate(&code, &[], &[4])), "global entry point outside code"); +} + +#[test] +fn test_tag_outside_arity_table() { + assert_eq!(reason(validate(&[PACK, X01, 1, FIN, X01], &[0], &[])), "constructor tag outside the arity table"); + assert_eq!(reason(validate(&[UNPACK, X01, 0, A1, FIN, X01], &[], &[])), "constructor tag outside the arity table"); +} + +#[test] +fn test_unpack_past_register_file() { + assert_eq!(validate(&[UNPACK, 0xFE, 0, A1, FIN, X01], &[2], &[]), Ok(())); + assert_eq!(reason(validate(&[UNPACK, 0xFF, 0, A1, FIN, X01], &[2], &[])), "UNPACK writes past the register file"); +} + +#[test] +fn test_global_index_out_of_range() { + let code = [GLOBAL, X01, 1, FIN, X01]; + assert_eq!(reason(validate(&code, &[], &[0])), "global index out of range"); + assert_eq!(validate(&code, &[], &[0, 0]), Ok(())); +} + +#[test] +fn test_extern_slot_out_of_range() { + assert_eq!(reason(validate(&[EXTERN, X01, A1, 32, 0, FIN, X01], &[], &[])), "extern slot out of range"); +} + +#[test] +fn test_wide_global_index() { + let code = [GLOBAL_W, X01, 0, 1, FIN, X01]; + let globals = vec![0u16; 256]; + assert_eq!(reason(validate(&code, &[], &globals)), "global index out of range"); + let globals = vec![0u16; 257]; + assert_eq!(validate(&code, &[], &globals), Ok(())); + assert_eq!(reason(validate(&[GLOBAL_W, X01, 0], &[], &[0])), "instruction runs past end of code"); +} + +// -- Loading -- + +#[test] +fn test_load_rejects_invalid_code_before_running() { + // The global would run fine, but trailing garbage makes the program invalid. + let code = [FIN, A1, 0x3F]; + let globals = [CodeAddress::new(0)]; + let prog = Program::new(&code, &[], &globals); + let mut mem = [Value::ZERO; 64]; + let mut vm = Vm::init(&mut mem); + assert!(matches!(vm.load(&prog), Err(VmError::InvalidOpcode { opcode: 0x3F, pc: 2 }))); +} + +#[test] +fn test_call_null_continuation_is_an_error() { + // Global 0: X01 = function(@6); FIN X01. Body at 6: ENCORE NULL, NULL. + let code = [FUNCTION, X01, 6, 0, FIN, X01, ENCORE, NULL, NULL]; + let globals = [CodeAddress::new(0)]; + let prog = Program::new(&code, &[], &globals); + let mut mem = [Value::ZERO; 64]; + let mut vm = Vm::init(&mut mem); + vm.load(&prog).unwrap(); + let err = vm.call_global_raw(GlobalAddress::new(0), &[]).unwrap_err(); + assert_eq!(err, VmError::Invalid { pc: 6, reason: "call target outside code" }); +} + +#[test] +fn test_call_missing_global_is_an_error() { + let code = [FIN, A1]; + let globals = [CodeAddress::new(0)]; + let prog = Program::new(&code, &[], &globals); + let mut mem = [Value::ZERO; 64]; + let mut vm = Vm::init(&mut mem); + vm.load(&prog).unwrap(); + assert_eq!(reason(vm.call_global_raw(GlobalAddress::new(1), &[]).map(|_| ())), "global index out of range"); +} + +// -- Fuzzing -- + +/// xorshift64*: deterministic, dependency-free randomness. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 >> 12; + self.0 ^= self.0 << 25; + self.0 ^= self.0 >> 27; + self.0.wrapping_mul(0x2545_F491_4F6C_DD1D) + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n as u64) as usize + } +} + +fn header(n_arities: usize, n_globals: usize, code_len: usize) -> Vec { + let mut bytes = MAGIC.to_vec(); + for n in [n_arities, n_globals, code_len] { + bytes.extend_from_slice(&(n as u16).to_le_bytes()); + } + bytes +} + +/// Fuzz iterations; Miri runs are far slower, so they take a sample. +fn iterations(n: usize) -> usize { + if cfg!(miri) { n / 100 } else { n } +} + +/// Parsing and validating must return, never panic, whatever the input. +fn check(bytes: &[u8]) -> bool { + match Program::parse(bytes) { + Ok(prog) => prog.validate().is_ok(), + Err(_) => false, + } +} + +#[test] +fn test_fuzz_random_bytes() { + let mut rng = Rng(0x9E37_79B9_7F4A_7C15); + for _ in 0..iterations(20_000) { + let len = rng.below(64); + let bytes: Vec = (0..len).map(|_| rng.next() as u8).collect(); + check(&bytes); + } +} + +#[test] +fn test_fuzz_random_code() { + // A well-formed header over random code, biased toward real opcodes so + // decoding gets past the first byte. + let ops = [ + FIN, MOV, CAPTURE, GLOBAL, CLOSURE, PACK, FIELD, MATCH, ENCORE, BRANCH, + FUNCTION, UNPACK, INT, INT_ADD, INT_BYTE, INT_0, EXTERN, BYTES, BYTES_SLICE, + ]; + let mut rng = Rng(0xD1B5_4A32_D192_ED03); + let mut accepted = 0; + for _ in 0..iterations(50_000) { + let n_arities = rng.below(4); + let n_globals = rng.below(3); + let code_len = rng.below(48); + let mut bytes = header(n_arities, n_globals, code_len); + bytes.extend((0..n_arities).map(|_| rng.below(4) as u8)); + for _ in 0..n_globals { + bytes.extend_from_slice(&(rng.below(code_len + 1) as u16).to_le_bytes()); + } + for _ in 0..code_len { + let byte = match rng.below(3) { + 0 => ops[rng.below(ops.len())], + 1 => rng.below(code_len + 1) as u8, + _ => rng.next() as u8, + }; + bytes.push(byte); + } + if check(&bytes) { accepted += 1; } + } + assert!(accepted > 0, "fuzzer never produced a valid program"); +} + +#[test] +fn test_fuzz_mutated_program() { + let code = [ + FIN, A1, // 0 + FUNCTION, X01, 0, 0, // 2 + PACK, X01, 1, A1, // 6 + BRANCH, X01, 0, 17, 0, 0, 0, // 10 + FIN, X01, // 17 + MATCH, A1, 0, 2, 17, 0, 0, 0, // 19 + ]; + let mut base = header(2, 1, code.len()); + base.extend_from_slice(&[0, 1]); + base.extend_from_slice(&2u16.to_le_bytes()); + base.extend_from_slice(&code); + assert!(check(&base)); + + let mut rng = Rng(0x0123_4567_89AB_CDEF); + for _ in 0..iterations(50_000) { + let mut bytes = base.clone(); + for _ in 0..1 + rng.below(3) { + let i = rng.below(bytes.len()); + bytes[i] = rng.next() as u8; + } + if rng.below(4) == 0 { + bytes.truncate(rng.below(bytes.len() + 1)); + } + check(&bytes); + } +}