Skip to main content

gravityfile_plugin/rhai/
runtime.rs

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