uni_plugin_rhai/
engine.rs1#[cfg(feature = "rhai-runtime")]
21use rhai::Engine;
22
23use uni_plugin::{Capability, CapabilitySet};
24
25use crate::host_fns::RhaiHostFnRegistry;
26
27pub const DEFAULT_MAX_CALL_LEVELS: usize = 64;
31
32pub const DEFAULT_MAX_OPERATIONS: u64 = 10_000_000;
41
42#[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 engine.disable_symbol("eval");
59
60 engine.set_module_resolver(rhai::module_resolvers::DummyModuleResolver::new());
63
64 engine.set_max_call_levels(DEFAULT_MAX_CALL_LEVELS);
66
67 engine.set_max_operations(DEFAULT_MAX_OPERATIONS);
72
73 apply_resource_limits(&mut engine, effective_caps);
75
76 crate::columns::register_column_types(&mut engine);
78
79 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 (spec.register)(&mut engine, effective_caps);
89 }
90 }
91
92 engine
93}
94
95#[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 engine.set_max_operations((*n).max(DEFAULT_MAX_OPERATIONS));
108 }
109 Capability::MemoryBytes(n) => {
110 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
123fn 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 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 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 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 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 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}