use std::collections::HashMap;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use mlua::{Lua, Value as LuaValue};
use serde_json::Value as JsonValue;
use tokio::sync::broadcast;
use crate::error::DbError;
use crate::scripting::channel_manager::ChannelManager;
use crate::storage::StorageEngine;
use crate::stream::StreamManager;
use super::conversion::lua_to_json_value;
use super::types::{Script, ScriptContext, ScriptResult, ScriptStats};
pub mod cache;
pub mod globals;
pub mod pool;
pub mod repl;
pub mod script_index;
pub mod websocket;
pub use cache::ScriptCache;
pub use pool::LuaPool;
pub use script_index::ScriptIndex;
pub fn lua_runtime_enabled() -> bool {
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ENABLED.get_or_init(|| !env_flag_is_set(std::env::var("SOLIDB_NO_LUA").ok().as_deref()))
}
pub(crate) fn env_flag_is_set(value: Option<&str>) -> bool {
matches!(
value,
Some(v) if v == "1" || v.eq_ignore_ascii_case("true") || v.eq_ignore_ascii_case("yes")
)
}
pub fn lua_disabled_error() -> DbError {
DbError::OperationNotSupported(
"Lua is disabled (--no-lua / SOLIDB_NO_LUA). Custom scripts, services, and the Lua REPL are not available.".to_string(),
)
}
fn install_busy_loop_hook(lua: &Lua) {
let timeout = script_timeout_secs();
if timeout == 0 {
return;
}
let limit = std::time::Duration::from_secs(timeout);
let state = std::sync::Mutex::new((std::time::Instant::now(), std::time::Duration::ZERO));
let _ = lua.set_global_hook(
mlua::HookTriggers::new().every_nth_instruction(50_000),
move |_lua, _debug| {
let now = std::time::Instant::now();
let mut st = state.lock().unwrap_or_else(|e| e.into_inner());
let gap = now.duration_since(st.0);
st.0 = now;
if gap > std::time::Duration::from_millis(20) {
st.1 = std::time::Duration::ZERO;
} else {
st.1 += gap;
}
if st.1 > limit {
Err(mlua::Error::RuntimeError(format!(
"script ran for {}s without yielding (execution time limit)",
timeout
)))
} else {
Ok(mlua::VmState::Continue)
}
},
);
}
fn script_timeout_secs() -> u64 {
static TIMEOUT_SECS: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
*TIMEOUT_SECS.get_or_init(|| {
std::env::var("SOLIDB_LUA_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(30)
})
}
pub(crate) fn lua_error_to_db_error(e: mlua::Error) -> DbError {
fn db_error_in(e: &mlua::Error) -> Option<DbError> {
match e {
mlua::Error::CallbackError { cause, .. } => db_error_in(cause),
mlua::Error::WithContext { cause, .. } => db_error_in(cause),
mlua::Error::ExternalError(arc) => match arc.downcast_ref::<DbError>() {
Some(DbError::Forbidden(m)) => Some(DbError::Forbidden(m.clone())),
Some(DbError::Unauthorized(m)) => Some(DbError::Unauthorized(m.clone())),
_ => None,
},
mlua::Error::RuntimeError(m) => script_error_in(m),
_ => None,
}
}
fn script_error_in(m: &str) -> Option<DbError> {
let at = m.find("ERROR:")?;
let rest = &m[at + "ERROR:".len()..];
let (code, message) = rest.split_once(':')?;
let status: u16 = code.trim().parse().ok()?;
if !(100..=599).contains(&status) {
return None;
}
Some(DbError::ScriptError {
status,
message: message.trim().to_string(),
})
}
db_error_in(&e).unwrap_or_else(|| DbError::InternalError(format!("Lua error: {}", e)))
}
fn install_deadline_hook(lua: &Lua) {
let timeout = script_timeout_secs();
if timeout == 0 {
return;
}
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout);
let _ = lua.set_hook(
mlua::HookTriggers::new().every_nth_instruction(50_000),
move |_lua, _debug| {
if std::time::Instant::now() > deadline {
Err(mlua::Error::RuntimeError(format!(
"script exceeded execution time limit ({}s)",
timeout
)))
} else {
Ok(mlua::VmState::Continue)
}
},
);
}
pub struct ScriptEngine {
pub(crate) storage: Arc<StorageEngine>,
pub(crate) queue_notifier: Option<broadcast::Sender<()>>,
pub(crate) stream_manager: Option<Arc<StreamManager>>,
pub(crate) channel_manager: Option<Arc<ChannelManager>>,
pub(crate) stats: Arc<ScriptStats>,
pub(crate) lua_pool: Option<Arc<LuaPool>>,
pub(crate) script_cache: Option<Arc<ScriptCache>>,
}
impl ScriptEngine {
pub fn new(storage: Arc<StorageEngine>, stats: Arc<ScriptStats>) -> Self {
Self {
storage,
queue_notifier: None,
stream_manager: None,
channel_manager: None,
stats,
lua_pool: None,
script_cache: None,
}
}
pub fn with_queue_notifier(mut self, notifier: broadcast::Sender<()>) -> Self {
self.queue_notifier = Some(notifier);
self
}
pub fn with_stream_manager(mut self, manager: Arc<StreamManager>) -> Self {
self.stream_manager = Some(manager);
self
}
pub fn with_channel_manager(mut self, manager: Arc<ChannelManager>) -> Self {
self.channel_manager = Some(manager);
self
}
pub fn with_lua_pool(mut self, pool: Arc<LuaPool>) -> Self {
self.lua_pool = Some(pool);
self
}
pub fn with_script_cache(mut self, cache: Arc<ScriptCache>) -> Self {
self.script_cache = Some(cache);
self
}
pub async fn execute(
&self,
script: &Script,
db_name: &str,
context: &ScriptContext,
) -> Result<ScriptResult, DbError> {
if !lua_runtime_enabled() {
return Err(lua_disabled_error());
}
self.stats.active_scripts.fetch_add(1, Ordering::Relaxed);
self.stats
.total_scripts_executed
.fetch_add(1, Ordering::Relaxed);
struct ActiveScriptGuard<'a>(&'a ScriptStats);
impl Drop for ActiveScriptGuard<'_> {
fn drop(&mut self) {
self.0.active_scripts.fetch_sub(1, Ordering::Relaxed);
}
}
let _guard = ActiveScriptGuard(&self.stats);
if let Some(ref pool) = self.lua_pool {
self.execute_with_pool(pool, script, db_name, context).await
} else {
self.execute_without_pool(script, db_name, context).await
}
}
async fn execute_with_pool(
&self,
pool: &Arc<LuaPool>,
script: &Script,
db_name: &str,
context: &ScriptContext,
) -> Result<ScriptResult, DbError> {
let acquire_deadline = std::time::Instant::now()
+ std::time::Duration::from_secs(script_timeout_secs().max(1));
let pool_guard = loop {
if let Some(guard) = pool.try_acquire() {
break guard;
}
if std::time::Instant::now() >= acquire_deadline {
return Err(DbError::InternalError(
"Script engine busy: every Lua state is in use".to_string(),
));
}
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
};
let needs = if let Some(ref cache) = self.script_cache {
cache.get_or_analyze_needs(&script.key, &script.code)
} else {
globals::ScriptNeeds::analyze(&script.code)
};
pool_guard.with_lua(|lua| {
if needs.any() {
let has_static_globals = lua
.globals()
.get::<bool>("__solidb_static_initialized")
.unwrap_or(false);
if has_static_globals {
globals::setup_request_globals_selective(
self,
lua,
db_name,
context,
Some((&script.key, &script.name)),
Some(&needs),
)?;
} else {
self.setup_lua_globals(
lua,
db_name,
context,
Some((&script.key, &script.name)),
)?;
}
}
let bytecode = if let Some(ref cache) = self.script_cache {
cache
.get_or_compile(&script.key, &script.code, |code| {
let chunk = lua.load(code);
let func = chunk.into_function()?;
Ok(func.dump(false))
})
.map_err(|e| {
DbError::InternalError(format!("Bytecode compilation error: {}", e))
})?
} else {
let chunk = lua.load(&script.code);
let func = chunk.into_function().map_err(|e| {
DbError::InternalError(format!("Script compilation error: {}", e))
})?;
func.dump(false)
};
install_deadline_hook(lua);
let chunk = lua.load(&bytecode[..]);
let lua_result = chunk.eval::<LuaValue>();
lua.remove_hook();
let lua_result = lua_result.map_err(lua_error_to_db_error)?;
self.finish_result(lua, db_name, lua_result)
})
}
async fn execute_without_pool(
&self,
script: &Script,
db_name: &str,
context: &ScriptContext,
) -> Result<ScriptResult, DbError> {
let lua = Lua::new();
LuaPool::apply_memory_limit(&lua);
let globals = lua.globals();
globals
.set("os", LuaValue::Nil)
.map_err(|e| DbError::InternalError(format!("Failed to secure os: {}", e)))?;
globals
.set("io", LuaValue::Nil)
.map_err(|e| DbError::InternalError(format!("Failed to secure io: {}", e)))?;
globals
.set("debug", LuaValue::Nil)
.map_err(|e| DbError::InternalError(format!("Failed to secure debug: {}", e)))?;
globals
.set("package", LuaValue::Nil)
.map_err(|e| DbError::InternalError(format!("Failed to secure package: {}", e)))?;
globals
.set("dofile", LuaValue::Nil)
.map_err(|e| DbError::InternalError(format!("Failed to secure dofile: {}", e)))?;
globals
.set("load", LuaValue::Nil)
.map_err(|e| DbError::InternalError(format!("Failed to secure load: {}", e)))?;
globals
.set("loadfile", LuaValue::Nil)
.map_err(|e| DbError::InternalError(format!("Failed to secure loadfile: {}", e)))?;
globals
.set("require", LuaValue::Nil)
.map_err(|e| DbError::InternalError(format!("Failed to secure require: {}", e)))?;
self.setup_lua_globals(&lua, db_name, context, Some((&script.key, &script.name)))?;
install_deadline_hook(&lua);
let chunk = lua.load(&script.code);
let eval_result = chunk.eval_async::<LuaValue>().await;
lua.remove_hook();
match eval_result {
Ok(result) => self.finish_result(&lua, db_name, result),
Err(e) => Err(lua_error_to_db_error(e)),
}
}
pub async fn execute_ws(
&self,
script: &Script,
db_name: &str,
context: &ScriptContext,
ws: axum::extract::ws::WebSocket,
) -> Result<(), DbError> {
if !lua_runtime_enabled() {
return Err(lua_disabled_error());
}
websocket::execute_ws(self, script, db_name, context, ws).await
}
pub async fn execute_repl(
&self,
code: &str,
db_name: &str,
user: crate::scripting::auth::ScriptUser,
variables: &HashMap<String, JsonValue>,
history: &[String],
output_capture: &mut Vec<String>,
) -> Result<(JsonValue, HashMap<String, JsonValue>), DbError> {
if !lua_runtime_enabled() {
return Err(lua_disabled_error());
}
repl::execute_repl(
self,
code,
db_name,
user,
variables,
history,
output_capture,
)
.await
}
fn finish_result(
&self,
lua: &Lua,
db_name: &str,
value: LuaValue,
) -> Result<ScriptResult, DbError> {
use crate::scripting::response::{take_overrides, ResponseBody, ScriptResponse};
let overrides = take_overrides(lua);
if let LuaValue::UserData(ref ud) = value {
if let Ok(resp) = ud.borrow::<ScriptResponse>() {
let mut headers: HashMap<String, String> = resp.headers.iter().cloned().collect();
let (body, raw_body) = match &resp.body {
ResponseBody::Json(v) => (v.clone(), None),
ResponseBody::Raw {
content_type,
bytes,
} => {
headers.insert("content-type".to_string(), content_type.clone());
(JsonValue::Null, Some(bytes.clone()))
}
ResponseBody::File { key, filename } => {
let (mime, bytes) = self.read_stored_file(db_name, key)?;
headers.insert("content-type".to_string(), mime);
if let Some(name) = filename {
headers.insert(
"content-disposition".to_string(),
format!("attachment; filename=\"{}\"", name.replace('"', "")),
);
}
(JsonValue::Null, Some(bytes))
}
};
return Ok(ScriptResult {
status: resp.status,
body,
headers,
raw_body,
});
}
if let Ok(raw) = ud.borrow::<crate::scripting::conversion::RawJson>() {
return Ok(ScriptResult {
status: overrides.status.unwrap_or(200),
body: JsonValue::Null,
headers: overrides.headers.into_iter().collect(),
raw_body: Some(raw.0.clone().into_bytes()),
});
}
}
let body = self.lua_to_json(lua, value)?;
Ok(ScriptResult {
status: overrides.status.unwrap_or(200),
body,
headers: overrides.headers.into_iter().collect(),
raw_body: None,
})
}
fn read_stored_file(&self, db_name: &str, key: &str) -> Result<(String, Vec<u8>), DbError> {
let database = self.storage.get_database(db_name)?;
let collection = database
.get_collection(crate::scripting::file_handling::FILES_COLLECTION)
.map_err(|_| DbError::DocumentNotFound(key.to_string()))?;
let doc = collection
.get(key)
.map_err(|_| DbError::DocumentNotFound(key.to_string()))?;
let meta = doc.to_value();
let mime = meta
.get("mime_type")
.and_then(|v| v.as_str())
.unwrap_or("application/octet-stream")
.to_string();
let chunk_count = meta.get("chunks").and_then(|v| v.as_u64()).unwrap_or(1) as u32;
let mut data = Vec::new();
for i in 0..chunk_count {
if let Ok(Some(chunk)) = collection.get_blob_chunk(key, i) {
data.extend(chunk);
}
}
Ok((mime, data))
}
pub(crate) fn setup_lua_globals(
&self,
lua: &Lua,
db_name: &str,
context: &ScriptContext,
script_info: Option<(&str, &str)>,
) -> Result<(), DbError> {
globals::setup_lua_globals(self, lua, db_name, context, script_info)
}
pub(crate) fn lua_to_json(&self, lua: &Lua, value: LuaValue) -> Result<JsonValue, DbError> {
lua_to_json_value(lua, value)
.map_err(|e| DbError::InternalError(format!("Failed to convert Lua to JSON: {}", e)))
}
}
#[cfg(test)]
mod lua_off_tests {
use super::*;
#[test]
fn env_flag_accepts_common_truthy_values() {
assert!(env_flag_is_set(Some("1")));
assert!(env_flag_is_set(Some("true")));
assert!(env_flag_is_set(Some("YES")));
assert!(!env_flag_is_set(Some("0")));
assert!(!env_flag_is_set(Some("false")));
assert!(!env_flag_is_set(None));
assert!(!env_flag_is_set(Some("")));
}
}