relon_eval_api/context.rs
1//! Shared evaluator context: host policy + sandbox state.
2//!
3//! `Context` is the carrier of all backend-agnostic configuration: the
4//! root AST node, decorator and native-fn registries, module resolvers,
5//! capability grants, and the per-run caches a backend uses to thread
6//! state across `eval_root` / `run_main` invocations.
7//!
8//! Most fields are `pub` so that any backend implementing
9//! [`crate::Evaluator`] in a different crate can read and update them.
10//! Sandbox-relevant state (`capabilities`, `module_resolvers`,
11//! `analyzed`) is private: reads go through the `&`-returning getters
12//! and writes through the construction-time `with_*` / controlled
13//! `*_module_resolver` entry points, so a host cannot silently widen
14//! a sandbox after handing the context to an evaluator.
15
16use crate::decorator::DecoratorPlugin;
17use crate::module::ModuleResolver;
18use crate::native_fn::RelonFunction;
19use crate::value::Value;
20use relon_parser::Node;
21use std::collections::{HashMap, HashSet};
22use std::sync::atomic::AtomicU64;
23use std::sync::{Arc, Mutex};
24
25// Canonical capability data types (`CapabilityBit`, `NativeFnGate`,
26// `Capabilities`) now live in the zero-dependency `relon-cap` leaf crate
27// so the analyzer can share the exact same definitions instead of
28// mirroring them field-for-field. Re-exported here at their historical
29// `relon_eval_api::context::{...}` (and, via `lib.rs`,
30// `relon_eval_api::{...}`) paths so every existing reference keeps
31// resolving unchanged. The enforcement machinery that references
32// eval-api types — `CapabilityGate`, `GatedNativeFn`, `NativeFnCaps` —
33// stays in this crate.
34pub use relon_cap::{
35 Capabilities, CapabilityBit, NativeFnGate, ResourceBudget, ResourceBudgetProfile,
36};
37
38/// Internal helper: a registered native function with its capability gate.
39/// `pub` so backend crates can read both the underlying `func` and the
40/// declared `gate` when dispatching a call.
41pub struct GatedNativeFn {
42 pub func: Arc<dyn RelonFunction>,
43 pub gate: NativeFnGate,
44}
45
46/// Shared execution environment for one or more evaluations.
47///
48/// Holds the document root, registered plugins, cached modules, and
49/// sandbox [`Capabilities`]. Thread-safe.
50///
51/// Most fields are `pub` so any backend implementing [`crate::Evaluator`]
52/// from a separate crate can read and update them. The sandbox-policy
53/// fields (`capabilities`, `module_resolvers`, `analyzed`) are private;
54/// hosts and backends go through the constructor / `register_*` /
55/// `with_*` helpers and the `&`-returning getters instead.
56pub struct Context {
57 pub root_node: Option<Arc<Node>>,
58 pub decorators: HashMap<String, Arc<dyn DecoratorPlugin>>,
59 pub functions: HashMap<String, GatedNativeFn>,
60 /// Schema-rooted Phase D: native methods registered against a
61 /// specific schema. Keyed by `(schema_name, method_name)` so a
62 /// host can attach `register_method("Money", "cents_value", gate,
63 /// func)` and the evaluator dispatches `m.cents_value()` to it
64 /// when `m`'s brand is `"Money"`. Mirrors the analyzer's
65 /// `tree.method_signatures` shape; the `#native` directive on a
66 /// `with { ... }` method declares the slot, the host fills it at
67 /// runtime through this map.
68 /// P2-4: nested map keyed `schema -> method -> entry` so per-call
69 /// `try_call_native_method` looks up without minting a
70 /// `(String, String)` tuple on every dispatch. The outer/inner
71 /// `HashMap::get(&str)` paths borrow the schema/method names
72 /// directly, eliminating the prior 2 × `String::from`
73 /// allocations on every comparator / index / arithmetic dispatch.
74 pub native_methods: HashMap<String, HashMap<String, GatedNativeFn>>,
75 pub schemas: HashMap<String, Value>,
76 /// Ordered module-resolution chain consulted front-to-back by the
77 /// evaluator's `#import` handling. Private: mutation goes through
78 /// [`Context::prepend_module_resolver`] /
79 /// [`Context::append_module_resolver`] so the sandbox's resolver
80 /// order (e.g. the default-deny tail installed for
81 /// [`Context::sandboxed`]) cannot be silently replaced wholesale.
82 module_resolvers: Vec<Arc<dyn ModuleResolver>>,
83 pub path_cache: Mutex<HashMap<String, Value>>,
84 pub module_cache: Mutex<HashMap<String, Value>>,
85 /// Backing cursor table for user-callable `Iter.next()`. Keyed by
86 /// the `u64` iter-id minted by [`Context::next_iter_id`] at the
87 /// `iter()` call site and stamped into the resulting `Iter`-branded
88 /// dict as `_id`. The `Value` graph is immutable (`Arc`-shared, no
89 /// interior mutability), so cursor state must live outside it; this
90 /// Context field is the canonical home — entries die when the
91 /// Context is dropped, and the table is cleared at the start of
92 /// every top-level `eval_root` / `run_main` so long-running hosts
93 /// reusing a Context never accumulate stale cursors. Cross-Context
94 /// `Iter` values surface as exhausted (`next()` returns `None`):
95 /// see `NativeFnCaps::iter_cursor_fetch_and_inc`.
96 pub iter_cursors: Mutex<HashMap<u64, usize>>,
97 /// Monotonic per-Context id generator paired with
98 /// [`Context::iter_cursors`]. Wraps at `u64::MAX`, effectively
99 /// never reached in practice. Deliberately not reset on
100 /// `eval_root` / `run_main` cleanup — the cursor table is, but
101 /// the counter must keep climbing so a still-live `Iter` dict
102 /// from the prior run can't collide with a fresh one in the
103 /// new run.
104 pub iter_id_counter: AtomicU64,
105 /// Modules currently on the load stack, with a re-entry counter so
106 /// the same canonical id can appear multiple times (e.g. via `as=`
107 /// vs `spread=true`) without the inner guard's `Drop` clearing the
108 /// outer frame's record. Decrement on drop, remove when zero.
109 pub loading_modules: Mutex<HashMap<String, usize>>,
110 pub evaluating_paths: Mutex<HashSet<String>>,
111 pub step_counter: AtomicU64,
112 /// Monotonic counter incremented once per closure invocation. Used
113 /// by `eval_closure` to derive a fresh `cache_namespace` for each
114 /// call so that path-cache entries computed inside the closure body
115 /// (e.g. `&sibling.x`) are not shared across distinct invocations
116 /// with different bound parameters.
117 pub closure_call_counter: AtomicU64,
118 /// Analyzer side-table for the entry file. Private: installed at
119 /// construction time via [`Context::with_analyzed`] /
120 /// [`Context::with_workspace`], read through
121 /// [`Context::analyzed`] — backends never swap the tree under a
122 /// live evaluation.
123 analyzed: Option<Arc<relon_analyzer::AnalyzedTree>>,
124 /// Pre-computed workspace tree (entry + every reachable module),
125 /// produced by `relon_analyzer::analyze_entry`. When present, the
126 /// evaluator's `evaluate_module_source` skips the per-module
127 /// parse-plus-analyze pass and looks up the cached node and
128 /// analyzed tree directly. The field is independent of
129 /// `analyzed`; the latter remains the side-table for the entry
130 /// file specifically, so existing callers that don't drive
131 /// workspace analysis keep working unchanged.
132 pub workspace: Option<Arc<relon_analyzer::WorkspaceTree>>,
133 /// Sandbox capability grants. Private so the only write path is
134 /// construction-time [`Context::with_capabilities`]; once the
135 /// context is handed to an evaluator the grants are immutable
136 /// policy, read through [`Context::capabilities`]. This is the
137 /// audit guarantee: an embedder cannot widen a running sandbox by
138 /// poking the field.
139 capabilities: Capabilities,
140 /// Set by [`Context::sandboxed`] so the backend's deferred setup
141 /// step can attach the default-deny filesystem resolver after the
142 /// stdlib / decorators / prelude registration. Untouched by the
143 /// bare [`Context::new`] constructor.
144 pub sandboxed_flag: bool,
145 /// Tracks whether the backend has already installed its default
146 /// stdlib / decorators / prelude into this context. Flipped from
147 /// `false` to `true` by `TreeWalkEvaluator::new` (and any future
148 /// backend) on first wrap, so a `Context` reused across multiple
149 /// evaluator instances doesn't pay the registration cost twice
150 /// and a host re-registering an intrinsic isn't silently undone
151 /// on a second wrap.
152 pub backend_prepared: bool,
153}
154
155impl Default for Context {
156 fn default() -> Self {
157 Self::new()
158 }
159}
160
161impl Context {
162 /// Construct a [`Context`] with no plugins / resolvers / stdlib
163 /// pre-registered. Backend crates (e.g. `relon-evaluator`) attach
164 /// their own stdlib + decorators + module resolvers when the host
165 /// constructs an evaluator on top of this context (the tree-walking
166 /// backend does this lazily in `TreeWalkEvaluator::new` so users
167 /// keep the historical "call `Context::new()` then go" ergonomics).
168 pub fn new() -> Self {
169 Self {
170 root_node: None,
171 decorators: HashMap::new(),
172 functions: HashMap::new(),
173 native_methods: HashMap::new(),
174 schemas: HashMap::new(),
175 module_resolvers: Vec::new(),
176 path_cache: Mutex::new(HashMap::new()),
177 module_cache: Mutex::new(HashMap::new()),
178 iter_cursors: Mutex::new(HashMap::new()),
179 iter_id_counter: AtomicU64::new(0),
180 loading_modules: Mutex::new(HashMap::new()),
181 evaluating_paths: Mutex::new(HashSet::new()),
182 step_counter: AtomicU64::new(0),
183 closure_call_counter: AtomicU64::new(0),
184 analyzed: None,
185 workspace: None,
186 capabilities: Capabilities::default(),
187 sandboxed_flag: false,
188 backend_prepared: false,
189 }
190 }
191
192 /// Sandboxed counterpart to [`Self::new`]. The bare construction is
193 /// identical; the only difference is `sandboxed_flag = true`, which
194 /// the active backend reads when it installs its defaults so a
195 /// default-deny filesystem resolver is appended after the standard
196 /// `std/...` resolver. The tree-walking backend implements this
197 /// hook in `TreeWalkEvaluator::new`.
198 pub fn sandboxed() -> Self {
199 let mut this = Self::new();
200 this.sandboxed_flag = true;
201 this
202 }
203
204 pub fn with_root(mut self, node: Node) -> Self {
205 self.root_node = Some(Arc::new(node));
206 self
207 }
208
209 pub fn with_analyzed(mut self, tree: Arc<relon_analyzer::AnalyzedTree>) -> Self {
210 self.analyzed = Some(tree);
211 self
212 }
213
214 /// Wire a pre-computed workspace tree into the context. The
215 /// workspace's entry tree (if present) is also installed as
216 /// `analyzed` so callers that read either field see consistent
217 /// data — gives single-file consumers the same view they had
218 /// before, and gives module-loading code a fast path to skip
219 /// per-module parse + analyze.
220 pub fn with_workspace(mut self, workspace: Arc<relon_analyzer::WorkspaceTree>) -> Self {
221 if let Some(entry) = workspace.modules.get(&workspace.entry_id) {
222 self.analyzed = Some(Arc::clone(entry));
223 }
224 self.workspace = Some(workspace);
225 self
226 }
227
228 /// Set the sandbox capability grants. Construction-time only by
229 /// design: the method consumes `self`, so it composes with the
230 /// other `with_*` builders but cannot retarget a context that is
231 /// already shared with an evaluator (those hold `Arc<Context>`).
232 /// There is deliberately no `&mut self` setter — widening a
233 /// sandbox mid-run is not a supported operation.
234 pub fn with_capabilities(mut self, capabilities: Capabilities) -> Self {
235 self.capabilities = capabilities;
236 self
237 }
238
239 /// Read-only view of the sandbox capability grants.
240 pub fn capabilities(&self) -> &Capabilities {
241 &self.capabilities
242 }
243
244 /// Read-only view of the analyzer side-table for the entry file,
245 /// when one was installed via [`Self::with_analyzed`] /
246 /// [`Self::with_workspace`].
247 pub fn analyzed(&self) -> Option<&Arc<relon_analyzer::AnalyzedTree>> {
248 self.analyzed.as_ref()
249 }
250
251 /// Read-only view of the module-resolution chain, in consultation
252 /// order (front wins).
253 pub fn module_resolvers(&self) -> &[Arc<dyn ModuleResolver>] {
254 &self.module_resolvers
255 }
256
257 /// Insert a resolver at the front of the chain so it is consulted
258 /// before every existing resolver (front wins).
259 pub fn prepend_module_resolver(&mut self, resolver: Arc<dyn ModuleResolver>) {
260 self.module_resolvers.insert(0, resolver);
261 }
262
263 /// Append a resolver at the back of the chain so it is consulted
264 /// only when no earlier resolver claimed the path. This is where a
265 /// backend installs catch-all / default-deny resolvers (e.g. the
266 /// sandboxed filesystem resolver) during its prepare step.
267 pub fn append_module_resolver(&mut self, resolver: Arc<dyn ModuleResolver>) {
268 self.module_resolvers.push(resolver);
269 }
270
271 /// Register a native function with explicit capability requirements.
272 /// The function declares which bits it needs via `gate`; under the
273 /// sandbox the call is rejected unless every set bit is granted in
274 /// the context-wide [`Capabilities`].
275 ///
276 /// For pure functions (no host capability, no I/O, no ambient
277 /// state) prefer [`Self::register_pure_fn`] — it makes the
278 /// "this fn is pure" intent explicit. Passing
279 /// `NativeFnGate::default()` here is equivalent.
280 pub fn register_fn<S: Into<String>>(
281 &mut self,
282 name: S,
283 gate: NativeFnGate,
284 func: Arc<dyn RelonFunction>,
285 ) {
286 self.functions
287 .insert(name.into(), GatedNativeFn { func, gate });
288 }
289
290 /// Register a pure native function: no I/O, no ambient state, no
291 /// host capability required. Equivalent to
292 /// `register_fn(name, NativeFnGate::default(), func)`. The all-zero
293 /// gate is trivially satisfied by every `Capabilities` value, so
294 /// pure fns keep working under a fully sandboxed context.
295 ///
296 /// Stdlib intrinsics (`len`, `range`, `string.*`, …) and
297 /// deterministic host fns whose contract is "args in, value out"
298 /// register through this entry point.
299 pub fn register_pure_fn<S: Into<String>>(&mut self, name: S, func: Arc<dyn RelonFunction>) {
300 self.register_fn(name, NativeFnGate::default(), func);
301 }
302
303 /// Schema-rooted Phase D: attach a host-supplied implementation to
304 /// a `#native` method on a specific schema. The evaluator
305 /// dispatches `value.method(...)` to this fn whenever `value`'s
306 /// brand matches `schema` and the source-side method body is
307 /// absent (declared `#native`). Capability gating mirrors
308 /// [`Self::register_fn`]: the `gate` declares which
309 /// [`Capabilities`] bits the body needs at runtime, and a denied
310 /// caller surfaces `RuntimeError::CapabilityDenied`.
311 ///
312 /// Replaces the v1 pattern of `register_fn("Schema.method", ...)`
313 /// with a key shape that tracks the schema-rooted dispatch model
314 /// directly — no string concatenation, no shadowing of free fn
315 /// names by accident.
316 pub fn register_method<S: Into<String>, M: Into<String>>(
317 &mut self,
318 schema: S,
319 method: M,
320 gate: NativeFnGate,
321 func: Arc<dyn RelonFunction>,
322 ) {
323 self.native_methods
324 .entry(schema.into())
325 .or_default()
326 .insert(method.into(), GatedNativeFn { func, gate });
327 }
328
329 /// Pure-method counterpart to [`Self::register_method`]. Equivalent
330 /// to passing [`NativeFnGate::default`] (the all-zero gate) — the
331 /// method body needs no host capability, so it dispatches under
332 /// every [`Capabilities`] including the zero-trust default.
333 pub fn register_pure_method<S: Into<String>, M: Into<String>>(
334 &mut self,
335 schema: S,
336 method: M,
337 func: Arc<dyn RelonFunction>,
338 ) {
339 self.register_method(schema, method, NativeFnGate::default(), func);
340 }
341
342 pub fn register_decorator<S: Into<String>>(
343 &mut self,
344 name: S,
345 plugin: Arc<dyn DecoratorPlugin>,
346 ) {
347 self.decorators.insert(name.into(), plugin);
348 }
349
350 pub fn register_schema<S: Into<String>>(&mut self, name: S, schema: Value) {
351 self.schemas.insert(name.into(), schema);
352 }
353
354 pub fn enter_loading_module(&self, id: String) -> LoadingModuleGuard<'_> {
355 *self
356 .loading_modules
357 .lock()
358 .unwrap()
359 .entry(id.clone())
360 .or_insert(0) += 1;
361 LoadingModuleGuard {
362 context: self,
363 module_id: id,
364 }
365 }
366
367 pub fn analyzer_target(&self, id: relon_parser::NodeId) -> Option<Node> {
368 self.analyzed()
369 .and_then(|tree| tree.node(id).map(|arc| (**arc).clone()))
370 }
371
372 /// Mint a fresh `Iter` cursor id under this Context **and seed a
373 /// zero cursor entry** so that subsequent
374 /// [`Context::iter_cursor_fetch_and_inc`] calls can distinguish a
375 /// "freshly minted, cursor at 0" iter from a foreign-Context iter
376 /// (no entry → treated as exhausted; see policy note on
377 /// `iter_cursor_fetch_and_inc`).
378 ///
379 /// Each `xs.iter()` consumes one id; two Contexts mint
380 /// independently because each owns its own counter. Wraps at
381 /// `u64::MAX` — reachable only in pathological constructions —
382 /// and the `Relaxed` ordering is sufficient because the id is
383 /// opaque outside of [`Context::iter_cursors`] lookup.
384 pub fn next_iter_id(&self) -> u64 {
385 use std::sync::atomic::Ordering;
386 let id = self.iter_id_counter.fetch_add(1, Ordering::Relaxed);
387 // Pre-register the cursor so the "missing entry → exhausted"
388 // signal in `iter_cursor_fetch_and_inc` cleanly distinguishes
389 // a foreign-Context `_id` from a fresh local one.
390 self.iter_cursors.lock().unwrap().insert(id, 0);
391 id
392 }
393
394 /// Atomically read the cursor for `iter_id`, and if `cursor < len`,
395 /// post-increment and return the old value; otherwise return
396 /// `None`. **A missing entry** (no cursor was ever minted for
397 /// `iter_id` in this Context) is also reported as `None` —
398 /// idempotent end-of-iter, matching the `Option::None` return
399 /// type of `Iter.next() -> Option<T>`.
400 ///
401 /// Cross-Context policy (deliberate): if the host hands an
402 /// `Iter` value built in Context A to Context B and then calls
403 /// `next()`, Context B's table has no entry for that id, so we
404 /// return `None`. This is the gentlest reading of "an iter
405 /// belongs to its originating Context" — no new error variant,
406 /// no capability trap; the iter simply looks exhausted to the
407 /// foreign Context. A future stricter mode could surface a
408 /// dedicated `RuntimeError::IterNotOwnedByContext`, but today's
409 /// host APIs don't yet expose a way to attach an iter to a
410 /// Context other than via `iter()` itself, so the implicit-
411 /// exhausted reading is sufficient and matches the
412 /// "no implicit ambient state" design promise.
413 pub fn iter_cursor_fetch_and_inc(&self, iter_id: u64, len: usize) -> Option<usize> {
414 // Single-lock atomic read-check-increment. Spelled out so
415 // the bounds check and the bump happen under the same
416 // critical section — splitting them would let a concurrent
417 // caller observe a stale "in bounds" reading after the
418 // cursor moved.
419 let mut cursors = self.iter_cursors.lock().unwrap();
420 // Do *not* `entry(...).or_insert(0)`: a foreign-Context id
421 // must surface as `None` rather than silently spawn a fresh
422 // cursor in this Context's table (which would start it
423 // walking from 0 against a `_source` the caller's Context
424 // never validated).
425 let cursor_slot = cursors.get_mut(&iter_id)?;
426 if *cursor_slot < len {
427 let idx = *cursor_slot;
428 *cursor_slot += 1;
429 Some(idx)
430 } else {
431 None
432 }
433 }
434}
435
436pub struct LoadingModuleGuard<'a> {
437 context: &'a Context,
438 module_id: String,
439}
440
441impl Drop for LoadingModuleGuard<'_> {
442 fn drop(&mut self) {
443 let mut loading = self.context.loading_modules.lock().unwrap();
444 if let Some(count) = loading.get_mut(&self.module_id) {
445 *count -= 1;
446 if *count == 0 {
447 loading.remove(&self.module_id);
448 }
449 }
450 }
451}
452
453#[cfg(test)]
454mod cap_bit_tests {
455 use super::*;
456
457 #[test]
458 fn cap_bit_indices_are_stable() {
459 // Stability contract: discriminants don't shift around, so a
460 // module emitted against an older codegen still keys the same
461 // capability bit the host's gate / vtable expects.
462 assert_eq!(CapabilityBit::ReadsFs.bit_index(), 0);
463 assert_eq!(CapabilityBit::WritesFs.bit_index(), 1);
464 assert_eq!(CapabilityBit::Network.bit_index(), 2);
465 assert_eq!(CapabilityBit::ReadsClock.bit_index(), 3);
466 assert_eq!(CapabilityBit::ReadsEnv.bit_index(), 4);
467 assert_eq!(CapabilityBit::UsesRng.bit_index(), 5);
468 }
469}