1use 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
18struct LoadedLuaPlugin {
20 name: String,
22
23 module: mlua::RegistryKey,
25
26 metadata: PluginMetadata,
28
29 hooks: Vec<String>,
31}
32
33pub struct LuaRuntime {
35 lua: Lua,
37
38 plugins: HashMap<PluginHandle, LoadedLuaPlugin>,
40
41 next_handle: usize,
43
44 config: Option<PluginConfig>,
46
47 sandbox: SandboxConfig,
49
50 notices: std::sync::Arc<std::sync::Mutex<Vec<Notice>>>,
52
53 initialized: bool,
55}
56
57impl LuaRuntime {
58 pub fn new() -> PluginResult<Self> {
60 let lua = Lua::new();
61
62 {
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 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 fn init_api(&self) -> PluginResult<()> {
131 let globals = self.lua.globals();
132
133 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 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 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 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 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 fn hook_to_lua(&self, lua: &Lua, hook: &Hook) -> mlua::Result<Table> {
222 let table = lua.create_table()?;
223
224 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 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 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 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 let code = crate::runtime::read_plugin_source(source)?;
340
341 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 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 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 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 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 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 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 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 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 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}