1pub mod async_bridge;
2pub mod builtins;
3pub mod file_source;
4pub mod policy;
5
6#[cfg(feature = "server")]
7#[allow(unused_imports)]
8pub use builtins::LuaAxumRouter;
9
10use anyhow::Result;
11use include_dir::{Dir, include_dir};
12use mlua::{Lua, LuaOptions, StdLib};
13
14static STDLIB_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/stdlib");
15
16pub const MODULES_PATH_ENV: &str = "ASSAY_MODULES_PATH";
18
19pub const BLOCK_GLOBALS_ENV: &str = "ASSAY_BLOCK_GLOBALS";
24
25pub const READONLY_ENV: &str = "ASSAY_READONLY";
30
31pub fn readonly_from_env() -> bool {
32 matches!(
33 std::env::var(READONLY_ENV).ok().as_deref().map(str::trim),
34 Some("1") | Some("true")
35 )
36}
37
38pub const APPROVAL_ENV: &str = "ASSAY_APPROVAL";
44
45pub(crate) const APPROVED_INDICES_ENV: &str = "ASSAY_APPROVED_INDICES";
48
49pub(crate) const DENIED_INDEX_ENV: &str = "ASSAY_DENIED_INDEX";
52
53pub(crate) const APPROVED_OPS_ENV: &str = "ASSAY_APPROVED_OPS";
58
59pub(crate) const APPROVAL_REQUEST_PREFIX: &str = "__assay_approval_request__:";
62
63pub fn approval_from_env() -> bool {
64 matches!(
65 std::env::var(APPROVAL_ENV).ok().as_deref().map(str::trim),
66 Some("1") | Some("true")
67 )
68}
69
70#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
73pub enum ExecMode {
74 #[default]
75 Unrestricted,
76 ReadOnly,
77 Approval,
78}
79
80impl ExecMode {
81 pub fn is_readonly(self) -> bool {
82 matches!(self, ExecMode::ReadOnly)
83 }
84
85 pub fn is_approval(self) -> bool {
86 matches!(self, ExecMode::Approval)
87 }
88}
89
90#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
97pub(crate) struct ApprovedOp {
98 pub index: u64,
99 pub op: String,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub approver: Option<String>,
102}
103
104pub(crate) fn approved_ops_from_env() -> Vec<ApprovedOp> {
108 std::env::var(APPROVED_OPS_ENV)
109 .ok()
110 .and_then(|raw| serde_json::from_str::<Vec<ApprovedOp>>(&raw).ok())
111 .unwrap_or_default()
112}
113
114#[derive(Clone, Debug, Default)]
118pub struct ApprovalConfig {
119 pub approved_indices: Vec<u64>,
120 pub denied_index: Option<u64>,
121}
122
123pub fn approval_config_from_env() -> ApprovalConfig {
127 let approved_indices = std::env::var(APPROVED_INDICES_ENV)
128 .ok()
129 .map(|raw| parse_indices(&raw))
130 .unwrap_or_default();
131 let denied_index = std::env::var(DENIED_INDEX_ENV)
132 .ok()
133 .and_then(|raw| raw.trim().parse::<u64>().ok());
134 ApprovalConfig {
135 approved_indices,
136 denied_index,
137 }
138}
139
140fn parse_indices(raw: &str) -> Vec<u64> {
141 raw.split(',')
142 .filter_map(|part| {
143 let trimmed = part.trim();
144 if trimmed.is_empty() {
145 None
146 } else {
147 trimmed.parse::<u64>().ok()
148 }
149 })
150 .collect()
151}
152
153#[derive(Clone, Debug, Default)]
156pub struct VmOptions {
157 pub global_modules_path: Option<String>,
158 pub mode: ExecMode,
159 pub approval: ApprovalConfig,
160}
161
162fn lua_err(e: mlua::Error) -> anyhow::Error {
163 anyhow::anyhow!("{e}")
164}
165
166fn resolve_policy(
169 explicit: Option<std::sync::Arc<policy::Policy>>,
170) -> Result<Option<std::sync::Arc<policy::Policy>>> {
171 match explicit {
172 Some(policy) => Ok(Some(policy)),
173 None => policy::from_env().map_err(|e| anyhow::anyhow!("{e}")),
174 }
175}
176
177#[allow(dead_code)]
178pub fn create_vm(client: reqwest::Client) -> Result<Lua> {
179 create_vm_configured(client, None, readonly_from_env())
180}
181
182#[allow(dead_code)]
183pub fn create_vm_with_lib_path(client: reqwest::Client, lib_path: String) -> Result<Lua> {
184 create_vm_configured(client, Some(lib_path), readonly_from_env())
185}
186
187#[allow(dead_code)]
188pub fn create_vm_with_paths(
189 client: reqwest::Client,
190 global_modules_path: Option<String>,
191) -> Result<Lua> {
192 create_vm_configured(client, global_modules_path, readonly_from_env())
193}
194
195pub fn create_vm_configured(
196 client: reqwest::Client,
197 global_modules_path: Option<String>,
198 readonly: bool,
199) -> Result<Lua> {
200 let mode = if readonly {
201 ExecMode::ReadOnly
202 } else {
203 ExecMode::Unrestricted
204 };
205 create_vm_with_options(
206 client,
207 VmOptions {
208 global_modules_path,
209 mode,
210 approval: ApprovalConfig::default(),
211 },
212 )
213}
214
215pub fn create_vm_with_options(client: reqwest::Client, options: VmOptions) -> Result<Lua> {
216 create_vm_with_policy(client, options, None)
217}
218
219pub fn create_vm_with_policy(
222 client: reqwest::Client,
223 options: VmOptions,
224 policy: Option<std::sync::Arc<policy::Policy>>,
225) -> Result<Lua> {
226 let VmOptions {
227 global_modules_path,
228 mode,
229 approval,
230 } = options;
231 let libs = StdLib::ALL_SAFE;
232 let lua = Lua::new_with(libs, LuaOptions::default()).map_err(lua_err)?;
233 lua.set_memory_limit(64 * 1024 * 1024).map_err(lua_err)?;
234 let policed = resolve_policy(policy)?;
237 if let Some(policy) = policed.clone() {
238 policy::install(&lua, policy);
239 }
240 sandbox(&lua).map_err(lua_err)?;
241 register_fs_loader(&lua, global_modules_path).map_err(lua_err)?;
242 register_stdlib_loader(&lua).map_err(lua_err)?;
243 builtins::register_all(&lua, client).map_err(lua_err)?;
244 if policed.is_some() {
247 policy::apply::apply(&lua).map_err(lua_err)?;
248 }
249 match mode {
250 ExecMode::ReadOnly => builtins::readonly::apply(&lua).map_err(lua_err)?,
251 ExecMode::Approval => builtins::approval::apply(&lua, &approval).map_err(lua_err)?,
252 ExecMode::Unrestricted => {}
253 }
254 Ok(lua)
255}
256
257fn sandbox(lua: &Lua) -> mlua::Result<()> {
258 let globals = lua.globals();
265 let string_lib: mlua::Table = globals.get("string")?;
266 string_lib.set("dump", mlua::Value::Nil)?;
267
268 if let Ok(extra) = std::env::var(BLOCK_GLOBALS_ENV) {
269 for raw in extra.split(',') {
270 let name = raw.trim();
271 if name.is_empty() {
272 continue;
273 }
274 nil_dotted_path(lua, name)?;
275 }
276 }
277
278 Ok(())
279}
280
281fn nil_dotted_path(lua: &Lua, path: &str) -> mlua::Result<()> {
287 let parts: Vec<&str> = path.split('.').filter(|s| !s.is_empty()).collect();
288 if parts.is_empty() {
289 return Ok(());
290 }
291 let mut current: mlua::Table = lua.globals();
292 for segment in &parts[..parts.len() - 1] {
293 let next: mlua::Value = current.get(*segment)?;
294 match next {
295 mlua::Value::Table(t) => current = t,
296 _ => return Ok(()),
297 }
298 }
299 current.set(parts[parts.len() - 1], mlua::Value::Nil)
300}
301
302fn module_candidates(lua: &Lua, module_name: &str) -> mlua::Result<Option<[String; 2]>> {
306 let Some(rest) = module_name.strip_prefix("assay.") else {
307 return Ok(None);
308 };
309 policy::guard_require(lua, module_name)?;
310 let base = rest.replace('.', "/");
311 Ok(Some([format!("{base}.lua"), format!("{base}/init.lua")]))
312}
313
314fn not_an_assay_module(lua: &Lua, module_name: &str) -> mlua::Result<mlua::Value> {
315 Ok(mlua::Value::String(lua.create_string(format!(
316 "not an assay.* module: {module_name}"
317 ))?))
318}
319
320fn register_stdlib_loader(lua: &Lua) -> mlua::Result<()> {
321 let package: mlua::Table = lua.globals().get("package")?;
322 let searchers: mlua::Table = package.get("searchers")?;
323
324 let stdlib_searcher = lua.create_function(|lua, module_name: String| {
330 let candidates = match module_candidates(lua, &module_name)? {
331 Some(c) => c,
332 None => return not_an_assay_module(lua, &module_name),
333 };
334
335 for path in &candidates {
336 if let Some(file) = STDLIB_DIR.get_file(path) {
337 let source = file
338 .contents_utf8()
339 .ok_or_else(|| mlua::Error::runtime(format!("stdlib {path}: invalid UTF-8")))?;
340 let loader = lua
341 .load(source)
342 .set_name(format!("@assay/{path}"))
343 .into_function()?;
344 return Ok(mlua::Value::Function(loader));
345 }
346 }
347
348 Ok(mlua::Value::String(lua.create_string(format!(
349 "no embedded stdlib file: {}",
350 candidates[0]
351 ))?))
352 })?;
353
354 let len = searchers.len()?;
355 searchers.set(len + 1, stdlib_searcher)?;
356
357 Ok(())
358}
359
360fn register_fs_loader(lua: &Lua, global_modules_path: Option<String>) -> mlua::Result<()> {
361 let package: mlua::Table = lua.globals().get("package")?;
362 let searchers: mlua::Table = package.get("searchers")?;
363
364 let fs_searcher = lua.create_function(move |lua, module_name: String| {
367 let candidates = match module_candidates(lua, &module_name)? {
368 Some(c) => c,
369 None => return not_an_assay_module(lua, &module_name),
370 };
371
372 let try_load = |dir: &std::path::Path| -> Option<(std::path::PathBuf, String)> {
373 for rel in &candidates {
374 let full = dir.join(rel);
375 if let Ok(source) = std::fs::read_to_string(&full) {
376 return Some((full, source));
377 }
378 }
379 None
380 };
381
382 if let Some((full, source)) = try_load(std::path::Path::new("./modules")) {
384 let loader = lua
385 .load(source)
386 .set_name(format!("@{}", full.display()))
387 .into_function()?;
388 return Ok(mlua::Value::Function(loader));
389 }
390
391 let global_path = if let Some(ref custom_path) = global_modules_path {
393 std::path::PathBuf::from(custom_path)
394 } else if let Ok(modules_env) = std::env::var(MODULES_PATH_ENV) {
395 std::path::PathBuf::from(modules_env)
396 } else if let Ok(home) = std::env::var("HOME") {
397 std::path::Path::new(&home).join(".assay/modules")
398 } else {
399 std::path::PathBuf::new()
400 };
401
402 if !global_path.as_os_str().is_empty()
403 && let Some((full, source)) = try_load(&global_path)
404 {
405 let loader = lua
406 .load(source)
407 .set_name(format!("@{}", full.display()))
408 .into_function()?;
409 return Ok(mlua::Value::Function(loader));
410 }
411
412 Ok(mlua::Value::Nil)
415 })?;
416
417 let len = searchers.len()?;
418 searchers.set(len + 1, fs_searcher)?;
419
420 Ok(())
421}
422
423pub fn inject_env(lua: &Lua, env: &std::collections::HashMap<String, String>) -> Result<()> {
424 if env.is_empty() {
425 return Ok(());
426 }
427 let globals = lua.globals();
428 let env_table: mlua::Table = globals.get("env").map_err(lua_err)?;
429 let check_env: mlua::Table = env_table.get("_check_env").map_err(lua_err)?;
430 for (k, v) in env {
431 check_env.set(k.as_str(), v.as_str()).map_err(lua_err)?;
432 }
433 Ok(())
434}