1pub mod async_bridge;
2pub mod builtins;
3pub mod file_source;
4
5#[cfg(feature = "server")]
6#[allow(unused_imports)]
7pub use builtins::LuaAxumRouter;
8
9use anyhow::Result;
10use include_dir::{Dir, include_dir};
11use mlua::{Lua, LuaOptions, StdLib};
12
13static STDLIB_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/stdlib");
14
15pub const MODULES_PATH_ENV: &str = "ASSAY_MODULES_PATH";
17
18pub const BLOCK_GLOBALS_ENV: &str = "ASSAY_BLOCK_GLOBALS";
23
24pub const READONLY_ENV: &str = "ASSAY_READONLY";
29
30pub fn readonly_from_env() -> bool {
31 matches!(
32 std::env::var(READONLY_ENV).ok().as_deref().map(str::trim),
33 Some("1") | Some("true")
34 )
35}
36
37fn lua_err(e: mlua::Error) -> anyhow::Error {
38 anyhow::anyhow!("{e}")
39}
40
41#[allow(dead_code)]
42pub fn create_vm(client: reqwest::Client) -> Result<Lua> {
43 create_vm_configured(client, None, readonly_from_env())
44}
45
46#[allow(dead_code)]
47pub fn create_vm_with_lib_path(client: reqwest::Client, lib_path: String) -> Result<Lua> {
48 create_vm_configured(client, Some(lib_path), readonly_from_env())
49}
50
51#[allow(dead_code)]
52pub fn create_vm_with_paths(
53 client: reqwest::Client,
54 global_modules_path: Option<String>,
55) -> Result<Lua> {
56 create_vm_configured(client, global_modules_path, readonly_from_env())
57}
58
59pub fn create_vm_configured(
60 client: reqwest::Client,
61 global_modules_path: Option<String>,
62 readonly: bool,
63) -> Result<Lua> {
64 let libs = StdLib::ALL_SAFE;
65 let lua = Lua::new_with(libs, LuaOptions::default()).map_err(lua_err)?;
66 lua.set_memory_limit(64 * 1024 * 1024).map_err(lua_err)?;
67 sandbox(&lua).map_err(lua_err)?;
68 register_fs_loader(&lua, global_modules_path).map_err(lua_err)?;
69 register_stdlib_loader(&lua).map_err(lua_err)?;
70 builtins::register_all(&lua, client).map_err(lua_err)?;
71 if readonly {
72 builtins::readonly::apply(&lua).map_err(lua_err)?;
73 }
74 Ok(lua)
75}
76
77fn sandbox(lua: &Lua) -> mlua::Result<()> {
78 let globals = lua.globals();
85 let string_lib: mlua::Table = globals.get("string")?;
86 string_lib.set("dump", mlua::Value::Nil)?;
87
88 if let Ok(extra) = std::env::var(BLOCK_GLOBALS_ENV) {
89 for raw in extra.split(',') {
90 let name = raw.trim();
91 if name.is_empty() {
92 continue;
93 }
94 nil_dotted_path(lua, name)?;
95 }
96 }
97
98 Ok(())
99}
100
101fn nil_dotted_path(lua: &Lua, path: &str) -> mlua::Result<()> {
107 let parts: Vec<&str> = path.split('.').filter(|s| !s.is_empty()).collect();
108 if parts.is_empty() {
109 return Ok(());
110 }
111 let mut current: mlua::Table = lua.globals();
112 for segment in &parts[..parts.len() - 1] {
113 let next: mlua::Value = current.get(*segment)?;
114 match next {
115 mlua::Value::Table(t) => current = t,
116 _ => return Ok(()),
117 }
118 }
119 current.set(parts[parts.len() - 1], mlua::Value::Nil)
120}
121
122fn register_stdlib_loader(lua: &Lua) -> mlua::Result<()> {
123 let package: mlua::Table = lua.globals().get("package")?;
124 let searchers: mlua::Table = package.get("searchers")?;
125
126 let stdlib_searcher = lua.create_function(|lua, module_name: String| {
132 let rest = match module_name.strip_prefix("assay.") {
133 Some(r) => r,
134 None => {
135 return Ok(mlua::Value::String(
136 lua.create_string(format!("not an assay.* module: {module_name}"))?,
137 ));
138 }
139 };
140
141 let base = rest.replace('.', "/");
142 let candidates = [format!("{base}.lua"), format!("{base}/init.lua")];
143
144 for path in &candidates {
145 if let Some(file) = STDLIB_DIR.get_file(path) {
146 let source = file
147 .contents_utf8()
148 .ok_or_else(|| mlua::Error::runtime(format!("stdlib {path}: invalid UTF-8")))?;
149 let loader = lua
150 .load(source)
151 .set_name(format!("@assay/{path}"))
152 .into_function()?;
153 return Ok(mlua::Value::Function(loader));
154 }
155 }
156
157 Ok(mlua::Value::String(lua.create_string(format!(
158 "no embedded stdlib file: {}",
159 candidates[0]
160 ))?))
161 })?;
162
163 let len = searchers.len()?;
164 searchers.set(len + 1, stdlib_searcher)?;
165
166 Ok(())
167}
168
169fn register_fs_loader(lua: &Lua, global_modules_path: Option<String>) -> mlua::Result<()> {
170 let package: mlua::Table = lua.globals().get("package")?;
171 let searchers: mlua::Table = package.get("searchers")?;
172
173 let fs_searcher = lua.create_function(move |lua, module_name: String| {
176 let rest = match module_name.strip_prefix("assay.") {
177 Some(r) => r,
178 None => {
179 return Ok(mlua::Value::String(
180 lua.create_string(format!("not an assay.* module: {module_name}"))?,
181 ));
182 }
183 };
184 let base = rest.replace('.', "/");
185 let candidates = [format!("{base}.lua"), format!("{base}/init.lua")];
186
187 let try_load = |dir: &std::path::Path| -> Option<(std::path::PathBuf, String)> {
188 for rel in &candidates {
189 let full = dir.join(rel);
190 if let Ok(source) = std::fs::read_to_string(&full) {
191 return Some((full, source));
192 }
193 }
194 None
195 };
196
197 if let Some((full, source)) = try_load(std::path::Path::new("./modules")) {
199 let loader = lua
200 .load(source)
201 .set_name(format!("@{}", full.display()))
202 .into_function()?;
203 return Ok(mlua::Value::Function(loader));
204 }
205
206 let global_path = if let Some(ref custom_path) = global_modules_path {
208 std::path::PathBuf::from(custom_path)
209 } else if let Ok(modules_env) = std::env::var(MODULES_PATH_ENV) {
210 std::path::PathBuf::from(modules_env)
211 } else if let Ok(home) = std::env::var("HOME") {
212 std::path::Path::new(&home).join(".assay/modules")
213 } else {
214 std::path::PathBuf::new()
215 };
216
217 if !global_path.as_os_str().is_empty()
218 && let Some((full, source)) = try_load(&global_path)
219 {
220 let loader = lua
221 .load(source)
222 .set_name(format!("@{}", full.display()))
223 .into_function()?;
224 return Ok(mlua::Value::Function(loader));
225 }
226
227 Ok(mlua::Value::Nil)
230 })?;
231
232 let len = searchers.len()?;
233 searchers.set(len + 1, fs_searcher)?;
234
235 Ok(())
236}
237
238pub fn inject_env(lua: &Lua, env: &std::collections::HashMap<String, String>) -> Result<()> {
239 if env.is_empty() {
240 return Ok(());
241 }
242 let globals = lua.globals();
243 let env_table: mlua::Table = globals.get("env").map_err(lua_err)?;
244 let check_env: mlua::Table = env_table.get("_check_env").map_err(lua_err)?;
245 for (k, v) in env {
246 check_env.set(k.as_str(), v.as_str()).map_err(lua_err)?;
247 }
248 Ok(())
249}