relon_codegen_llvm/sandbox.rs
1//! Sandbox primitives for the LLVM-native AOT (`emit_object`) backend.
2//! **Phase C.**
3//!
4//! The cranelift-native backend (`relon-codegen-cranelift::sandbox`) is
5//! the gold standard: it enforces the four hard sandbox guarantees the
6//! wasm-AOT backend ships, expressed in cranelift IR. This module ports
7//! the host-facing half of that surface to the LLVM backend so the
8//! linked-after **native** binary produced by [`crate::LlvmAotEvaluator::emit_object`]
9//! carries the same capability-gate + trap semantics.
10//!
11//! ## How the LLVM gate differs from the cranelift gate
12//!
13//! The two backends reach the *same* policy outcome
14//! (`RuntimeError::CapabilityDenied` on a denied bit) through different
15//! machinery:
16//!
17//! * **cranelift** parks an `extern "C"` host-fn pointer in a heap
18//! `CapabilityVtable`, takes its base address as a constant, and emits
19//! a per-call `cap_lookup` + null-check; a null slot traps.
20//! * **LLVM** carries the host-granted capability set as the
21//! buffer-protocol entry's trailing `i64 caps` param (IR `LocalGet(4)`).
22//! `Op::CheckCap { cap_bit }` (lowered in `codegen/call.rs`) bakes a
23//! `(caps & (1 << cap_bit)) != 0` test into the emitted object; a clear
24//! bit records [`SandboxTrapKind::CapabilityDenied`] in
25//! `ArenaState::trap_code` and returns the negative sentinel so the host
26//! lifts a typed `RuntimeError` (rather than an `llvm.trap` `ud2` /
27//! SIGILL the host cannot catch on stable Rust).
28//!
29//! Because the gate is a *bitmask* on the LLVM side, the LLVM
30//! [`CapabilityVtable`] is a thin builder around that `i64` mask: it
31//! consults the same [`relon_eval_api::CapabilityGate`] policy the
32//! cranelift backend and the tree-walker consult ([`Self::register_via_gate`])
33//! and folds each granted bit into the mask the linked binary receives
34//! as `caps`. The grant decision and the bit index are identical across
35//! all three backends — only the runtime carrier differs.
36//!
37//! ## What lives where
38//!
39//! * [`SandboxConfig`] — compile-time knobs (mirror of cranelift's).
40//! * [`SandboxTrapKind`] — the trap-cause enum, numbered to match
41//! cranelift's `TrapKind` and the [`crate::state::NativeTrap`] subset
42//! already recorded by the JIT-side dynamic dispatch helper.
43//! * [`CapabilityVtable`] — the grant surface, expressed as an `i64`
44//! `caps` bitmask + the `import_idx`-keyed dynamic host-fn registry
45//! (which is just a re-export of [`crate::state::HostFnRegistry`], the
46//! existing LLVM equivalent of cranelift's `host_fns` half).
47//!
48//! `state.rs` (`ArenaState` / `HostFnRegistry` / `NativeTrap` /
49//! `relon_llvm_call_native`) is consumed read-only by this module — it is
50//! the codegen-visible runtime contract and must not change.
51
52use relon_eval_api::{CapabilityBit, CapabilityGate, RelonFunction, RuntimeError};
53use relon_parser::TokenRange;
54use std::sync::Arc;
55
56use crate::state::HostFnRegistry;
57
58/// Compile-time sandbox configuration. Mirrors the cranelift backend's
59/// `SandboxConfig` field-for-field so a side-by-side comparison of the
60/// two AOT backends shares the same knob surface.
61///
62/// Production LLVM buffer entries emit the guard surface unconditionally:
63/// arena bounds checks, div/mod guards, checked signed `Int` arithmetic,
64/// capability gates, dynamic host-call trap lifting, and deterministic
65/// step-budget fuel. This struct stays field-compatible with cranelift's
66/// configuration so tests and host code can describe the same policy
67/// intent across backends. The booleans are bench/debug intent records
68/// for LLVM today; they should not be used to create a trusted execution
69/// posture for untrusted source.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct SandboxConfig {
72 /// When `true`, host-visible memory access should be guarded
73 /// against the arena byte length. LLVM buffer entries currently emit
74 /// these guards unconditionally.
75 pub bounds_check: bool,
76 /// When `true`, resource exhaustion should be enforced. LLVM uses
77 /// deterministic step-budget fuel configured on `LlvmAotEvaluator`
78 /// rather than reading this bool directly as a wall-clock deadline
79 /// switch.
80 pub deadline_check: bool,
81 /// When `true`, `Op::CheckCap` bakes the `caps`-bitmask test into
82 /// the emitted object. The `codegen/call.rs` lowering already emits
83 /// it unconditionally for a non-`NO_CAPABILITY_BIT` bit; this flag
84 /// is the host-facing intent record.
85 pub capability_check: bool,
86 /// When `true`, `Op::Div` / `Op::Mod` emit an explicit divisor-zero
87 /// guard before LLVM's `sdiv` / `srem` (whose div-by-zero is UB).
88 /// The `codegen/arith.rs` lowering already emits it; this flag is
89 /// the host-facing intent record.
90 pub div_check: bool,
91}
92
93impl Default for SandboxConfig {
94 fn default() -> Self {
95 Self {
96 bounds_check: true,
97 deadline_check: true,
98 capability_check: true,
99 div_check: true,
100 }
101 }
102}
103
104impl SandboxConfig {
105 /// Disable all four guards. Bench-only — production code paths
106 /// should never call this.
107 pub fn unchecked() -> Self {
108 Self {
109 bounds_check: false,
110 deadline_check: false,
111 capability_check: false,
112 div_check: false,
113 }
114 }
115}
116
117/// Trap kind raised by a guard inside LLVM-emitted native code. The
118/// numeric values match the cranelift backend's `TrapKind` and the
119/// [`crate::state::NativeTrap`] subset the JIT-side dynamic dispatch
120/// helper already records, so the host decodes the same cause numbering
121/// across backends. Encoded as `u64` so it fits the `ArenaState::trap_code`
122/// slot the emitted object writes through `relon_llvm_call_native` /
123/// the `Op::CheckCap` trap arm.
124///
125/// Only the subset the LLVM native path can currently raise
126/// (`DivisionByZero` via the `sdiv`/`srem` guard, `BoundsViolation`
127/// via arena guards, `CapabilityDenied` via `Op::CheckCap`,
128/// `NumericOverflow` via checked Int arithmetic / reductions, and
129/// `HostFnMissing`/`HostFnError` via dynamic dispatch) is reachable
130/// today; the remaining variants are kept so the numbering stays a
131/// faithful mirror of cranelift's for the deadline work that lands
132/// with the wider emitter.
133#[repr(u64)]
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum SandboxTrapKind {
136 /// Division (`Op::Div` / `Op::Mod`) by zero. Buffer entries record
137 /// this code in `ArenaState::trap_code`; legacy/fast entries have no
138 /// typed error lane and still use `llvm.trap`.
139 DivisionByZero = 1,
140 /// Pointer dereference walked past the arena bounds.
141 BoundsViolation = 2,
142 /// An `Op::CheckCap { cap_bit }` found the matching bit clear in the
143 /// host-granted `caps` mask. Lifts to `RuntimeError::CapabilityDenied`.
144 /// Matches cranelift's `TrapKind::CapabilityDenied` and
145 /// [`crate::state::NativeTrap::CapabilityDenied`] (= 3).
146 CapabilityDenied = 3,
147 /// Per-call resource budget exhausted. LLVM currently raises this
148 /// through deterministic step-budget fuel; a future wall-clock
149 /// deadline can reuse the same trap code.
150 ResourceExhausted = 4,
151 /// No host fn registered at the requested `import_idx`, or no
152 /// registry installed. Matches cranelift's `TrapKind::Unreachable`
153 /// (= 5) and [`crate::state::NativeTrap::HostFnMissing`]; lifts to
154 /// `RuntimeError::Unsupported`.
155 HostFnMissing = 5,
156 /// Signed integer overflow. Matches cranelift's
157 /// `TrapKind::NumericOverflow` (= 6) and
158 /// [`crate::state::NativeTrap::NumericOverflow`]. Raised by checked
159 /// `Op::Add` / `Op::Sub` / `Op::Mul`, the `INT_MIN / -1` div/rem
160 /// guard, and bundled checked reductions such as `list_int_sum`.
161 NumericOverflow = 6,
162 /// A host fn returned an error, or a value outside the scalar return
163 /// envelope. Matches [`crate::state::NativeTrap::HostFnError`] (= 7);
164 /// lifts to `RuntimeError::Unsupported`.
165 HostFnError = 7,
166}
167
168impl SandboxTrapKind {
169 /// Decode a `u64` recorded in `ArenaState::trap_code` back into a
170 /// [`SandboxTrapKind`]. Unknown / `0` codes route to
171 /// [`SandboxTrapKind::HostFnError`] so the host always gets a typed
172 /// `RuntimeError` rather than a panic — matching cranelift's
173 /// catch-all-into-typed-error posture.
174 pub fn from_code(code: u64) -> SandboxTrapKind {
175 match code {
176 1 => SandboxTrapKind::DivisionByZero,
177 2 => SandboxTrapKind::BoundsViolation,
178 3 => SandboxTrapKind::CapabilityDenied,
179 4 => SandboxTrapKind::ResourceExhausted,
180 5 => SandboxTrapKind::HostFnMissing,
181 6 => SandboxTrapKind::NumericOverflow,
182 _ => SandboxTrapKind::HostFnError,
183 }
184 }
185
186 /// Lift a trap kind into the appropriate [`RuntimeError`] variant.
187 /// All trap mappings carry the entry function's source range so the
188 /// diagnostic at least points at the `#main` declaration. Mirrors
189 /// cranelift's `TrapKind::to_runtime_error` and the
190 /// [`crate::state::NativeTrap::runtime_error_from_code`] subset.
191 pub fn to_runtime_error(self, range: TokenRange) -> RuntimeError {
192 match self {
193 SandboxTrapKind::DivisionByZero => RuntimeError::DivisionByZero(range),
194 SandboxTrapKind::BoundsViolation => RuntimeError::IndexOutOfBounds { range },
195 SandboxTrapKind::CapabilityDenied => RuntimeError::CapabilityDenied {
196 // The trap path carries no bit (the cleared mask bit is
197 // the only signal), so the host gets a generic reason —
198 // same posture as cranelift's null-slot trap.
199 cap_bit: None,
200 reason: "llvm-native: host-fn call denied by capability gate".to_string(),
201 range,
202 },
203 SandboxTrapKind::ResourceExhausted => {
204 RuntimeError::StepLimitExceeded { limit: None, range }
205 }
206 SandboxTrapKind::NumericOverflow => RuntimeError::NumericOverflow(range),
207 SandboxTrapKind::HostFnMissing | SandboxTrapKind::HostFnError => {
208 RuntimeError::Unsupported {
209 reason: "llvm-native: native-fn dispatch failed (host fn missing / errored / \
210 returned a non-scalar value)"
211 .to_string(),
212 }
213 }
214 }
215 }
216}
217
218/// Highest `cap_bit` the `i64 caps` bitmask can represent. Mirrors the
219/// `cap_bit >= 64` guard `codegen/call.rs::emit_check_cap` enforces.
220pub const MAX_CAP_BIT: u32 = 64;
221
222/// The LLVM backend's capability grant surface.
223///
224/// On the cranelift side the equivalent `CapabilityVtable` is a heap
225/// array of `extern "C"` host-fn pointers whose *non-null-ness* at
226/// `slots[cap_bit]` is what lets an `Op::CheckCap { cap_bit }` pass. On
227/// the LLVM side the granted set is carried as an `i64` bitmask the
228/// buffer-protocol entry receives as its trailing `caps` param, so this
229/// type is a thin builder around that mask plus the dynamic host-fn
230/// registry the `import_idx`-keyed `Op::CallNative` dispatch resolves
231/// against.
232///
233/// ## Two halves (same split as cranelift)
234///
235/// * `caps_mask` — the granted-capability bitmask. A set bit at index
236/// `cap_bit` is what lets an `Op::CheckCap { cap_bit }` pass (the LLVM
237/// analogue of cranelift's "non-null slot at `cap_bit`"). Built via
238/// [`Self::grant`] / [`Self::register_via_gate`]; consumed by the host
239/// as the `caps` word it hands to the linked entry (or to
240/// `LlvmAotEvaluator::with_caps`).
241/// * `host_fns` — the `import_idx`-keyed dynamic callable registry
242/// ([`HostFnRegistry`]). A source-lowered
243/// `Op::CallNative { cap_bit: NO_CAPABILITY_BIT }` resolves through it
244/// via `relon_llvm_call_native`. Keyed off `import_idx` (a private
245/// namespace) so it never collides with the `cap_bit`-indexed mask —
246/// exactly cranelift's `host_fns` split.
247#[derive(Default, Clone)]
248pub struct CapabilityVtable {
249 caps_mask: i64,
250 host_fns: HostFnRegistry,
251}
252
253impl std::fmt::Debug for CapabilityVtable {
254 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255 f.debug_struct("CapabilityVtable")
256 .field("caps_mask", &format_args!("{:#018b}", self.caps_mask))
257 .field("host_fn_count", &self.host_fns.len())
258 .finish()
259 }
260}
261
262impl CapabilityVtable {
263 /// Build an empty vtable: no capabilities granted, no host fns
264 /// registered. The `n` argument is accepted for source-shape parity
265 /// with cranelift's `with_capacity(n)`; the LLVM mask is fixed at 64
266 /// bits so the value is only used to assert the caller does not ask
267 /// for more bits than the `i64` mask can hold.
268 pub fn with_capacity(_n: usize) -> Self {
269 Self {
270 caps_mask: 0,
271 host_fns: HostFnRegistry::new(),
272 }
273 }
274
275 /// Grant a capability bit by setting it in the `caps` mask. An
276 /// `Op::CheckCap { cap_bit }` only tests the bit, so a set bit is
277 /// enough to let the guard pass; the actual call dispatches through
278 /// the `import_idx`-keyed `host_fns` registry. Mirrors cranelift's
279 /// `CapabilityVtable::grant` (which parks a non-null sentinel).
280 ///
281 /// Bits `>= 64` are silently ignored (the `i64` mask cannot carry
282 /// them); the matching `Op::CheckCap` lowering rejects an out-of-
283 /// range bit at compile time, so a too-large grant can never satisfy
284 /// a gate either way.
285 pub fn grant(&mut self, cap_bit: u32) {
286 if cap_bit < MAX_CAP_BIT {
287 self.caps_mask |= 1i64 << cap_bit;
288 }
289 }
290
291 /// Capability-gated grant. Consults `gate` for `cap_bit` via the
292 /// shared [`relon_eval_api::CapabilityGate`] trait; if the gate
293 /// denies the bit, the mask bit stays clear so the IR-level
294 /// `Op::CheckCap` traps with [`SandboxTrapKind::CapabilityDenied`].
295 /// This is the LLVM backend's half of the unified-enforcement
296 /// design: the same policy the tree-walker consults at dispatch time
297 /// and the cranelift backend consults at vtable-build time is
298 /// consulted here when folding the bit into the `caps` mask, so
299 /// denying a bit on the host side produces the same outcome class
300 /// (`RuntimeError::CapabilityDenied`) on all three backends.
301 ///
302 /// Returns `true` if the bit was granted; `false` if the gate denied
303 /// it (mask bit left clear).
304 pub fn register_via_gate<G: CapabilityGate>(
305 &mut self,
306 gate: &G,
307 cap_bit: CapabilityBit,
308 ) -> bool {
309 match gate.check(cap_bit) {
310 Ok(()) => {
311 self.grant(cap_bit.bit_index());
312 true
313 }
314 Err(_) => false,
315 }
316 }
317
318 /// `true` when `cap_bit` is granted in the mask. The LLVM analogue
319 /// of cranelift's `lookup(cap_bit).is_some()`.
320 pub fn is_granted(&self, cap_bit: u32) -> bool {
321 cap_bit < MAX_CAP_BIT && (self.caps_mask & (1i64 << cap_bit)) != 0
322 }
323
324 /// The granted-capability bitmask, ready to hand to the linked
325 /// entry as its trailing `caps` param (or to
326 /// `LlvmAotEvaluator::with_caps`). This is the runtime carrier the
327 /// `Op::CheckCap` gate baked into the emitted object reads.
328 pub fn caps_mask(&self) -> i64 {
329 self.caps_mask
330 }
331
332 /// Register a dynamic `Arc<dyn RelonFunction>` host fn at the given
333 /// `import_idx`. Mirrors cranelift's
334 /// `CapabilityVtable::register_host_fn`; delegates to the existing
335 /// [`HostFnRegistry`] so the JIT-side `relon_llvm_call_native`
336 /// dispatch resolves against the same map.
337 pub fn register_host_fn(&mut self, import_idx: u32, func: Arc<dyn RelonFunction>) {
338 self.host_fns.register(import_idx, func);
339 }
340
341 /// Resolve the dynamic host fn registered at `import_idx`. Mirrors
342 /// cranelift's `CapabilityVtable::resolve_host_fn`.
343 pub fn resolve_host_fn(&self, import_idx: u32) -> Option<&Arc<dyn RelonFunction>> {
344 self.host_fns.resolve(import_idx)
345 }
346
347 /// Borrow the underlying [`HostFnRegistry`] so the evaluator can
348 /// install it on a per-call [`crate::state::ArenaState`] via
349 /// `ArenaState::install_host_fns`.
350 pub fn host_fns(&self) -> &HostFnRegistry {
351 &self.host_fns
352 }
353
354 /// Number of registered dynamic host fns.
355 pub fn host_fn_count(&self) -> usize {
356 self.host_fns.len()
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use relon_eval_api::{Capabilities, NativeArgs, Value};
364
365 #[test]
366 fn config_default_enables_all_guards() {
367 let cfg = SandboxConfig::default();
368 assert!(cfg.bounds_check);
369 assert!(cfg.deadline_check);
370 assert!(cfg.capability_check);
371 assert!(cfg.div_check);
372 }
373
374 #[test]
375 fn config_unchecked_disables_all_guards() {
376 let cfg = SandboxConfig::unchecked();
377 assert!(!cfg.bounds_check);
378 assert!(!cfg.deadline_check);
379 assert!(!cfg.capability_check);
380 assert!(!cfg.div_check);
381 }
382
383 #[test]
384 fn trap_kind_round_trips_through_u64_code() {
385 for kind in [
386 SandboxTrapKind::DivisionByZero,
387 SandboxTrapKind::BoundsViolation,
388 SandboxTrapKind::CapabilityDenied,
389 SandboxTrapKind::ResourceExhausted,
390 SandboxTrapKind::HostFnMissing,
391 SandboxTrapKind::NumericOverflow,
392 SandboxTrapKind::HostFnError,
393 ] {
394 let code = kind as u64;
395 assert_eq!(SandboxTrapKind::from_code(code), kind);
396 }
397 // Unknown / 0 codes route to HostFnError (defensive catch-all).
398 assert_eq!(SandboxTrapKind::from_code(0), SandboxTrapKind::HostFnError);
399 assert_eq!(SandboxTrapKind::from_code(99), SandboxTrapKind::HostFnError);
400 }
401
402 #[test]
403 fn trap_kind_numbering_mirrors_cranelift_and_native_trap() {
404 // The numbering MUST match cranelift's TrapKind and the
405 // crate-local NativeTrap subset so the host decodes one cause
406 // numbering across backends.
407 assert_eq!(SandboxTrapKind::CapabilityDenied as u64, 3);
408 assert_eq!(SandboxTrapKind::HostFnMissing as u64, 5);
409 assert_eq!(SandboxTrapKind::HostFnError as u64, 7);
410 assert_eq!(
411 SandboxTrapKind::CapabilityDenied as u64,
412 crate::state::NativeTrap::CapabilityDenied as u64
413 );
414 assert_eq!(
415 SandboxTrapKind::HostFnMissing as u64,
416 crate::state::NativeTrap::HostFnMissing as u64
417 );
418 assert_eq!(
419 SandboxTrapKind::HostFnError as u64,
420 crate::state::NativeTrap::HostFnError as u64
421 );
422 }
423
424 #[test]
425 fn trap_kind_maps_to_runtime_error_variant() {
426 let range = TokenRange::default();
427 assert!(matches!(
428 SandboxTrapKind::DivisionByZero.to_runtime_error(range),
429 RuntimeError::DivisionByZero(_)
430 ));
431 assert!(matches!(
432 SandboxTrapKind::BoundsViolation.to_runtime_error(range),
433 RuntimeError::IndexOutOfBounds { .. }
434 ));
435 assert!(matches!(
436 SandboxTrapKind::CapabilityDenied.to_runtime_error(range),
437 RuntimeError::CapabilityDenied { .. }
438 ));
439 assert!(matches!(
440 SandboxTrapKind::ResourceExhausted.to_runtime_error(range),
441 RuntimeError::StepLimitExceeded { .. }
442 ));
443 assert!(matches!(
444 SandboxTrapKind::NumericOverflow.to_runtime_error(range),
445 RuntimeError::NumericOverflow(_)
446 ));
447 assert!(matches!(
448 SandboxTrapKind::HostFnMissing.to_runtime_error(range),
449 RuntimeError::Unsupported { .. }
450 ));
451 }
452
453 #[test]
454 fn grant_and_is_granted_round_trip() {
455 let mut vt = CapabilityVtable::with_capacity(64);
456 assert!(!vt.is_granted(2));
457 vt.grant(2);
458 assert!(vt.is_granted(2));
459 assert!(!vt.is_granted(3));
460 // The runtime carrier is the bitmask: bit 2 set.
461 assert_eq!(vt.caps_mask(), 1i64 << 2);
462 }
463
464 #[test]
465 fn grant_ignores_out_of_range_bits() {
466 let mut vt = CapabilityVtable::with_capacity(64);
467 vt.grant(64);
468 vt.grant(200);
469 assert_eq!(vt.caps_mask(), 0);
470 assert!(!vt.is_granted(64));
471 }
472
473 #[test]
474 fn register_via_gate_denies_when_capability_not_granted() {
475 let caps = Capabilities::default();
476 let mut vt = CapabilityVtable::with_capacity(64);
477 // `reads_fs` not granted in the default snapshot — bit stays clear.
478 let populated = vt.register_via_gate(&caps, CapabilityBit::ReadsFs);
479 assert!(!populated, "denied gate must leave the mask bit clear");
480 assert!(!vt.is_granted(CapabilityBit::ReadsFs.bit_index()));
481 assert_eq!(vt.caps_mask(), 0);
482 }
483
484 #[test]
485 fn register_via_gate_populates_when_capability_granted() {
486 let caps = Capabilities::all_granted();
487 let mut vt = CapabilityVtable::with_capacity(64);
488 let populated = vt.register_via_gate(&caps, CapabilityBit::Network);
489 assert!(populated, "granted gate must set the mask bit");
490 assert!(vt.is_granted(CapabilityBit::Network.bit_index()));
491 assert_eq!(vt.caps_mask(), 1i64 << CapabilityBit::Network.bit_index());
492 }
493
494 /// Mirrors cranelift's `host_fns` half: a registered callable is
495 /// resolvable by `import_idx` and dispatch-callable.
496 struct AddOne;
497 impl RelonFunction for AddOne {
498 fn call(&self, args: NativeArgs, _r: TokenRange) -> Result<Value, RuntimeError> {
499 match args.positional.first() {
500 Some(Value::Int(x)) => Ok(Value::Int(x + 1)),
501 _ => Err(RuntimeError::Unsupported {
502 reason: "AddOne expects Int".into(),
503 }),
504 }
505 }
506 }
507
508 #[test]
509 fn host_fn_registry_round_trip() {
510 let mut vt = CapabilityVtable::with_capacity(64);
511 assert!(vt.resolve_host_fn(0).is_none());
512 vt.register_host_fn(0, Arc::new(AddOne));
513 assert_eq!(vt.host_fn_count(), 1);
514 let f = vt.resolve_host_fn(0).expect("registered");
515 let r = f
516 .call(
517 NativeArgs::from_positional(vec![Value::Int(41)], {
518 // reuse the crate's caps shim via a trivial closure-free path
519 use relon_eval_api::NativeFnCaps;
520 struct NoCb;
521 impl NativeFnCaps for NoCb {
522 fn call_relon(
523 &self,
524 _f: &Value,
525 _a: Vec<Value>,
526 _r: TokenRange,
527 ) -> Result<Value, RuntimeError> {
528 Err(RuntimeError::Unsupported {
529 reason: "no cb".into(),
530 })
531 }
532 }
533 Arc::new(NoCb)
534 }),
535 TokenRange::default(),
536 )
537 .expect("dispatch");
538 assert_eq!(r, Value::Int(42));
539 }
540}