use std::sync::Arc;
use std::time::{Duration, Instant};
use mlua::{Lua, LuaOptions, StdLib, Value as LuaValue};
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken;
use crate::runtime::{BoxFuture, IsolatedContext};
use crate::sandbox::SandboxConfig;
use crate::types::{PluginError, PluginResult, Value};
use super::bindings;
use super::convert::{lua_to_value, value_to_lua};
fn execution_error(error: mlua::Error, timeout_ms: u64) -> PluginError {
let message = error.to_string();
if message.contains("plugin execution cancelled") {
PluginError::Cancelled {
name: "isolate".into(),
}
} else if message.contains("plugin execution timed out") {
PluginError::Timeout {
name: "isolate".into(),
timeout_ms,
}
} else {
PluginError::ExecutionError {
name: "isolate".into(),
message,
}
}
}
fn sandboxed_stdlib() -> StdLib {
StdLib::STRING | StdLib::TABLE | StdLib::MATH | StdLib::UTF8 | StdLib::COROUTINE
}
const DENIED_BASE_GLOBALS: &[&str] = &[
"load",
"loadstring",
"loadfile",
"dofile",
"require",
"collectgarbage",
];
pub struct LuaIsolatedContext {
lua: Arc<Lua>,
sandbox: SandboxConfig,
vm_lock: Arc<Mutex<()>>,
}
impl LuaIsolatedContext {
pub fn new(sandbox: SandboxConfig) -> PluginResult<Self> {
let lua = Lua::new_with(sandboxed_stdlib(), LuaOptions::default()).map_err(|e| {
PluginError::LoadError {
name: "isolate".into(),
message: format!("Failed to create sandboxed Lua state: {e}"),
}
})?;
if sandbox.max_memory > 0 {
let _ = lua.set_memory_limit(sandbox.max_memory);
}
let globals = lua.globals();
for name in DENIED_BASE_GLOBALS {
globals.set(*name, LuaValue::Nil).ok();
}
let print = lua
.create_function(|_, args: mlua::Variadic<LuaValue>| {
let line = args
.iter()
.map(|value| match value {
LuaValue::String(text) => text.to_string_lossy().to_string(),
other => format!("{other:?}"),
})
.collect::<Vec<_>>()
.join("\t");
tracing::info!(target: "plugin_isolate", "{}", line);
Ok(())
})
.map_err(|e| PluginError::LoadError {
name: "isolate".into(),
message: e.to_string(),
})?;
globals.set("print", print).ok();
if let Ok(string_table) = globals.get::<mlua::Table>("string") {
string_table.set("dump", LuaValue::Nil).ok();
}
let gf = lua.create_table().map_err(|e| PluginError::LoadError {
name: "isolate".into(),
message: e.to_string(),
})?;
gf.set("version", env!("CARGO_PKG_VERSION")).ok();
let log_info = lua
.create_function(|_, msg: String| {
tracing::info!(target: "plugin_isolate", "{}", msg);
Ok(())
})
.map_err(|e| PluginError::LoadError {
name: "isolate".into(),
message: e.to_string(),
})?;
gf.set("log_info", log_info).ok();
globals.set("gf", gf).ok();
if sandbox.has_permission(crate::sandbox::Permission::Read) {
let fs = bindings::create_fs_api(&lua, Some(sandbox.clone()))?;
globals.set("fs", fs).ok();
}
drop(globals);
Ok(Self {
lua: Arc::new(lua),
sandbox,
vm_lock: Arc::new(Mutex::new(())),
})
}
fn install_guard_hook(lua: &Lua, cancel: &CancellationToken, timeout_ms: u64) {
let cancel = cancel.clone();
let deadline = (timeout_ms > 0)
.then(|| Instant::now().checked_add(Duration::from_millis(timeout_ms)))
.flatten();
let _ = lua.set_hook(
mlua::HookTriggers::new().every_nth_instruction(1000),
move |_lua, _debug| {
if cancel.is_cancelled() {
return Err(mlua::Error::external("plugin execution cancelled"));
}
if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
return Err(mlua::Error::external("plugin execution timed out"));
}
Ok(mlua::VmState::Continue)
},
);
}
async fn run_guarded<T, F>(&self, cancel: CancellationToken, job: F) -> PluginResult<T>
where
T: Send + 'static,
F: FnOnce(&Lua) -> PluginResult<T> + Send + 'static,
{
if cancel.is_cancelled() {
return Err(PluginError::Cancelled {
name: "isolate".into(),
});
}
let timeout_ms = self.sandbox.timeout_ms;
let lua = Arc::clone(&self.lua);
let guard = Arc::clone(&self.vm_lock).lock_owned().await;
let handle = tokio::task::spawn_blocking(move || {
let _guard = guard;
Self::install_guard_hook(&lua, &cancel, timeout_ms);
let result = job(&lua);
lua.remove_hook();
result
});
let joined = if timeout_ms == 0 {
handle.await
} else {
match tokio::time::timeout(Duration::from_millis(timeout_ms), handle).await {
Ok(joined) => joined,
Err(_elapsed) => {
return Err(PluginError::Timeout {
name: "isolate".into(),
timeout_ms,
});
}
}
};
joined.map_err(|error| PluginError::ExecutionError {
name: "isolate".into(),
message: format!("plugin task failed: {error}"),
})?
}
}
impl IsolatedContext for LuaIsolatedContext {
fn execute<'a>(
&'a self,
code: &'a [u8],
cancel: CancellationToken,
) -> BoxFuture<'a, PluginResult<Value>> {
Box::pin(async move {
let timeout_ms = self.sandbox.timeout_ms;
let code = std::str::from_utf8(code)
.map_err(|e| PluginError::ExecutionError {
name: "isolate".into(),
message: format!("Invalid UTF-8 in code: {e}"),
})?
.to_owned();
self.run_guarded(cancel, move |lua| {
lua.load(&code)
.eval::<LuaValue>()
.map(lua_to_value)
.map_err(|error| execution_error(error, timeout_ms))
})
.await
})
}
fn call_function<'a>(
&'a self,
name: &'a str,
args: Vec<Value>,
cancel: CancellationToken,
) -> BoxFuture<'a, PluginResult<Value>> {
Box::pin(async move {
let timeout_ms = self.sandbox.timeout_ms;
let name = name.to_owned();
self.run_guarded(cancel, move |lua| {
let func: mlua::Function =
lua.globals()
.get(&*name)
.map_err(|e| PluginError::ExecutionError {
name: "isolate".into(),
message: format!("Function '{name}' not found: {e}"),
})?;
let lua_args: Vec<LuaValue> = args
.iter()
.map(|value| value_to_lua(lua, value))
.collect::<Result<_, _>>()
.map_err(|e| PluginError::ExecutionError {
name: "isolate".into(),
message: e.to_string(),
})?;
func.call(mlua::MultiValue::from_vec(lua_args))
.map(lua_to_value)
.map_err(|error| execution_error(error, timeout_ms))
})
.await
})
}
fn set_global(&mut self, name: &str, value: Value) -> PluginResult<()> {
let lua_val = value_to_lua(&self.lua, &value).map_err(|e| PluginError::ExecutionError {
name: "isolate".into(),
message: e.to_string(),
})?;
self.lua
.globals()
.set(name, lua_val)
.map_err(|e| PluginError::ExecutionError {
name: "isolate".into(),
message: e.to_string(),
})?;
Ok(())
}
fn get_global(&self, name: &str) -> PluginResult<Value> {
let val: LuaValue =
self.lua
.globals()
.get(name)
.map_err(|e| PluginError::ExecutionError {
name: "isolate".into(),
message: e.to_string(),
})?;
Ok(lua_to_value(val))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn context(timeout_ms: u64) -> LuaIsolatedContext {
let mut sandbox = SandboxConfig::minimal();
sandbox.timeout_ms = timeout_ms;
LuaIsolatedContext::new(sandbox).unwrap()
}
#[tokio::test]
async fn execute_returns_converted_values() {
let context = context(1_000);
let value = context
.execute(b"return {1, 2, 3}", CancellationToken::new())
.await
.unwrap();
assert!(matches!(value, Value::Array(values) if values.len() == 3));
}
#[tokio::test]
async fn execute_honors_precancellation() {
let context = context(1_000);
let token = CancellationToken::new();
token.cancel();
let error = context.execute(b"return 1", token).await.unwrap_err();
assert!(matches!(error, PluginError::Cancelled { .. }));
}
#[tokio::test]
async fn call_function_aborts_runaway_code() {
let context = context(50);
context
.execute(
b"function runaway() while true do end end",
CancellationToken::new(),
)
.await
.unwrap();
let error = context
.call_function("runaway", vec![], CancellationToken::new())
.await
.unwrap_err();
assert!(matches!(error, PluginError::Timeout { .. }));
}
#[tokio::test]
async fn zero_timeout_allows_finite_execution() {
let context = context(0);
let value = context
.execute(b"return 40 + 2", CancellationToken::new())
.await
.unwrap();
assert!(matches!(value, Value::Integer(42)));
}
}