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