Skip to main content

byteflow/bytecode/
verify.rs

1use super::chunk::Chunk;
2use super::opcode::Opcode;
3use super::value::Value;
4use std::fmt;
5
6/// Whether the chunk may contain authority-bearing constants.
7///
8/// Default is [`TrustLevel::Untrusted`] (fail closed). Host assemblers that
9/// intentionally embed `Cap` / `Pid` / `Message` in the pool must pass
10/// [`TrustLevel::Trusted`].
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum TrustLevel {
13    Trusted,
14    Untrusted,
15}
16
17/// Knob for [`verify_with`]. Default trust is untrusted.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub struct VerifyConfig {
20    pub trust: TrustLevel,
21}
22
23impl Default for VerifyConfig {
24    fn default() -> Self {
25        Self {
26            trust: TrustLevel::Untrusted,
27        }
28    }
29}
30
31/// Constant-pool tags that untrusted modules must not embed.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum ConstantKind {
34    Capability,
35    ProcessId,
36    Message,
37}
38
39impl fmt::Display for ConstantKind {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        match self {
42            ConstantKind::Capability => f.write_str("capability"),
43            ConstantKind::ProcessId => f.write_str("pid"),
44            ConstantKind::Message => f.write_str("message"),
45        }
46    }
47}
48
49fn validate_constant(value: &Value, trust: TrustLevel, index: usize) -> Result<(), VerifyError> {
50    if trust == TrustLevel::Trusted {
51        return Ok(());
52    }
53    match value {
54        Value::Cap(_) => Err(VerifyError::ForbiddenConstant {
55            index,
56            kind: ConstantKind::Capability,
57        }),
58        Value::Pid(_) => Err(VerifyError::ForbiddenConstant {
59            index,
60            kind: ConstantKind::ProcessId,
61        }),
62        Value::Message(_) => Err(VerifyError::ForbiddenConstant {
63            index,
64            kind: ConstantKind::Message,
65        }),
66        _ => Ok(()),
67    }
68}
69
70/// Why a [`Chunk`] failed verification.
71///
72/// The verifier's job is to make every fact the interpreter's hot loop
73/// relies on (jump targets in range, constant/function indices in range)
74/// true *before* a single instruction runs, so `byteflow-vm` never has to
75/// re-check them per-step. Skipping this on trusted, compiler-generated
76/// bytecode is fine; it is mandatory before loading anything that crossed a
77/// trust boundary (a plugin, a network-fetched module, see design notes
78/// §24 capability/sandboxing).
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum VerifyError {
81    UnknownOpcode { at: usize, byte: u8 },
82    ConstOutOfRange { at: usize, index: u32, len: usize },
83    FunctionOutOfRange { at: usize, index: u32, len: usize },
84    JumpOutOfRange { at: usize, target: i64, len: usize },
85    EmptyFunctionTable,
86    EntryOutOfRange { function: usize, entry: u32, len: usize },
87    /// A function declares more parameters than it has registers to hold
88    /// them. The VM loads `r0..arity` on entry, so this makes the very first
89    /// thing a call does — copying arguments in — reach past the register
90    /// file. Cheap to settle here: it is a static property of the function
91    /// table, one comparison per function, and no amount of runtime checking
92    /// makes such a function callable.
93    ArityExceedsRegisters {
94        function: usize,
95        arity: u8,
96        num_registers: u8,
97    },
98    /// Untrusted chunk embedded a Cap, Pid, or Message in the constant pool.
99    ForbiddenConstant { index: usize, kind: ConstantKind },
100}
101
102impl fmt::Display for VerifyError {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        match self {
105            VerifyError::UnknownOpcode { at, byte } => {
106                write!(f, "unknown opcode 0x{byte:02X} at instruction {at}")
107            }
108            VerifyError::ConstOutOfRange { at, index, len } => write!(
109                f,
110                "instruction {at} references constant {index}, pool has {len} entries"
111            ),
112            VerifyError::FunctionOutOfRange { at, index, len } => write!(
113                f,
114                "instruction {at} references function {index}, table has {len} entries"
115            ),
116            VerifyError::JumpOutOfRange { at, target, len } => write!(
117                f,
118                "instruction {at} jumps to {target}, out of code bounds (len={len})"
119            ),
120            VerifyError::EmptyFunctionTable => write!(f, "chunk has no entry function"),
121            VerifyError::EntryOutOfRange { function, entry, len } => write!(
122                f,
123                "function {function} entry point {entry} is out of code bounds (len={len})"
124            ),
125            VerifyError::ArityExceedsRegisters { function, arity, num_registers } => write!(
126                f,
127                "function {function} declares arity {arity} but only {num_registers} registers"
128            ),
129            VerifyError::ForbiddenConstant { index, kind } => write!(
130                f,
131                "untrusted constant[{index}] must not embed {kind}"
132            ),
133        }
134    }
135}
136
137impl std::error::Error for VerifyError {}
138
139/// Verify structural invariants of `chunk`. See [`VerifyError`] for what is
140/// checked. This does **not** perform full dataflow/register-liveness
141/// verification (unlike, say, the JVM verifier) — v0 trades that off
142/// against implementation complexity, and instead the VM bounds-checks
143/// register indices at runtime (cheap: it's an array index against a fixed
144/// small register file, not worth statically proving away yet).
145///
146/// The one register fact that *is* settled statically is
147/// [`VerifyError::ArityExceedsRegisters`]. It belongs here rather than in
148/// the VM because it is a property of the function table, not of an
149/// execution: such a function cannot be entered at all, so letting it reach
150/// the interpreter only moves the same rejection later and per call.
151///
152/// `Opcode::CallNative` targets are deliberately **not** range-checked
153/// here: native functions live in a `byteflow_vm::NativeTable` supplied by
154/// the embedder at `Vm` construction time, entirely outside this crate's
155/// (and this `Chunk`'s) knowledge. An out-of-range `CallNative` is instead
156/// caught at runtime as `Fault::BadNative`.
157pub fn verify(chunk: &Chunk) -> Result<(), VerifyError> {
158    verify_with(chunk, VerifyConfig::default())
159}
160
161/// Like [`verify`], with an explicit [`VerifyConfig`].
162pub fn verify_with(chunk: &Chunk, config: VerifyConfig) -> Result<(), VerifyError> {
163    if chunk.functions.is_empty() {
164        return Err(VerifyError::EmptyFunctionTable);
165    }
166
167    for (index, value) in chunk.constants.iter().enumerate() {
168        validate_constant(value, config.trust, index)?;
169    }
170
171    let len = chunk.code.len();
172
173    // `enumerate` rather than looking the index back up by name: two
174    // functions may share a name, and a search would then report the wrong
175    // one (and cost O(n²) doing it).
176    for (function, def) in chunk.functions.iter().enumerate() {
177        if def.entry as usize >= len {
178            return Err(VerifyError::EntryOutOfRange {
179                function,
180                entry: def.entry,
181                len,
182            });
183        }
184        if def.arity > def.num_registers {
185            return Err(VerifyError::ArityExceedsRegisters {
186                function,
187                arity: def.arity,
188                num_registers: def.num_registers,
189            });
190        }
191    }
192
193    for (at, instr) in chunk.code.iter().enumerate() {
194        match instr.op {
195            Opcode::LoadConst => {
196                let idx = instr.imm as u32;
197                if idx as usize >= chunk.constants.len() {
198                    return Err(VerifyError::ConstOutOfRange {
199                        at,
200                        index: idx,
201                        len: chunk.constants.len(),
202                    });
203                }
204            }
205            Opcode::Spawn | Opcode::Call => {
206                let idx = instr.imm as u32;
207                if idx as usize >= chunk.functions.len() {
208                    return Err(VerifyError::FunctionOutOfRange {
209                        at,
210                        index: idx,
211                        len: chunk.functions.len(),
212                    });
213                }
214            }
215            // `CallNative` targets a `byteflow_vm::NativeTable` supplied at
216            // runtime, outside this chunk — see this function's doc
217            // comment. Not checked here; checked as `Fault::BadNative` at
218            // call time instead.
219            Opcode::CallNative => {}
220            Opcode::Jump | Opcode::Branch => {
221                let target = at as i64 + 1 + instr.imm as i64;
222                if target < 0 || target as usize > len {
223                    // == len is allowed: jumping to "one past the end" is a
224                    // valid way to fall off the end of a function body.
225                    return Err(VerifyError::JumpOutOfRange { at, target, len });
226                }
227            }
228            _ => {}
229        }
230    }
231
232    Ok(())
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::bytecode::builder::ChunkBuilder;
239    use crate::bytecode::opcode::Opcode;
240
241
242    #[test]
243    fn rejects_empty_function_table() {
244        let chunk = Chunk::default();
245        assert_eq!(verify(&chunk), Err(VerifyError::EmptyFunctionTable));
246    }
247
248    #[test]
249    fn accepts_well_formed_chunk() {
250        let mut b = ChunkBuilder::new("test");
251        b.begin_function("main", 0, 2);
252        let k = b.const_(crate::bytecode::value::Value::Int(41));
253        b.emit_load_const(0, k);
254        b.emit_load_imm(1, 1);
255        b.emit_binop(Opcode::Add, 0, 0, 1);
256        b.emit_return(0);
257        let chunk = b.finish();
258        assert!(verify(&chunk).is_ok());
259    }
260
261    /// A function the VM cannot even enter: entry copies `r0..arity`, but
262    /// the frame only has `num_registers` slots. Used to be accepted here and
263    /// then panic with an out-of-bounds index inside `Vm::new` / `Call`.
264    #[test]
265    fn rejects_arity_larger_than_the_register_file() {
266        let mut b = ChunkBuilder::new("test");
267        b.begin_function("main", 3, 1);
268        b.emit_return(0);
269        let chunk = b.finish();
270        assert_eq!(
271            verify(&chunk),
272            Err(VerifyError::ArityExceedsRegisters {
273                function: 0,
274                arity: 3,
275                num_registers: 1,
276            })
277        );
278    }
279
280    #[test]
281    fn accepts_arity_equal_to_the_register_file() {
282        let mut b = ChunkBuilder::new("test");
283        b.begin_function("main", 2, 2);
284        b.emit_return(0);
285        let chunk = b.finish();
286        assert!(verify(&chunk).is_ok());
287    }
288
289    #[test]
290    fn rejects_out_of_range_jump() {
291        use crate::bytecode::instruction::Instruction;
292        let mut chunk = Chunk::default();
293        chunk.functions.push(crate::bytecode::chunk::FunctionDef {
294            name: "main".into(),
295            entry: 0,
296            arity: 0,
297            num_registers: 1,
298        });
299        chunk.code.push(Instruction::only_imm(Opcode::Jump, 999));
300        assert!(matches!(verify(&chunk), Err(VerifyError::JumpOutOfRange { .. })));
301    }
302
303    #[test]
304    fn untrusted_rejects_cap_pid_message_constants() {
305        use crate::bytecode::cap::CapId;
306        use crate::bytecode::value::Message;
307        for (value, kind) in [
308            (crate::bytecode::value::Value::Cap(CapId::from_raw(1)), ConstantKind::Capability),
309            (crate::bytecode::value::Value::Pid(7), ConstantKind::ProcessId),
310            (
311                crate::bytecode::value::Value::Message(Message::new(1, 2, 3, 4u64)),
312                ConstantKind::Message,
313            ),
314        ] {
315            let mut b = ChunkBuilder::new("forge");
316            b.begin_function("main", 0, 1);
317            let k = b.const_(value);
318            b.emit_load_const(0, k);
319            b.emit_return(0);
320            let chunk = b.finish();
321            assert_eq!(
322                verify(&chunk),
323                Err(VerifyError::ForbiddenConstant { index: 0, kind })
324            );
325            assert!(verify_with(
326                &chunk,
327                VerifyConfig {
328                    trust: TrustLevel::Trusted
329                }
330            )
331            .is_ok());
332        }
333    }
334}