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 let Some(policy) = policed.as_ref() {
247 policy::credential::register(&lua, policy).map_err(lua_err)?;
248 policy::apply::apply(&lua).map_err(lua_err)?;
249 }
250 match mode {
251 ExecMode::ReadOnly => builtins::readonly::apply(&lua).map_err(lua_err)?,
252 ExecMode::Approval => builtins::approval::apply(&lua, &approval).map_err(lua_err)?,
253 ExecMode::Unrestricted => {}
254 }
255 Ok(lua)
256}
257
258fn sandbox(lua: &Lua) -> mlua::Result<()> {
259 let globals = lua.globals();
266 let string_lib: mlua::Table = globals.get("string")?;
267 string_lib.set("dump", mlua::Value::Nil)?;
268
269 if let Ok(extra) = std::env::var(BLOCK_GLOBALS_ENV) {
270 for raw in extra.split(',') {
271 let name = raw.trim();
272 if name.is_empty() {
273 continue;
274 }
275 nil_dotted_path(lua, name)?;
276 }
277 }
278
279 Ok(())
280}
281
282fn nil_dotted_path(lua: &Lua, path: &str) -> mlua::Result<()> {
288 let parts: Vec<&str> = path.split('.').filter(|s| !s.is_empty()).collect();
289 if parts.is_empty() {
290 return Ok(());
291 }
292 let mut current: mlua::Table = lua.globals();
293 for segment in &parts[..parts.len() - 1] {
294 let next: mlua::Value = current.get(*segment)?;
295 match next {
296 mlua::Value::Table(t) => current = t,
297 _ => return Ok(()),
298 }
299 }
300 current.set(parts[parts.len() - 1], mlua::Value::Nil)
301}
302
303fn module_candidates(lua: &Lua, module_name: &str) -> mlua::Result<Option<[String; 2]>> {
307 let Some(rest) = module_name.strip_prefix("assay.") else {
308 return Ok(None);
309 };
310 policy::guard_require(lua, module_name)?;
311 let base = rest.replace('.', "/");
312 Ok(Some([format!("{base}.lua"), format!("{base}/init.lua")]))
313}
314
315fn not_an_assay_module(lua: &Lua, module_name: &str) -> mlua::Result<mlua::Value> {
316 Ok(mlua::Value::String(lua.create_string(format!(
317 "not an assay.* module: {module_name}"
318 ))?))
319}
320
321fn register_stdlib_loader(lua: &Lua) -> mlua::Result<()> {
322 let package: mlua::Table = lua.globals().get("package")?;
323 let searchers: mlua::Table = package.get("searchers")?;
324
325 let stdlib_searcher = lua.create_function(|lua, module_name: String| {
331 let candidates = match module_candidates(lua, &module_name)? {
332 Some(c) => c,
333 None => return not_an_assay_module(lua, &module_name),
334 };
335
336 for path in &candidates {
337 if let Some(file) = STDLIB_DIR.get_file(path) {
338 let source = file
339 .contents_utf8()
340 .ok_or_else(|| mlua::Error::runtime(format!("stdlib {path}: invalid UTF-8")))?;
341 let loader = lua
342 .load(source)
343 .set_name(format!("@assay/{path}"))
344 .into_function()?;
345 return Ok(mlua::Value::Function(loader));
346 }
347 }
348
349 Ok(mlua::Value::String(lua.create_string(format!(
350 "no embedded stdlib file: {}",
351 candidates[0]
352 ))?))
353 })?;
354
355 let len = searchers.len()?;
356 searchers.set(len + 1, stdlib_searcher)?;
357
358 Ok(())
359}
360
361fn register_fs_loader(lua: &Lua, global_modules_path: Option<String>) -> mlua::Result<()> {
362 let package: mlua::Table = lua.globals().get("package")?;
363 let searchers: mlua::Table = package.get("searchers")?;
364
365 let fs_searcher = lua.create_function(move |lua, module_name: String| {
368 let candidates = match module_candidates(lua, &module_name)? {
369 Some(c) => c,
370 None => return not_an_assay_module(lua, &module_name),
371 };
372
373 let try_load = |dir: &std::path::Path| -> Option<(std::path::PathBuf, String)> {
374 for rel in &candidates {
375 let full = dir.join(rel);
376 if let Ok(source) = std::fs::read_to_string(&full) {
377 return Some((full, source));
378 }
379 }
380 None
381 };
382
383 if let Some((full, source)) = try_load(std::path::Path::new("./modules")) {
385 let loader = lua
386 .load(source)
387 .set_name(format!("@{}", full.display()))
388 .into_function()?;
389 return Ok(mlua::Value::Function(loader));
390 }
391
392 let global_path = if let Some(ref custom_path) = global_modules_path {
394 std::path::PathBuf::from(custom_path)
395 } else if let Ok(modules_env) = std::env::var(MODULES_PATH_ENV) {
396 std::path::PathBuf::from(modules_env)
397 } else if let Ok(home) = std::env::var("HOME") {
398 std::path::Path::new(&home).join(".assay/modules")
399 } else {
400 std::path::PathBuf::new()
401 };
402
403 if !global_path.as_os_str().is_empty()
404 && let Some((full, source)) = try_load(&global_path)
405 {
406 let loader = lua
407 .load(source)
408 .set_name(format!("@{}", full.display()))
409 .into_function()?;
410 return Ok(mlua::Value::Function(loader));
411 }
412
413 Ok(mlua::Value::Nil)
416 })?;
417
418 let len = searchers.len()?;
419 searchers.set(len + 1, fs_searcher)?;
420
421 Ok(())
422}
423
424pub fn inject_env(lua: &Lua, env: &std::collections::HashMap<String, String>) -> Result<()> {
425 if env.is_empty() {
426 return Ok(());
427 }
428 let globals = lua.globals();
429 let env_table: mlua::Table = globals.get("env").map_err(lua_err)?;
430 let check_env: mlua::Table = env_table.get("_check_env").map_err(lua_err)?;
431 for (k, v) in env {
432 check_env.set(k.as_str(), v.as_str()).map_err(lua_err)?;
433 }
434 Ok(())
435}