gravityfile_plugin/rhai/
runtime.rs

1//! Rhai runtime implementation.
2
3use std::collections::HashMap;
4use std::path::Path;
5
6use rhai::{Dynamic, Engine, Scope, AST};
7
8use crate::config::{PluginConfig, PluginMetadata};
9use crate::hooks::{Hook, HookContext, HookResult};
10use crate::runtime::{BoxFuture, IsolatedContext, PluginHandle, PluginRuntime};
11use crate::sandbox::SandboxConfig;
12use crate::types::{PluginError, PluginResult, Value};
13
14/// A loaded Rhai plugin.
15struct LoadedRhaiPlugin {
16    /// Plugin name/id.
17    name: String,
18
19    /// Compiled AST.
20    ast: AST,
21
22    /// Plugin metadata.
23    metadata: PluginMetadata,
24
25    /// Hooks implemented by this plugin.
26    hooks: Vec<String>,
27}
28
29/// Rhai plugin runtime.
30pub struct RhaiRuntime {
31    /// The Rhai engine.
32    engine: Engine,
33
34    /// Loaded plugins by handle.
35    plugins: HashMap<PluginHandle, LoadedRhaiPlugin>,
36
37    /// Next plugin handle ID.
38    next_handle: usize,
39
40    /// Runtime configuration.
41    config: Option<PluginConfig>,
42
43    /// Whether the runtime has been initialized.
44    initialized: bool,
45}
46
47impl RhaiRuntime {
48    /// Create a new Rhai runtime.
49    pub fn new() -> PluginResult<Self> {
50        let mut engine = Engine::new();
51
52        // Configure safety limits
53        engine.set_max_expr_depths(64, 64);
54        engine.set_max_call_levels(64);
55        engine.set_max_operations(1_000_000);
56        engine.set_max_modules(100);
57        engine.set_max_string_size(1024 * 1024); // 1MB strings
58        engine.set_max_array_size(10_000);
59        engine.set_max_map_size(10_000);
60
61        Ok(Self {
62            engine,
63            plugins: HashMap::new(),
64            next_handle: 0,
65            config: None,
66            initialized: false,
67        })
68    }
69
70    /// Initialize the Rhai engine with the gravityfile API.
71    fn init_api(&mut self) -> PluginResult<()> {
72        // Register logging functions
73        self.engine.register_fn("log_info", |msg: &str| {
74            tracing::info!(target: "plugin", "{}", msg);
75        });
76
77        self.engine.register_fn("log_warn", |msg: &str| {
78            tracing::warn!(target: "plugin", "{}", msg);
79        });
80
81        self.engine.register_fn("log_error", |msg: &str| {
82            tracing::error!(target: "plugin", "{}", msg);
83        });
84
85        self.engine.register_fn("notify", |msg: &str| {
86            tracing::info!(target: "plugin_notify", "{}", msg);
87        });
88
89        // Register filesystem functions
90        self.engine
91            .register_fn("fs_exists", |path: &str| -> bool {
92                std::path::Path::new(path).exists()
93            });
94
95        self.engine
96            .register_fn("fs_is_dir", |path: &str| -> bool {
97                std::path::Path::new(path).is_dir()
98            });
99
100        self.engine
101            .register_fn("fs_is_file", |path: &str| -> bool {
102                std::path::Path::new(path).is_file()
103            });
104
105        self.engine
106            .register_fn("fs_read", |path: &str| -> Dynamic {
107                match std::fs::read_to_string(path) {
108                    Ok(content) => Dynamic::from(content),
109                    Err(_) => Dynamic::UNIT,
110                }
111            });
112
113        self.engine
114            .register_fn("fs_extension", |path: &str| -> Dynamic {
115                let p = std::path::Path::new(path);
116                match p.extension().and_then(|e| e.to_str()) {
117                    Some(ext) => Dynamic::from(ext.to_string()),
118                    None => Dynamic::UNIT,
119                }
120            });
121
122        self.engine
123            .register_fn("fs_filename", |path: &str| -> Dynamic {
124                let p = std::path::Path::new(path);
125                match p.file_name().and_then(|n| n.to_str()) {
126                    Some(name) => Dynamic::from(name.to_string()),
127                    None => Dynamic::UNIT,
128                }
129            });
130
131        self.engine
132            .register_fn("fs_parent", |path: &str| -> Dynamic {
133                let p = std::path::Path::new(path);
134                match p.parent().and_then(|p| p.to_str()) {
135                    Some(parent) => Dynamic::from(parent.to_string()),
136                    None => Dynamic::UNIT,
137                }
138            });
139
140        self.engine
141            .register_fn("fs_size", |path: &str| -> Dynamic {
142                match std::fs::metadata(path) {
143                    Ok(meta) => Dynamic::from(meta.len() as i64),
144                    Err(_) => Dynamic::from(-1_i64),
145                }
146            });
147
148        // Register UI helper functions
149        self.engine.register_fn(
150            "ui_span",
151            |text: &str, fg: &str| -> rhai::Map {
152                let mut map = rhai::Map::new();
153                map.insert("type".into(), Dynamic::from("span"));
154                map.insert("text".into(), Dynamic::from(text.to_string()));
155                map.insert("fg".into(), Dynamic::from(fg.to_string()));
156                map
157            },
158        );
159
160        self.engine.register_fn("ui_line", |spans: rhai::Array| -> rhai::Map {
161            let mut map = rhai::Map::new();
162            map.insert("type".into(), Dynamic::from("line"));
163            map.insert("spans".into(), Dynamic::from(spans));
164            map
165        });
166
167        Ok(())
168    }
169
170    /// Convert a Rhai Dynamic to our Value type.
171    fn dynamic_to_value(val: &Dynamic) -> Value {
172        if val.is_unit() {
173            Value::Null
174        } else if val.is_bool() {
175            Value::Bool(val.as_bool().unwrap_or(false))
176        } else if val.is_int() {
177            Value::Integer(val.as_int().unwrap_or(0))
178        } else if val.is_float() {
179            Value::Float(val.as_float().unwrap_or(0.0))
180        } else if val.is_string() {
181            Value::String(val.clone().into_string().unwrap_or_default())
182        } else if val.is_array() {
183            let arr = val.clone().into_array().unwrap_or_default();
184            Value::Array(arr.iter().map(Self::dynamic_to_value).collect())
185        } else if val.is_map() {
186            let map = val.clone().cast::<rhai::Map>();
187            let obj: std::collections::HashMap<String, Value> = map
188                .into_iter()
189                .map(|(k, v)| (k.to_string(), Self::dynamic_to_value(&v)))
190                .collect();
191            Value::Object(obj)
192        } else {
193            Value::Null
194        }
195    }
196
197    /// Convert our Value type to a Rhai Dynamic.
198    fn value_to_dynamic(val: &Value) -> Dynamic {
199        match val {
200            Value::Null => Dynamic::UNIT,
201            Value::Bool(b) => Dynamic::from(*b),
202            Value::Integer(i) => Dynamic::from(*i),
203            Value::Float(f) => Dynamic::from(*f),
204            Value::String(s) => Dynamic::from(s.clone()),
205            Value::Array(arr) => {
206                let rhai_arr: rhai::Array = arr.iter().map(Self::value_to_dynamic).collect();
207                Dynamic::from(rhai_arr)
208            }
209            Value::Object(obj) => {
210                let mut map = rhai::Map::new();
211                for (k, v) in obj {
212                    map.insert(k.clone().into(), Self::value_to_dynamic(v));
213                }
214                Dynamic::from(map)
215            }
216            Value::Bytes(b) => Dynamic::from(b.clone()),
217        }
218    }
219
220    /// Convert a Hook to a Rhai map.
221    fn hook_to_dynamic(&self, hook: &Hook) -> Dynamic {
222        // Serialize to JSON, then to Rhai map
223        let json = serde_json::to_value(hook).unwrap_or(serde_json::Value::Null);
224
225        fn json_to_dynamic(val: &serde_json::Value) -> Dynamic {
226            match val {
227                serde_json::Value::Null => Dynamic::UNIT,
228                serde_json::Value::Bool(b) => Dynamic::from(*b),
229                serde_json::Value::Number(n) => {
230                    if let Some(i) = n.as_i64() {
231                        Dynamic::from(i)
232                    } else {
233                        Dynamic::from(n.as_f64().unwrap_or(0.0))
234                    }
235                }
236                serde_json::Value::String(s) => Dynamic::from(s.clone()),
237                serde_json::Value::Array(arr) => {
238                    let rhai_arr: rhai::Array = arr.iter().map(json_to_dynamic).collect();
239                    Dynamic::from(rhai_arr)
240                }
241                serde_json::Value::Object(obj) => {
242                    let mut map = rhai::Map::new();
243                    for (k, v) in obj {
244                        map.insert(k.clone().into(), json_to_dynamic(v));
245                    }
246                    Dynamic::from(map)
247                }
248            }
249        }
250
251        json_to_dynamic(&json)
252    }
253}
254
255impl Default for RhaiRuntime {
256    fn default() -> Self {
257        Self::new().expect("Failed to create Rhai runtime")
258    }
259}
260
261impl PluginRuntime for RhaiRuntime {
262    fn name(&self) -> &'static str {
263        "rhai"
264    }
265
266    fn file_extensions(&self) -> &'static [&'static str] {
267        &[".rhai"]
268    }
269
270    fn init(&mut self, config: &PluginConfig) -> PluginResult<()> {
271        if self.initialized {
272            return Ok(());
273        }
274
275        self.config = Some(config.clone());
276        self.init_api()?;
277        self.initialized = true;
278
279        Ok(())
280    }
281
282    fn load_plugin(&mut self, id: &str, source: &Path) -> PluginResult<PluginHandle> {
283        // Read and compile the plugin
284        let code = std::fs::read_to_string(source)?;
285
286        let ast = self.engine.compile(&code).map_err(|e| PluginError::LoadError {
287            name: id.to_string(),
288            message: e.to_string(),
289        })?;
290
291        // Detect hooks by looking for function definitions
292        let mut hooks = vec![];
293        for func in ast.iter_functions() {
294            let name = func.name.to_string();
295            if name.starts_with("on_") {
296                hooks.push(name);
297            }
298        }
299
300        let handle = PluginHandle::new(self.next_handle);
301        self.next_handle += 1;
302
303        let metadata = PluginMetadata {
304            name: id.to_string(),
305            runtime: "rhai".to_string(),
306            ..Default::default()
307        };
308
309        self.plugins.insert(
310            handle,
311            LoadedRhaiPlugin {
312                name: id.to_string(),
313                ast,
314                metadata,
315                hooks,
316            },
317        );
318
319        Ok(handle)
320    }
321
322    fn unload_plugin(&mut self, handle: PluginHandle) -> PluginResult<()> {
323        self.plugins.remove(&handle);
324        Ok(())
325    }
326
327    fn get_metadata(&self, handle: PluginHandle) -> Option<&PluginMetadata> {
328        self.plugins.get(&handle).map(|p| &p.metadata)
329    }
330
331    fn has_hook(&self, handle: PluginHandle, hook_name: &str) -> bool {
332        self.plugins
333            .get(&handle)
334            .map(|p| p.hooks.contains(&hook_name.to_string()))
335            .unwrap_or(false)
336    }
337
338    fn call_hook_sync(
339        &self,
340        handle: PluginHandle,
341        hook: &Hook,
342        _ctx: &HookContext,
343    ) -> PluginResult<HookResult> {
344        let plugin = self.plugins.get(&handle).ok_or_else(|| PluginError::NotFound {
345            path: std::path::PathBuf::new(),
346        })?;
347
348        let hook_name = hook.name();
349        if !plugin.hooks.contains(&hook_name.to_string()) {
350            return Ok(HookResult::default());
351        }
352
353        // Create a scope with the hook data
354        let mut scope = Scope::new();
355        scope.push("hook", self.hook_to_dynamic(hook));
356
357        // Call the hook function
358        let result = self
359            .engine
360            .call_fn::<Dynamic>(&mut scope, &plugin.ast, hook_name, ())
361            .map_err(|e| PluginError::ExecutionError {
362                name: plugin.name.clone(),
363                message: e.to_string(),
364            })?;
365
366        // Convert result
367        let mut hook_result = HookResult::ok();
368        if result.is_map() {
369            if let Some(map) = result.try_cast::<rhai::Map>() {
370                if let Some(prevent) = map.get("prevent_default") {
371                    if prevent.as_bool().unwrap_or(false) {
372                        hook_result = hook_result.prevent_default();
373                    }
374                }
375                if let Some(stop) = map.get("stop_propagation") {
376                    if stop.as_bool().unwrap_or(false) {
377                        hook_result = hook_result.stop_propagation();
378                    }
379                }
380                if let Some(val) = map.get("value") {
381                    hook_result.value = Some(Self::dynamic_to_value(val));
382                }
383            }
384        }
385
386        Ok(hook_result)
387    }
388
389    fn call_hook_async<'a>(
390        &'a self,
391        handle: PluginHandle,
392        hook: &'a Hook,
393        ctx: &'a HookContext,
394    ) -> BoxFuture<'a, PluginResult<HookResult>> {
395        Box::pin(async move { self.call_hook_sync(handle, hook, ctx) })
396    }
397
398    fn call_method<'a>(
399        &'a self,
400        handle: PluginHandle,
401        method: &'a str,
402        args: Vec<Value>,
403    ) -> BoxFuture<'a, PluginResult<Value>> {
404        Box::pin(async move {
405            let plugin = self.plugins.get(&handle).ok_or_else(|| PluginError::NotFound {
406                path: std::path::PathBuf::new(),
407            })?;
408
409            let mut scope = Scope::new();
410
411            // Convert args to Rhai dynamics
412            let rhai_args: Vec<Dynamic> = args.iter().map(Self::value_to_dynamic).collect();
413
414            // Rhai doesn't support variable-length args directly, so we pass as array
415            scope.push("args", rhai_args);
416
417            let result = self
418                .engine
419                .call_fn::<Dynamic>(&mut scope, &plugin.ast, method, ())
420                .map_err(|e| PluginError::ExecutionError {
421                    name: plugin.name.clone(),
422                    message: e.to_string(),
423                })?;
424
425            Ok(Self::dynamic_to_value(&result))
426        })
427    }
428
429    fn create_isolated_context(
430        &self,
431        sandbox: &SandboxConfig,
432    ) -> PluginResult<Box<dyn IsolatedContext>> {
433        Ok(Box::new(RhaiIsolatedContext::new(sandbox.clone())?))
434    }
435
436    fn loaded_plugins(&self) -> Vec<PluginHandle> {
437        self.plugins.keys().copied().collect()
438    }
439
440    fn shutdown(&mut self) -> PluginResult<()> {
441        self.plugins.clear();
442        Ok(())
443    }
444}
445
446/// An isolated Rhai context for async execution.
447struct RhaiIsolatedContext {
448    engine: Engine,
449    #[allow(dead_code)]
450    sandbox: SandboxConfig,
451}
452
453impl RhaiIsolatedContext {
454    fn new(sandbox: SandboxConfig) -> PluginResult<Self> {
455        let mut engine = Engine::new();
456
457        // Strict limits for isolated contexts
458        engine.set_max_expr_depths(32, 32);
459        engine.set_max_call_levels(32);
460        engine.set_max_operations(100_000);
461        engine.set_max_modules(10);
462        engine.set_max_string_size(100 * 1024); // 100KB
463        engine.set_max_array_size(1000);
464        engine.set_max_map_size(1000);
465
466        // Disable potentially dangerous operations
467        engine.disable_symbol("eval");
468
469        Ok(Self { engine, sandbox })
470    }
471}
472
473impl IsolatedContext for RhaiIsolatedContext {
474    fn execute<'a>(
475        &'a self,
476        code: &'a [u8],
477        cancel: tokio_util::sync::CancellationToken,
478    ) -> BoxFuture<'a, PluginResult<Value>> {
479        Box::pin(async move {
480            if cancel.is_cancelled() {
481                return Err(PluginError::Cancelled {
482                    name: "isolate".into(),
483                });
484            }
485
486            let code_str = std::str::from_utf8(code).map_err(|e| PluginError::ExecutionError {
487                name: "isolate".into(),
488                message: format!("Invalid UTF-8: {}", e),
489            })?;
490
491            let result = self.engine.eval::<Dynamic>(code_str).map_err(|e| {
492                PluginError::ExecutionError {
493                    name: "isolate".into(),
494                    message: e.to_string(),
495                }
496            })?;
497
498            Ok(RhaiRuntime::dynamic_to_value(&result))
499        })
500    }
501
502    fn call_function<'a>(
503        &'a self,
504        name: &'a str,
505        args: Vec<Value>,
506        cancel: tokio_util::sync::CancellationToken,
507    ) -> BoxFuture<'a, PluginResult<Value>> {
508        Box::pin(async move {
509            if cancel.is_cancelled() {
510                return Err(PluginError::Cancelled {
511                    name: "isolate".into(),
512                });
513            }
514
515            // Rhai requires AST to call functions, so we construct a call expression
516            let args_str: Vec<String> = args
517                .iter()
518                .map(|v| match v {
519                    Value::String(s) => format!("\"{}\"", s.replace("\"", "\\\"")),
520                    Value::Integer(i) => i.to_string(),
521                    Value::Float(f) => f.to_string(),
522                    Value::Bool(b) => b.to_string(),
523                    _ => "()".to_string(),
524                })
525                .collect();
526
527            let code = format!("{}({})", name, args_str.join(", "));
528
529            let result = self.engine.eval::<Dynamic>(&code).map_err(|e| {
530                PluginError::ExecutionError {
531                    name: "isolate".into(),
532                    message: e.to_string(),
533                }
534            })?;
535
536            Ok(RhaiRuntime::dynamic_to_value(&result))
537        })
538    }
539
540    fn set_global(&mut self, _name: &str, _value: Value) -> PluginResult<()> {
541        // Rhai doesn't support persistent globals without scope
542        // This would need to be handled differently
543        Ok(())
544    }
545
546    fn get_global(&self, _name: &str) -> PluginResult<Value> {
547        Ok(Value::Null)
548    }
549}