Skip to main content

uni_plugin_rhai/
engine.rs

1//! Per-plugin `rhai::Engine` factory.
2//!
3//! Builds a Rhai engine configured for the framework's sandbox model:
4//!
5//! - **Eval disabled** at the symbol level so scripts cannot smuggle in
6//!   dynamic code generation.
7//! - **Module resolver replaced with a deny-all stub** so `import` always
8//!   fails. Modules can only be made available through host-registered
9//!   Rhai packages (none are exposed in v1).
10//! - **Resource limits** wired from the effective `CapabilitySet`:
11//!   `Capability::FuelPerCall(N)` → `Engine::set_max_operations(N)`;
12//!   `Capability::MemoryBytes(N)` → conservative caps on string / array /
13//!   map sizes (full memory accounting is M10's broader work).
14//! - **Capability-gated host fns** registered conditionally — fns whose
15//!   `required_capability` is not in the effective set are simply not
16//!   registered, and the script fails at parse-resolution with
17//!   `ErrorFunctionNotFound`. This is the in-host analogue of CM's
18//!   linker-absence guarantee (proposal §10.2).
19
20#[cfg(feature = "rhai-runtime")]
21use rhai::Engine;
22
23use uni_plugin::{Capability, CapabilitySet};
24
25use crate::host_fns::RhaiHostFnRegistry;
26
27/// Default maximum recursion depth for Rhai scripts. Overridable by
28/// scripts via the loader's per-plugin engine configuration; future:
29/// expose a `Capability::MaxCallLevels(N)` so plugins can request more.
30pub const DEFAULT_MAX_CALL_LEVELS: usize = 64;
31
32/// Default Rhai operation-limit floor applied to every engine.
33///
34/// Rhai's built-in default of `max_operations = 0` means *unlimited*, so
35/// without an explicit cap a malicious or buggy plugin running `while true
36/// {}` would wedge the synchronous DataFusion worker thread forever. This
37/// floor bounds every engine's per-call work even when no
38/// `Capability::FuelPerCall` grant is present. A `FuelPerCall` grant may
39/// only *raise* this floor, never lower or disable it.
40pub const DEFAULT_MAX_OPERATIONS: u64 = 10_000_000;
41
42/// Build a Rhai engine pre-configured for a single plugin's effective
43/// capability set.
44///
45/// The returned Engine has:
46/// - Full Rhai stdlib (math, array, map, string, time).
47/// - `eval` disabled.
48/// - A deny-all module resolver (no `import` statements work).
49/// - Resource limits applied from `effective_caps`.
50/// - Each `host_fns` spec whose required capability is satisfied has its
51///   `register` closure invoked against the engine.
52#[cfg(feature = "rhai-runtime")]
53#[must_use]
54pub fn build_engine(effective_caps: &CapabilitySet, host_fns: &RhaiHostFnRegistry) -> Engine {
55    let mut engine = Engine::new();
56
57    // Disable eval — no dynamic code generation inside scripts.
58    engine.disable_symbol("eval");
59
60    // Deny-all module resolver. Rhai's `DummyModuleResolver` always
61    // returns "ModuleNotFound" for any path, blocking `import` statements.
62    engine.set_module_resolver(rhai::module_resolvers::DummyModuleResolver::new());
63
64    // Always cap call depth (stack-overflow protection).
65    engine.set_max_call_levels(DEFAULT_MAX_CALL_LEVELS);
66
67    // Always apply an operation-limit floor. Rhai defaults to
68    // `max_operations = 0` (unlimited); without this every engine lacking a
69    // `FuelPerCall` grant could be wedged forever by an unbounded loop. A
70    // grant raises this floor in `apply_resource_limits`; it never lowers it.
71    engine.set_max_operations(DEFAULT_MAX_OPERATIONS);
72
73    // Apply resource limits from capabilities.
74    apply_resource_limits(&mut engine, effective_caps);
75
76    // Always-available column userdata for vectorized mode.
77    crate::columns::register_column_types(&mut engine);
78
79    // Register capability-gated host fns.
80    for spec in host_fns.iter() {
81        let granted = match &spec.required_capability {
82            None => true,
83            Some(required) => caps_grant(effective_caps, required),
84        };
85        if granted {
86            // Pass the effective set so the registered fn can enforce
87            // call-time attenuation (URL allow-list, key-id match, …).
88            (spec.register)(&mut engine, effective_caps);
89        }
90    }
91
92    engine
93}
94
95/// Stub variant used when the `rhai-runtime` feature is disabled — keeps
96/// the crate compiling for embedders that only want the trait surface.
97#[cfg(not(feature = "rhai-runtime"))]
98pub fn build_engine(_effective_caps: &CapabilitySet, _host_fns: &RhaiHostFnRegistry) -> () {}
99
100#[cfg(feature = "rhai-runtime")]
101fn apply_resource_limits(engine: &mut Engine, caps: &CapabilitySet) {
102    for cap in caps.iter() {
103        match cap {
104            Capability::FuelPerCall(n) => {
105                // A grant may only raise the always-applied floor, never
106                // lower it (and never re-enable Rhai's unlimited default of 0).
107                engine.set_max_operations((*n).max(DEFAULT_MAX_OPERATIONS));
108            }
109            Capability::MemoryBytes(n) => {
110                // Rhai doesn't have a direct total-memory cap. Apply
111                // conservative per-collection caps derived from the
112                // total budget. Full memory accounting is M10 work.
113                let per_collection = (*n / 4).max(1024) as usize;
114                engine.set_max_string_size(per_collection);
115                engine.set_max_array_size(per_collection);
116                engine.set_max_map_size(per_collection);
117            }
118            _ => {}
119        }
120    }
121}
122
123/// Does the effective set grant a capability variant that satisfies
124/// `required`?
125///
126/// For now this is variant-equality. Pattern-narrowed grants
127/// (`Filesystem { read: ["/data/**"] }`) are validated at host-fn-body
128/// call time (Phase 5), not at engine-construction time.
129fn caps_grant(effective: &CapabilitySet, required: &Capability) -> bool {
130    effective.contains_variant(required)
131}
132
133#[cfg(all(test, feature = "rhai-runtime"))]
134mod tests {
135    use super::*;
136    use crate::host_fns::RhaiHostFnSpec;
137    use std::sync::Arc;
138
139    fn empty_caps() -> CapabilitySet {
140        CapabilitySet::new()
141    }
142
143    #[test]
144    fn eval_is_disabled() {
145        let engine = build_engine(&empty_caps(), &RhaiHostFnRegistry::new());
146        // `eval` is a Rhai keyword; disable_symbol turns it into a parse
147        // error.
148        let result = engine.eval::<rhai::Dynamic>(r#"eval("1 + 1")"#);
149        assert!(result.is_err(), "eval should be disabled");
150    }
151
152    #[test]
153    fn import_is_denied() {
154        let engine = build_engine(&empty_caps(), &RhaiHostFnRegistry::new());
155        let script = r#"import "math" as m; m.pi"#;
156        let result = engine.eval::<rhai::Dynamic>(script);
157        assert!(
158            result.is_err(),
159            "import should be denied by module resolver"
160        );
161    }
162
163    #[test]
164    fn ungranted_host_fn_not_registered() {
165        let mut host_fns = RhaiHostFnRegistry::new();
166        host_fns.register(RhaiHostFnSpec {
167            name: "uni.fs.read".to_owned(),
168            required_capability: Some(Capability::Filesystem {
169                read: vec!["/data/**".into()],
170                write: vec![],
171            }),
172            docs: String::new(),
173            register: Arc::new(|engine: &mut Engine, _caps: &CapabilitySet| {
174                engine.register_fn("uni_fs_read", |_path: &str| "ok".to_string());
175            }),
176        });
177        let engine = build_engine(&empty_caps(), &host_fns);
178        let result = engine.eval::<String>(r#"uni_fs_read("/data/x")"#);
179        assert!(result.is_err(), "ungranted host fn must not resolve");
180    }
181
182    #[test]
183    fn granted_host_fn_callable() {
184        let mut host_fns = RhaiHostFnRegistry::new();
185        let cap = Capability::Filesystem {
186            read: vec!["/data/**".into()],
187            write: vec![],
188        };
189        host_fns.register(RhaiHostFnSpec {
190            name: "uni.fs.read".to_owned(),
191            required_capability: Some(cap.clone()),
192            docs: String::new(),
193            register: Arc::new(|engine: &mut Engine, _caps: &CapabilitySet| {
194                engine.register_fn("uni_fs_read", |_path: &str| "ok".to_string());
195            }),
196        });
197        let caps = CapabilitySet::from_iter_of([cap]);
198        let engine = build_engine(&caps, &host_fns);
199        let result: String = engine
200            .eval(r#"uni_fs_read("/data/x")"#)
201            .expect("should call");
202        assert_eq!(result, "ok");
203    }
204
205    #[test]
206    fn fuel_limit_trips() {
207        // Grant a budget above the always-applied DEFAULT_MAX_OPERATIONS
208        // floor so this exercises the granted limit, not the floor.
209        let grant = DEFAULT_MAX_OPERATIONS + 2_000_000;
210        let caps = CapabilitySet::from_iter_of([Capability::FuelPerCall(grant)]);
211        let engine = build_engine(&caps, &RhaiHostFnRegistry::new());
212        // 100M loop iterations cost far more than `grant` operations.
213        let script = r#"
214            let i = 0;
215            while i < 100000000 {
216                i += 1;
217            }
218            i
219        "#;
220        let result = engine.eval::<i64>(script);
221        assert!(
222            result.is_err(),
223            "a FuelPerCall grant should trip on a long-enough loop"
224        );
225    }
226
227    #[test]
228    fn default_op_limit_floor_applies_without_fuel_grant() {
229        // No FuelPerCall grant: the DEFAULT_MAX_OPERATIONS floor must still
230        // trip an unbounded loop instead of Rhai's unlimited default.
231        let engine = build_engine(&empty_caps(), &RhaiHostFnRegistry::new());
232        let script = r#"
233            let i = 0;
234            while true {
235                i += 1;
236            }
237            i
238        "#;
239        let result = engine.eval::<i64>(script);
240        assert!(
241            result.is_err(),
242            "the default op-limit floor must trip an unbounded loop without any fuel grant"
243        );
244    }
245
246    #[test]
247    fn fuel_grant_below_floor_is_raised_to_floor() {
248        // A grant below the floor must not lower it: a loop that exceeds the
249        // tiny grant but stays under the floor must succeed.
250        let caps = CapabilitySet::from_iter_of([Capability::FuelPerCall(1_000)]);
251        let engine = build_engine(&caps, &RhaiHostFnRegistry::new());
252        let script = r#"
253            let i = 0;
254            while i < 50000 {
255                i += 1;
256            }
257            i
258        "#;
259        let result: i64 = engine
260            .eval(script)
261            .expect("a sub-floor grant must be raised to the floor, not lowered");
262        assert_eq!(result, 50000);
263    }
264
265    #[test]
266    fn always_available_fn_registered_with_empty_caps() {
267        let mut host_fns = RhaiHostFnRegistry::new();
268        host_fns.register(RhaiHostFnSpec {
269            name: "uni.always".to_owned(),
270            required_capability: None,
271            docs: String::new(),
272            register: Arc::new(|engine: &mut Engine, _caps: &CapabilitySet| {
273                engine.register_fn("uni_always", || 42_i64);
274            }),
275        });
276        let engine = build_engine(&empty_caps(), &host_fns);
277        let result: i64 = engine.eval("uni_always()").expect("should call");
278        assert_eq!(result, 42);
279    }
280}