Skip to main content

byteflow/bytecode/
verify.rs

1use super::chunk::Chunk;
2use super::opcode::Opcode;
3use std::fmt;
4
5/// Why a [`Chunk`] failed verification.
6///
7/// The verifier's job is to make every fact the interpreter's hot loop
8/// relies on (jump targets in range, constant/function indices in range)
9/// true *before* a single instruction runs, so `byteflow-vm` never has to
10/// re-check them per-step. Skipping this on trusted, compiler-generated
11/// bytecode is fine; it is mandatory before loading anything that crossed a
12/// trust boundary (a plugin, a network-fetched module, see design notes
13/// §24 capability/sandboxing).
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum VerifyError {
16    UnknownOpcode { at: usize, byte: u8 },
17    ConstOutOfRange { at: usize, index: u32, len: usize },
18    FunctionOutOfRange { at: usize, index: u32, len: usize },
19    JumpOutOfRange { at: usize, target: i64, len: usize },
20    EmptyFunctionTable,
21    EntryOutOfRange { function: usize, entry: u32, len: usize },
22    /// A function declares more parameters than it has registers to hold
23    /// them. The VM loads `r0..arity` on entry, so this makes the very first
24    /// thing a call does — copying arguments in — reach past the register
25    /// file. Cheap to settle here: it is a static property of the function
26    /// table, one comparison per function, and no amount of runtime checking
27    /// makes such a function callable.
28    ArityExceedsRegisters {
29        function: usize,
30        arity: u8,
31        num_registers: u8,
32    },
33}
34
35impl fmt::Display for VerifyError {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        match self {
38            VerifyError::UnknownOpcode { at, byte } => {
39                write!(f, "unknown opcode 0x{byte:02X} at instruction {at}")
40            }
41            VerifyError::ConstOutOfRange { at, index, len } => write!(
42                f,
43                "instruction {at} references constant {index}, pool has {len} entries"
44            ),
45            VerifyError::FunctionOutOfRange { at, index, len } => write!(
46                f,
47                "instruction {at} references function {index}, table has {len} entries"
48            ),
49            VerifyError::JumpOutOfRange { at, target, len } => write!(
50                f,
51                "instruction {at} jumps to {target}, out of code bounds (len={len})"
52            ),
53            VerifyError::EmptyFunctionTable => write!(f, "chunk has no entry function"),
54            VerifyError::EntryOutOfRange { function, entry, len } => write!(
55                f,
56                "function {function} entry point {entry} is out of code bounds (len={len})"
57            ),
58            VerifyError::ArityExceedsRegisters { function, arity, num_registers } => write!(
59                f,
60                "function {function} declares arity {arity} but only {num_registers} registers"
61            ),
62        }
63    }
64}
65
66impl std::error::Error for VerifyError {}
67
68/// Verify structural invariants of `chunk`. See [`VerifyError`] for what is
69/// checked. This does **not** perform full dataflow/register-liveness
70/// verification (unlike, say, the JVM verifier) — v0 trades that off
71/// against implementation complexity, and instead the VM bounds-checks
72/// register indices at runtime (cheap: it's an array index against a fixed
73/// small register file, not worth statically proving away yet).
74///
75/// The one register fact that *is* settled statically is
76/// [`VerifyError::ArityExceedsRegisters`]. It belongs here rather than in
77/// the VM because it is a property of the function table, not of an
78/// execution: such a function cannot be entered at all, so letting it reach
79/// the interpreter only moves the same rejection later and per call.
80///
81/// `Opcode::CallNative` targets are deliberately **not** range-checked
82/// here: native functions live in a `byteflow_vm::NativeTable` supplied by
83/// the embedder at `Vm` construction time, entirely outside this crate's
84/// (and this `Chunk`'s) knowledge. An out-of-range `CallNative` is instead
85/// caught at runtime as `Fault::BadNative`.
86pub fn verify(chunk: &Chunk) -> Result<(), VerifyError> {
87    if chunk.functions.is_empty() {
88        return Err(VerifyError::EmptyFunctionTable);
89    }
90
91    let len = chunk.code.len();
92
93    // `enumerate` rather than looking the index back up by name: two
94    // functions may share a name, and a search would then report the wrong
95    // one (and cost O(n²) doing it).
96    for (function, def) in chunk.functions.iter().enumerate() {
97        if def.entry as usize >= len {
98            return Err(VerifyError::EntryOutOfRange {
99                function,
100                entry: def.entry,
101                len,
102            });
103        }
104        if def.arity > def.num_registers {
105            return Err(VerifyError::ArityExceedsRegisters {
106                function,
107                arity: def.arity,
108                num_registers: def.num_registers,
109            });
110        }
111    }
112
113    for (at, instr) in chunk.code.iter().enumerate() {
114        match instr.op {
115            Opcode::LoadConst => {
116                let idx = instr.imm as u32;
117                if idx as usize >= chunk.constants.len() {
118                    return Err(VerifyError::ConstOutOfRange {
119                        at,
120                        index: idx,
121                        len: chunk.constants.len(),
122                    });
123                }
124            }
125            Opcode::Spawn | Opcode::Call => {
126                let idx = instr.imm as u32;
127                if idx as usize >= chunk.functions.len() {
128                    return Err(VerifyError::FunctionOutOfRange {
129                        at,
130                        index: idx,
131                        len: chunk.functions.len(),
132                    });
133                }
134            }
135            // `CallNative` targets a `byteflow_vm::NativeTable` supplied at
136            // runtime, outside this chunk — see this function's doc
137            // comment. Not checked here; checked as `Fault::BadNative` at
138            // call time instead.
139            Opcode::CallNative => {}
140            Opcode::Jump | Opcode::Branch => {
141                let target = at as i64 + 1 + instr.imm as i64;
142                if target < 0 || target as usize > len {
143                    // == len is allowed: jumping to "one past the end" is a
144                    // valid way to fall off the end of a function body.
145                    return Err(VerifyError::JumpOutOfRange { at, target, len });
146                }
147            }
148            _ => {}
149        }
150    }
151
152    Ok(())
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::bytecode::builder::ChunkBuilder;
159    use crate::bytecode::opcode::Opcode;
160
161
162    #[test]
163    fn rejects_empty_function_table() {
164        let chunk = Chunk::default();
165        assert_eq!(verify(&chunk), Err(VerifyError::EmptyFunctionTable));
166    }
167
168    #[test]
169    fn accepts_well_formed_chunk() {
170        let mut b = ChunkBuilder::new("test");
171        b.begin_function("main", 0, 2);
172        let k = b.const_(crate::bytecode::value::Value::Int(41));
173        b.emit_load_const(0, k);
174        b.emit_load_imm(1, 1);
175        b.emit_binop(Opcode::Add, 0, 0, 1);
176        b.emit_return(0);
177        let chunk = b.finish();
178        assert!(verify(&chunk).is_ok());
179    }
180
181    /// A function the VM cannot even enter: entry copies `r0..arity`, but
182    /// the frame only has `num_registers` slots. Used to be accepted here and
183    /// then panic with an out-of-bounds index inside `Vm::new` / `Call`.
184    #[test]
185    fn rejects_arity_larger_than_the_register_file() {
186        let mut b = ChunkBuilder::new("test");
187        b.begin_function("main", 3, 1);
188        b.emit_return(0);
189        let chunk = b.finish();
190        assert_eq!(
191            verify(&chunk),
192            Err(VerifyError::ArityExceedsRegisters {
193                function: 0,
194                arity: 3,
195                num_registers: 1,
196            })
197        );
198    }
199
200    #[test]
201    fn accepts_arity_equal_to_the_register_file() {
202        let mut b = ChunkBuilder::new("test");
203        b.begin_function("main", 2, 2);
204        b.emit_return(0);
205        let chunk = b.finish();
206        assert!(verify(&chunk).is_ok());
207    }
208
209    #[test]
210    fn rejects_out_of_range_jump() {
211        use crate::bytecode::instruction::Instruction;
212        let mut chunk = Chunk::default();
213        chunk.functions.push(crate::bytecode::chunk::FunctionDef {
214            name: "main".into(),
215            entry: 0,
216            arity: 0,
217            num_registers: 1,
218        });
219        chunk.code.push(Instruction::only_imm(Opcode::Jump, 999));
220        assert!(matches!(verify(&chunk), Err(VerifyError::JumpOutOfRange { .. })));
221    }
222}