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
37pub const APPROVAL_ENV: &str = "ASSAY_APPROVAL";
43
44pub(crate) const APPROVED_INDICES_ENV: &str = "ASSAY_APPROVED_INDICES";
47
48pub(crate) const DENIED_INDEX_ENV: &str = "ASSAY_DENIED_INDEX";
51
52pub(crate) const APPROVAL_REQUEST_PREFIX: &str = "__assay_approval_request__:";
55
56pub fn approval_from_env() -> bool {
57 matches!(
58 std::env::var(APPROVAL_ENV).ok().as_deref().map(str::trim),
59 Some("1") | Some("true")
60 )
61}
62
63#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
66pub enum ExecMode {
67 #[default]
68 Unrestricted,
69 ReadOnly,
70 Approval,
71}
72
73impl ExecMode {
74 pub fn is_readonly(self) -> bool {
75 matches!(self, ExecMode::ReadOnly)
76 }
77
78 pub fn is_approval(self) -> bool {
79 matches!(self, ExecMode::Approval)
80 }
81}
82
83#[derive(Clone, Debug, Default)]
87pub struct ApprovalConfig {
88 pub approved_indices: Vec<u64>,
89 pub denied_index: Option<u64>,
90}
91
92pub fn approval_config_from_env() -> ApprovalConfig {
96 let approved_indices = std::env::var(APPROVED_INDICES_ENV)
97 .ok()
98 .map(|raw| parse_indices(&raw))
99 .unwrap_or_default();
100 let denied_index = std::env::var(DENIED_INDEX_ENV)
101 .ok()
102 .and_then(|raw| raw.trim().parse::<u64>().ok());
103 ApprovalConfig {
104 approved_indices,
105 denied_index,
106 }
107}
108
109fn parse_indices(raw: &str) -> Vec<u64> {
110 raw.split(',')
111 .filter_map(|part| {
112 let trimmed = part.trim();
113 if trimmed.is_empty() {
114 None
115 } else {
116 trimmed.parse::<u64>().ok()
117 }
118 })
119 .collect()
120}
121
122#[derive(Clone, Debug, Default)]
125pub struct VmOptions {
126 pub global_modules_path: Option<String>,
127 pub mode: ExecMode,
128 pub approval: ApprovalConfig,
129}
130
131fn lua_err(e: mlua::Error) -> anyhow::Error {
132 anyhow::anyhow!("{e}")
133}
134
135#[allow(dead_code)]
136pub fn create_vm(client: reqwest::Client) -> Result<Lua> {
137 create_vm_configured(client, None, readonly_from_env())
138}
139
140#[allow(dead_code)]
141pub fn create_vm_with_lib_path(client: reqwest::Client, lib_path: String) -> Result<Lua> {
142 create_vm_configured(client, Some(lib_path), readonly_from_env())
143}
144
145#[allow(dead_code)]
146pub fn create_vm_with_paths(
147 client: reqwest::Client,
148 global_modules_path: Option<String>,
149) -> Result<Lua> {
150 create_vm_configured(client, global_modules_path, readonly_from_env())
151}
152
153pub fn create_vm_configured(
154 client: reqwest::Client,
155 global_modules_path: Option<String>,
156 readonly: bool,
157) -> Result<Lua> {
158 let mode = if readonly {
159 ExecMode::ReadOnly
160 } else {
161 ExecMode::Unrestricted
162 };
163 create_vm_with_options(
164 client,
165 VmOptions {
166 global_modules_path,
167 mode,
168 approval: ApprovalConfig::default(),
169 },
170 )
171}
172
173pub fn create_vm_with_options(client: reqwest::Client, options: VmOptions) -> Result<Lua> {
174 let VmOptions {
175 global_modules_path,
176 mode,
177 approval,
178 } = options;
179 let libs = StdLib::ALL_SAFE;
180 let lua = Lua::new_with(libs, LuaOptions::default()).map_err(lua_err)?;
181 lua.set_memory_limit(64 * 1024 * 1024).map_err(lua_err)?;
182 sandbox(&lua).map_err(lua_err)?;
183 register_fs_loader(&lua, global_modules_path).map_err(lua_err)?;
184 register_stdlib_loader(&lua).map_err(lua_err)?;
185 builtins::register_all(&lua, client).map_err(lua_err)?;
186 match mode {
187 ExecMode::ReadOnly => builtins::readonly::apply(&lua).map_err(lua_err)?,
188 ExecMode::Approval => builtins::approval::apply(&lua, &approval).map_err(lua_err)?,
189 ExecMode::Unrestricted => {}
190 }
191 Ok(lua)
192}
193
194fn sandbox(lua: &Lua) -> mlua::Result<()> {
195 let globals = lua.globals();
202 let string_lib: mlua::Table = globals.get("string")?;
203 string_lib.set("dump", mlua::Value::Nil)?;
204
205 if let Ok(extra) = std::env::var(BLOCK_GLOBALS_ENV) {
206 for raw in extra.split(',') {
207 let name = raw.trim();
208 if name.is_empty() {
209 continue;
210 }
211 nil_dotted_path(lua, name)?;
212 }
213 }
214
215 Ok(())
216}
217
218fn nil_dotted_path(lua: &Lua, path: &str) -> mlua::Result<()> {
224 let parts: Vec<&str> = path.split('.').filter(|s| !s.is_empty()).collect();
225 if parts.is_empty() {
226 return Ok(());
227 }
228 let mut current: mlua::Table = lua.globals();
229 for segment in &parts[..parts.len() - 1] {
230 let next: mlua::Value = current.get(*segment)?;
231 match next {
232 mlua::Value::Table(t) => current = t,
233 _ => return Ok(()),
234 }
235 }
236 current.set(parts[parts.len() - 1], mlua::Value::Nil)
237}
238
239fn register_stdlib_loader(lua: &Lua) -> mlua::Result<()> {
240 let package: mlua::Table = lua.globals().get("package")?;
241 let searchers: mlua::Table = package.get("searchers")?;
242
243 let stdlib_searcher = lua.create_function(|lua, module_name: String| {
249 let rest = match module_name.strip_prefix("assay.") {
250 Some(r) => r,
251 None => {
252 return Ok(mlua::Value::String(
253 lua.create_string(format!("not an assay.* module: {module_name}"))?,
254 ));
255 }
256 };
257
258 let base = rest.replace('.', "/");
259 let candidates = [format!("{base}.lua"), format!("{base}/init.lua")];
260
261 for path in &candidates {
262 if let Some(file) = STDLIB_DIR.get_file(path) {
263 let source = file
264 .contents_utf8()
265 .ok_or_else(|| mlua::Error::runtime(format!("stdlib {path}: invalid UTF-8")))?;
266 let loader = lua
267 .load(source)
268 .set_name(format!("@assay/{path}"))
269 .into_function()?;
270 return Ok(mlua::Value::Function(loader));
271 }
272 }
273
274 Ok(mlua::Value::String(lua.create_string(format!(
275 "no embedded stdlib file: {}",
276 candidates[0]
277 ))?))
278 })?;
279
280 let len = searchers.len()?;
281 searchers.set(len + 1, stdlib_searcher)?;
282
283 Ok(())
284}
285
286fn register_fs_loader(lua: &Lua, global_modules_path: Option<String>) -> mlua::Result<()> {
287 let package: mlua::Table = lua.globals().get("package")?;
288 let searchers: mlua::Table = package.get("searchers")?;
289
290 let fs_searcher = lua.create_function(move |lua, module_name: String| {
293 let rest = match module_name.strip_prefix("assay.") {
294 Some(r) => r,
295 None => {
296 return Ok(mlua::Value::String(
297 lua.create_string(format!("not an assay.* module: {module_name}"))?,
298 ));
299 }
300 };
301 let base = rest.replace('.', "/");
302 let candidates = [format!("{base}.lua"), format!("{base}/init.lua")];
303
304 let try_load = |dir: &std::path::Path| -> Option<(std::path::PathBuf, String)> {
305 for rel in &candidates {
306 let full = dir.join(rel);
307 if let Ok(source) = std::fs::read_to_string(&full) {
308 return Some((full, source));
309 }
310 }
311 None
312 };
313
314 if let Some((full, source)) = try_load(std::path::Path::new("./modules")) {
316 let loader = lua
317 .load(source)
318 .set_name(format!("@{}", full.display()))
319 .into_function()?;
320 return Ok(mlua::Value::Function(loader));
321 }
322
323 let global_path = if let Some(ref custom_path) = global_modules_path {
325 std::path::PathBuf::from(custom_path)
326 } else if let Ok(modules_env) = std::env::var(MODULES_PATH_ENV) {
327 std::path::PathBuf::from(modules_env)
328 } else if let Ok(home) = std::env::var("HOME") {
329 std::path::Path::new(&home).join(".assay/modules")
330 } else {
331 std::path::PathBuf::new()
332 };
333
334 if !global_path.as_os_str().is_empty()
335 && let Some((full, source)) = try_load(&global_path)
336 {
337 let loader = lua
338 .load(source)
339 .set_name(format!("@{}", full.display()))
340 .into_function()?;
341 return Ok(mlua::Value::Function(loader));
342 }
343
344 Ok(mlua::Value::Nil)
347 })?;
348
349 let len = searchers.len()?;
350 searchers.set(len + 1, fs_searcher)?;
351
352 Ok(())
353}
354
355pub fn inject_env(lua: &Lua, env: &std::collections::HashMap<String, String>) -> Result<()> {
356 if env.is_empty() {
357 return Ok(());
358 }
359 let globals = lua.globals();
360 let env_table: mlua::Table = globals.get("env").map_err(lua_err)?;
361 let check_env: mlua::Table = env_table.get("_check_env").map_err(lua_err)?;
362 for (k, v) in env {
363 check_env.set(k.as_str(), v.as_str()).map_err(lua_err)?;
364 }
365 Ok(())
366}