Skip to main content

gravityfile_plugin/lua/
runtime.rs

1//! Lua runtime implementation.
2
3use std::collections::HashMap;
4use std::path::Path;
5
6use mlua::{Function, Lua, MultiValue, Table, Value as LuaValue};
7
8use crate::config::{PluginConfig, PluginMetadata};
9use crate::hooks::{Hook, HookContext, HookResult, Notice, NoticeLevel};
10use crate::runtime::{BoxFuture, HOOK_NAMES, IsolatedContext, PluginHandle, PluginRuntime};
11use crate::sandbox::SandboxConfig;
12use crate::types::{PluginError, PluginResult, Value};
13
14use super::bindings;
15use super::convert::{context_to_lua, lua_to_value, value_to_lua};
16use super::isolate::LuaIsolatedContext;
17
18/// A loaded Lua plugin.
19struct LoadedLuaPlugin {
20    /// Plugin name/id.
21    name: String,
22
23    /// The plugin's module table.
24    module: mlua::RegistryKey,
25
26    /// Plugin metadata.
27    metadata: PluginMetadata,
28
29    /// Hooks implemented by this plugin.
30    hooks: Vec<String>,
31}
32
33/// Lua plugin runtime.
34pub struct LuaRuntime {
35    /// The main Lua state.
36    lua: Lua,
37
38    /// Loaded plugins by handle.
39    plugins: HashMap<PluginHandle, LoadedLuaPlugin>,
40
41    /// Next plugin handle ID.
42    next_handle: usize,
43
44    /// Runtime configuration.
45    config: Option<PluginConfig>,
46
47    /// Sandbox configuration used to gate filesystem API access.
48    sandbox: SandboxConfig,
49
50    /// Notices raised with `gf.notify` during the current hook call.
51    notices: std::sync::Arc<std::sync::Mutex<Vec<Notice>>>,
52
53    /// Whether the runtime has been initialized.
54    initialized: bool,
55}
56
57impl LuaRuntime {
58    /// Create a new Lua runtime.
59    pub fn new() -> PluginResult<Self> {
60        let lua = Lua::new();
61
62        // Disable potentially dangerous standard library functions
63        {
64            let globals = lua.globals();
65            let map_err = |e: mlua::Error| PluginError::LoadError {
66                name: "lua".into(),
67                message: e.to_string(),
68            };
69            globals.set("loadfile", LuaValue::Nil).map_err(map_err)?;
70            globals
71                .set("dofile", LuaValue::Nil)
72                .map_err(|e| PluginError::LoadError {
73                    name: "lua".into(),
74                    message: e.to_string(),
75                })?;
76            globals
77                .set("load", LuaValue::Nil)
78                .map_err(|e| PluginError::LoadError {
79                    name: "lua".into(),
80                    message: e.to_string(),
81                })?;
82            globals
83                .set("os", LuaValue::Nil)
84                .map_err(|e| PluginError::LoadError {
85                    name: "lua".into(),
86                    message: e.to_string(),
87                })?;
88            globals
89                .set("io", LuaValue::Nil)
90                .map_err(|e| PluginError::LoadError {
91                    name: "lua".into(),
92                    message: e.to_string(),
93                })?;
94            globals
95                .set("debug", LuaValue::Nil)
96                .map_err(|e| PluginError::LoadError {
97                    name: "lua".into(),
98                    message: e.to_string(),
99                })?;
100            globals
101                .set("require", LuaValue::Nil)
102                .map_err(|e| PluginError::LoadError {
103                    name: "lua".into(),
104                    message: e.to_string(),
105                })?;
106            globals
107                .set("package", LuaValue::Nil)
108                .map_err(|e| PluginError::LoadError {
109                    name: "lua".into(),
110                    message: e.to_string(),
111                })?;
112            // Disable string.dump to prevent bytecode extraction
113            if let Ok(string_table) = globals.get::<mlua::Table>("string") {
114                string_table.set("dump", LuaValue::Nil).ok();
115            }
116        }
117
118        Ok(Self {
119            lua,
120            plugins: HashMap::new(),
121            next_handle: 0,
122            config: None,
123            sandbox: SandboxConfig::default(),
124            notices: Default::default(),
125            initialized: false,
126        })
127    }
128
129    /// Initialize the Lua runtime with the gravityfile API.
130    fn init_api(&self) -> PluginResult<()> {
131        let globals = self.lua.globals();
132
133        // Create the 'gf' namespace (gravityfile API)
134        let gf = self
135            .lua
136            .create_table()
137            .map_err(|e| PluginError::LoadError {
138                name: "lua".into(),
139                message: format!("Failed to create gf table: {}", e),
140            })?;
141
142        // Add version info
143        gf.set("version", env!("CARGO_PKG_VERSION"))
144            .map_err(|e| PluginError::LoadError {
145                name: "lua".into(),
146                message: e.to_string(),
147            })?;
148
149        // Add logging functions
150        let log_info = self
151            .lua
152            .create_function(|_, msg: String| {
153                tracing::info!(target: "plugin", "{}", msg);
154                Ok(())
155            })
156            .map_err(|e| PluginError::LoadError {
157                name: "lua".into(),
158                message: e.to_string(),
159            })?;
160        gf.set("log_info", log_info).ok();
161
162        let log_warn = self
163            .lua
164            .create_function(|_, msg: String| {
165                tracing::warn!(target: "plugin", "{}", msg);
166                Ok(())
167            })
168            .map_err(|e| PluginError::LoadError {
169                name: "lua".into(),
170                message: e.to_string(),
171            })?;
172        gf.set("log_warn", log_warn).ok();
173
174        let log_error = self
175            .lua
176            .create_function(|_, msg: String| {
177                tracing::error!(target: "plugin", "{}", msg);
178                Ok(())
179            })
180            .map_err(|e| PluginError::LoadError {
181                name: "lua".into(),
182                message: e.to_string(),
183            })?;
184        gf.set("log_error", log_error).ok();
185
186        // gf.notify(message, level): shown by the explorer once the hook
187        // returns. Bounded per call so a plugin cannot flood the screen.
188        let notices = std::sync::Arc::clone(&self.notices);
189        let notify = self
190            .lua
191            .create_function(move |_, (message, level): (String, Option<String>)| {
192                let level = NoticeLevel::parse(level.as_deref().unwrap_or("info"));
193                let mut notices = notices.lock().unwrap_or_else(|poison| poison.into_inner());
194                if notices.len() < Notice::MAX_PER_CALL {
195                    notices.push(Notice::new(level, &message));
196                }
197                Ok(())
198            })
199            .map_err(|e| PluginError::LoadError {
200                name: "lua".into(),
201                message: e.to_string(),
202            })?;
203        gf.set("notify", notify).ok();
204
205        globals.set("gf", gf).map_err(|e| PluginError::LoadError {
206            name: "lua".into(),
207            message: e.to_string(),
208        })?;
209
210        // Create the 'fs' namespace (filesystem API), gated by the runtime sandbox.
211        let fs = bindings::create_fs_api(&self.lua, Some(self.sandbox.clone()))?;
212        globals.set("fs", fs).map_err(|e| PluginError::LoadError {
213            name: "lua".into(),
214            message: e.to_string(),
215        })?;
216
217        Ok(())
218    }
219
220    /// Convert a Hook to a Lua table.
221    fn hook_to_lua(&self, lua: &Lua, hook: &Hook) -> mlua::Result<Table> {
222        let table = lua.create_table()?;
223
224        // Serialize hook to JSON then to Lua table
225        let json = serde_json::to_string(hook).map_err(mlua::Error::external)?;
226        let json_val: serde_json::Value =
227            serde_json::from_str(&json).map_err(mlua::Error::external)?;
228
229        fn json_to_lua(lua: &Lua, val: &serde_json::Value) -> mlua::Result<LuaValue> {
230            match val {
231                serde_json::Value::Null => Ok(LuaValue::Nil),
232                serde_json::Value::Bool(b) => Ok(LuaValue::Boolean(*b)),
233                serde_json::Value::Number(n) => {
234                    if let Some(i) = n.as_i64() {
235                        Ok(LuaValue::Integer(i))
236                    } else {
237                        Ok(LuaValue::Number(n.as_f64().unwrap_or(0.0)))
238                    }
239                }
240                serde_json::Value::String(s) => Ok(LuaValue::String(lua.create_string(s)?)),
241                serde_json::Value::Array(arr) => {
242                    let t = lua.create_table()?;
243                    for (i, v) in arr.iter().enumerate() {
244                        t.set(i + 1, json_to_lua(lua, v)?)?;
245                    }
246                    Ok(LuaValue::Table(t))
247                }
248                serde_json::Value::Object(obj) => {
249                    let t = lua.create_table()?;
250                    for (k, v) in obj {
251                        t.set(k.as_str(), json_to_lua(lua, v)?)?;
252                    }
253                    Ok(LuaValue::Table(t))
254                }
255            }
256        }
257
258        if let serde_json::Value::Object(obj) = json_val {
259            for (k, v) in obj {
260                table.set(k.as_str(), json_to_lua(lua, &v)?)?;
261            }
262        }
263
264        Ok(table)
265    }
266
267    /// Run `f` under a CPU watchdog: an instruction-count hook aborts execution
268    /// once the wall-clock deadline (from the sandbox timeout) passes. This
269    /// bounds runaway plugins on the shared Lua state without requiring the state
270    /// to be `Send` (so we avoid `spawn_blocking`). A timeout surfaces as an
271    /// execution error from the wrapped Lua call.
272    fn with_deadline<T>(&self, f: impl FnOnce() -> PluginResult<T>) -> PluginResult<T> {
273        use std::time::{Duration, Instant};
274
275        let timeout_ms = self.sandbox.timeout_ms;
276        if timeout_ms == 0 {
277            return f();
278        }
279
280        let Some(deadline) = Instant::now().checked_add(Duration::from_millis(timeout_ms)) else {
281            return f();
282        };
283        let _ = self.lua.set_hook(
284            mlua::HookTriggers::new().every_nth_instruction(100_000),
285            move |_lua, _debug| {
286                if Instant::now() >= deadline {
287                    Err(mlua::Error::external("plugin execution timed out"))
288                } else {
289                    Ok(mlua::VmState::Continue)
290                }
291            },
292        );
293        let result = f();
294        self.lua.remove_hook();
295        result
296    }
297}
298
299impl PluginRuntime for LuaRuntime {
300    fn name(&self) -> &'static str {
301        "lua"
302    }
303
304    fn file_extensions(&self) -> &'static [&'static str] {
305        &[".lua"]
306    }
307
308    fn init(&mut self, config: &PluginConfig) -> PluginResult<()> {
309        if self.initialized {
310            return Ok(());
311        }
312
313        // Build a sandbox from plugin config settings. The default grants the
314        // Read permission but no paths; wiring the configured roots is what makes
315        // the filesystem API actually usable (and scoped).
316        self.sandbox = SandboxConfig {
317            timeout_ms: config.default_timeout_ms,
318            max_memory: config.max_memory_mb.saturating_mul(1024 * 1024),
319            allow_network: config.allow_network,
320            allowed_read_paths: config.fs_read_roots.clone(),
321            ..SandboxConfig::default()
322        };
323
324        // Bound memory on the shared Lua state so a runaway plugin can't OOM the
325        // whole TUI. 0 means "unlimited" in our config; only set a real cap.
326        if self.sandbox.max_memory > 0 {
327            let _ = self.lua.set_memory_limit(self.sandbox.max_memory);
328        }
329
330        self.config = Some(config.clone());
331        self.init_api()?;
332        self.initialized = true;
333
334        Ok(())
335    }
336
337    fn load_plugin(&mut self, id: &str, source: &Path) -> PluginResult<PluginHandle> {
338        // Read the plugin source
339        let code = crate::runtime::read_plugin_source(source)?;
340
341        // Load and execute the plugin
342        let chunk = self.lua.load(&code).set_name(id);
343
344        let module: Table = chunk.eval().map_err(|e| PluginError::LoadError {
345            name: id.to_string(),
346            message: e.to_string(),
347        })?;
348
349        // Detect which hooks are implemented, using the shared canonical list
350        // so Lua plugins can register every hook the host dispatches.
351        let mut hooks = vec![];
352        for hook_name in HOOK_NAMES {
353            if module.contains_key(*hook_name).unwrap_or(false) {
354                hooks.push(hook_name.to_string());
355            }
356        }
357
358        // Store in registry
359        let key = self
360            .lua
361            .create_registry_value(module)
362            .map_err(|e| PluginError::LoadError {
363                name: id.to_string(),
364                message: e.to_string(),
365            })?;
366
367        let handle = PluginHandle::new(self.next_handle);
368        self.next_handle += 1;
369
370        // Create default metadata (would normally come from plugin.toml)
371        let metadata = PluginMetadata {
372            name: id.to_string(),
373            runtime: "lua".to_string(),
374            ..Default::default()
375        };
376
377        self.plugins.insert(
378            handle,
379            LoadedLuaPlugin {
380                name: id.to_string(),
381                module: key,
382                metadata,
383                hooks,
384            },
385        );
386
387        Ok(handle)
388    }
389
390    fn unload_plugin(&mut self, handle: PluginHandle) -> PluginResult<()> {
391        if let Some(plugin) = self.plugins.remove(&handle) {
392            self.lua.remove_registry_value(plugin.module).ok();
393        }
394        Ok(())
395    }
396
397    fn get_metadata(&self, handle: PluginHandle) -> Option<&PluginMetadata> {
398        self.plugins.get(&handle).map(|p| &p.metadata)
399    }
400
401    fn has_hook(&self, handle: PluginHandle, hook_name: &str) -> bool {
402        self.plugins
403            .get(&handle)
404            .map(|p| p.hooks.contains(&hook_name.to_string()))
405            .unwrap_or(false)
406    }
407
408    fn call_hook_sync(
409        &self,
410        handle: PluginHandle,
411        hook: &Hook,
412        ctx: &HookContext,
413    ) -> PluginResult<HookResult> {
414        let take_notices = || {
415            std::mem::take(
416                &mut *self
417                    .notices
418                    .lock()
419                    .unwrap_or_else(|poison| poison.into_inner()),
420            )
421        };
422        take_notices();
423        let plugin = self
424            .plugins
425            .get(&handle)
426            .ok_or_else(|| PluginError::NotFound {
427                path: std::path::PathBuf::new(),
428            })?;
429
430        let module: Table =
431            self.lua
432                .registry_value(&plugin.module)
433                .map_err(|e| PluginError::ExecutionError {
434                    name: plugin.name.clone(),
435                    message: e.to_string(),
436                })?;
437
438        let hook_name = hook.name();
439        let func: Function = match module.get(hook_name) {
440            Ok(f) => f,
441            Err(_) => return Ok(HookResult::default()),
442        };
443
444        // Convert hook and context to Lua
445        let hook_table =
446            self.hook_to_lua(&self.lua, hook)
447                .map_err(|e| PluginError::ExecutionError {
448                    name: plugin.name.clone(),
449                    message: e.to_string(),
450                })?;
451
452        let context_table =
453            context_to_lua(&self.lua, ctx).map_err(|e| PluginError::ExecutionError {
454                name: plugin.name.clone(),
455                message: e.to_string(),
456            })?;
457
458        // Call the function under the CPU/time watchdog.
459        let result: LuaValue = self.with_deadline(|| {
460            func.call((module.clone(), hook_table, context_table))
461                .map_err(|e| PluginError::ExecutionError {
462                    name: plugin.name.clone(),
463                    message: e.to_string(),
464                })
465        })?;
466
467        // Convert result
468        let mut hook_result = HookResult::ok();
469        hook_result.notices = take_notices();
470        if let LuaValue::Table(t) = result {
471            if let Ok(prevent) = t.get::<bool>("prevent_default")
472                && prevent
473            {
474                hook_result = hook_result.prevent_default();
475            }
476            if let Ok(stop) = t.get::<bool>("stop_propagation")
477                && stop
478            {
479                hook_result = hook_result.stop_propagation();
480            }
481            if let Ok(val) = t.get::<LuaValue>("value") {
482                hook_result.value = Some(lua_to_value(val));
483            }
484        }
485
486        Ok(hook_result)
487    }
488
489    fn call_hook_async<'a>(
490        &'a self,
491        handle: PluginHandle,
492        hook: &'a Hook,
493        ctx: &'a HookContext,
494    ) -> BoxFuture<'a, PluginResult<HookResult>> {
495        // The main Lua state is shared with synchronous render hooks, so calls
496        // remain serialized. `with_deadline` enforces the VM instruction/time
497        // budget even though this future resolves synchronously.
498        Box::pin(async move { self.call_hook_sync(handle, hook, ctx) })
499    }
500
501    fn call_method<'a>(
502        &'a self,
503        handle: PluginHandle,
504        method: &'a str,
505        args: Vec<Value>,
506    ) -> BoxFuture<'a, PluginResult<Value>> {
507        Box::pin(async move {
508            let plugin = self
509                .plugins
510                .get(&handle)
511                .ok_or_else(|| PluginError::NotFound {
512                    path: std::path::PathBuf::new(),
513                })?;
514
515            let module: Table = self.lua.registry_value(&plugin.module).map_err(|e| {
516                PluginError::ExecutionError {
517                    name: plugin.name.clone(),
518                    message: e.to_string(),
519                }
520            })?;
521
522            let func: Function = module
523                .get(method)
524                .map_err(|e| PluginError::ExecutionError {
525                    name: plugin.name.clone(),
526                    message: format!("Method '{}' not found: {}", method, e),
527                })?;
528
529            // Convert args to Lua
530            let lua_args: Vec<LuaValue> = args
531                .iter()
532                .map(|v| value_to_lua(&self.lua, v))
533                .collect::<Result<_, _>>()
534                .map_err(|e| PluginError::ExecutionError {
535                    name: plugin.name.clone(),
536                    message: e.to_string(),
537                })?;
538
539            let result: LuaValue = self.with_deadline(|| {
540                func.call(MultiValue::from_vec(
541                    std::iter::once(LuaValue::Table(module))
542                        .chain(lua_args)
543                        .collect(),
544                ))
545                .map_err(|e| PluginError::ExecutionError {
546                    name: plugin.name.clone(),
547                    message: e.to_string(),
548                })
549            })?;
550
551            Ok(lua_to_value(result))
552        })
553    }
554
555    fn create_isolated_context(
556        &self,
557        sandbox: &SandboxConfig,
558    ) -> PluginResult<Box<dyn IsolatedContext>> {
559        Ok(Box::new(LuaIsolatedContext::new(sandbox.clone())?))
560    }
561
562    fn loaded_plugins(&self) -> Vec<PluginHandle> {
563        self.plugins.keys().copied().collect()
564    }
565
566    fn shutdown(&mut self) -> PluginResult<()> {
567        self.plugins.clear();
568        Ok(())
569    }
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575    use crate::hooks::{Hook, HookContext};
576    use std::io::Write as _;
577
578    fn load(rt: &mut LuaRuntime, source: &str) -> PluginHandle {
579        let mut file = tempfile::NamedTempFile::new().unwrap();
580        file.write_all(source.as_bytes()).unwrap();
581        rt.load_plugin("test", file.path()).unwrap()
582    }
583
584    #[test]
585    fn notices_return_with_the_hook_result_bounded_and_per_call() {
586        let mut rt = lua_runtime(1000);
587        let handle = load(
588            &mut rt,
589            r#"
590            local M = {}
591            function M.on_startup(self, hook)
592                gf.notify("started", "warn")
593                gf.notify(string.rep("x", 2000))
594                for i = 1, 20 do gf.notify("more " .. i, "error") end
595            end
596            function M.on_shutdown(self, hook) end
597            return M
598            "#,
599        );
600        let started = rt
601            .call_hook_sync(handle, &Hook::OnStartup, &HookContext::new())
602            .unwrap();
603        assert_eq!(started.notices.len(), Notice::MAX_PER_CALL);
604        assert_eq!(
605            started.notices[0],
606            Notice::new(NoticeLevel::Warn, "started")
607        );
608        assert_eq!(started.notices[1].level, NoticeLevel::Info);
609        assert_eq!(started.notices[1].message.len(), Notice::MAX_BYTES);
610        let quiet = rt
611            .call_hook_sync(handle, &Hook::OnShutdown, &HookContext::new())
612            .unwrap();
613        assert!(quiet.notices.is_empty(), "notices do not carry over");
614    }
615
616    #[test]
617    fn hooks_receive_the_context() {
618        let mut rt = lua_runtime(1000);
619        let handle = load(
620            &mut rt,
621            r#"
622            local M = {}
623            function M.on_navigate(self, hook, ctx)
624                return { value = ctx.view_root .. "|" .. ctx.theme .. "|" .. ctx.selected .. "|" .. hook.to }
625            end
626            return M
627            "#,
628        );
629        let mut context = HookContext::new()
630            .with_view_root("/home/me".into())
631            .with_theme("nord");
632        context.set("selected", "notes.txt");
633        let hook = Hook::OnNavigate {
634            from: "/home".into(),
635            to: "/home/me".into(),
636        };
637        let result = rt.call_hook_sync(handle, &hook, &context).unwrap();
638        assert_eq!(
639            result.value,
640            Some(Value::String("/home/me|nord|notes.txt|/home/me".into()))
641        );
642    }
643
644    fn lua_runtime(timeout_ms: u64) -> LuaRuntime {
645        let mut rt = LuaRuntime::new().unwrap();
646        let cfg = PluginConfig {
647            default_timeout_ms: timeout_ms,
648            ..PluginConfig::default()
649        };
650        rt.init(&cfg).unwrap();
651        rt
652    }
653
654    #[test]
655    fn deadline_aborts_infinite_loop_hook() {
656        let mut rt = lua_runtime(50);
657
658        let mut file = tempfile::NamedTempFile::new().unwrap();
659        // on_startup is a sync hook; the body loops forever and must be aborted
660        // by the instruction-count deadline watchdog rather than hanging.
661        write!(
662            file,
663            "local M = {{}}\nfunction M.on_startup(self, hook) while true do end end\nreturn M\n"
664        )
665        .unwrap();
666
667        let handle = rt.load_plugin("loopy", file.path()).unwrap();
668        assert!(rt.has_hook(handle, "on_startup"));
669
670        let start = std::time::Instant::now();
671        let res = rt.call_hook_sync(handle, &Hook::OnStartup, &HookContext::new());
672
673        assert!(res.is_err(), "runaway hook should abort, got {res:?}");
674        assert!(
675            start.elapsed().as_secs() < 5,
676            "watchdog should abort promptly, took {:?}",
677            start.elapsed()
678        );
679    }
680
681    #[test]
682    fn normal_hook_runs_under_deadline() {
683        let mut rt = lua_runtime(1000);
684
685        let mut file = tempfile::NamedTempFile::new().unwrap();
686        write!(
687            file,
688            "local M = {{}}\nfunction M.on_startup(self, hook) return {{ handled = true }} end\nreturn M\n"
689        )
690        .unwrap();
691
692        let handle = rt.load_plugin("calm", file.path()).unwrap();
693        let res = rt
694            .call_hook_sync(handle, &Hook::OnStartup, &HookContext::new())
695            .unwrap();
696        assert!(res.is_ok());
697    }
698}