Skip to main content

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}