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")]
104 pub digest: Option<String>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub approver: Option<String>,
107}
108
109pub(crate) fn approved_ops_from_env() -> Vec<ApprovedOp> {
113 std::env::var(APPROVED_OPS_ENV)
114 .ok()
115 .and_then(|raw| serde_json::from_str::<Vec<ApprovedOp>>(&raw).ok())
116 .unwrap_or_default()
117}
118
119#[derive(Clone, Debug, Default)]
123pub struct ApprovalConfig {
124 pub approved_indices: Vec<u64>,
125 pub denied_index: Option<u64>,
126}
127
128pub fn approval_config_from_env() -> ApprovalConfig {
132 let approved_indices = std::env::var(APPROVED_INDICES_ENV)
133 .ok()
134 .map(|raw| parse_indices(&raw))
135 .unwrap_or_default();
136 let denied_index = std::env::var(DENIED_INDEX_ENV)
137 .ok()
138 .and_then(|raw| raw.trim().parse::<u64>().ok());
139 ApprovalConfig {
140 approved_indices,
141 denied_index,
142 }
143}
144
145fn parse_indices(raw: &str) -> Vec<u64> {
146 raw.split(',')
147 .filter_map(|part| {
148 let trimmed = part.trim();
149 if trimmed.is_empty() {
150 None
151 } else {
152 trimmed.parse::<u64>().ok()
153 }
154 })
155 .collect()
156}
157
158#[derive(Clone, Debug, Default)]
161pub struct VmOptions {
162 pub global_modules_path: Option<String>,
163 pub mode: ExecMode,
164 pub approval: ApprovalConfig,
165}
166
167fn lua_err(e: mlua::Error) -> anyhow::Error {
168 anyhow::anyhow!("{e}")
169}
170
171fn resolve_policy(
174 explicit: Option<std::sync::Arc<policy::Policy>>,
175) -> Result<Option<std::sync::Arc<policy::Policy>>> {
176 match explicit {
177 Some(policy) => Ok(Some(policy)),
178 None => policy::from_env().map_err(|e| anyhow::anyhow!("{e}")),
179 }
180}
181
182#[allow(dead_code)]
183pub fn create_vm(client: reqwest::Client) -> Result<Lua> {
184 create_vm_configured(client, None, readonly_from_env())
185}
186
187#[allow(dead_code)]
188pub fn create_vm_with_lib_path(client: reqwest::Client, lib_path: String) -> Result<Lua> {
189 create_vm_configured(client, Some(lib_path), readonly_from_env())
190}
191
192#[allow(dead_code)]
193pub fn create_vm_with_paths(
194 client: reqwest::Client,
195 global_modules_path: Option<String>,
196) -> Result<Lua> {
197 create_vm_configured(client, global_modules_path, readonly_from_env())
198}
199
200pub fn create_vm_configured(
201 client: reqwest::Client,
202 global_modules_path: Option<String>,
203 readonly: bool,
204) -> Result<Lua> {
205 let mode = if readonly {
206 ExecMode::ReadOnly
207 } else {
208 ExecMode::Unrestricted
209 };
210 create_vm_with_options(
211 client,
212 VmOptions {
213 global_modules_path,
214 mode,
215 approval: ApprovalConfig::default(),
216 },
217 )
218}
219
220pub fn create_vm_with_options(client: reqwest::Client, options: VmOptions) -> Result<Lua> {
221 create_vm_with_policy(client, options, None)
222}
223
224pub fn create_vm_with_policy(
227 client: reqwest::Client,
228 options: VmOptions,
229 policy: Option<std::sync::Arc<policy::Policy>>,
230) -> Result<Lua> {
231 let VmOptions {
232 global_modules_path,
233 mode,
234 approval,
235 } = options;
236 let libs = StdLib::ALL_SAFE;
237 let lua = Lua::new_with(libs, LuaOptions::default()).map_err(lua_err)?;
238 lua.set_memory_limit(64 * 1024 * 1024).map_err(lua_err)?;
239 let policed = resolve_policy(policy)?;
242 if let Some(policy) = policed.clone() {
243 policy::install(&lua, policy);
244 }
245 sandbox(&lua).map_err(lua_err)?;
246 register_fs_loader(&lua, global_modules_path).map_err(lua_err)?;
247 register_stdlib_loader(&lua).map_err(lua_err)?;
248 builtins::register_all(&lua, client).map_err(lua_err)?;
249 if let Some(policy) = policed.as_ref() {
252 policy::credential::register(&lua, policy).map_err(lua_err)?;
253 policy::apply::apply(&lua).map_err(lua_err)?;
254 }
255 match mode {
256 ExecMode::ReadOnly => builtins::readonly::apply(&lua).map_err(lua_err)?,
257 ExecMode::Approval => builtins::approval::apply(&lua, &approval).map_err(lua_err)?,
258 ExecMode::Unrestricted => {}
259 }
260 apply_global_blocks(&lua).map_err(lua_err)?;
261 Ok(lua)
262}
263
264pub fn apply_global_blocks(lua: &Lua) -> mlua::Result<()> {
289 block_globals_from_env(lua)?;
290 let Some(policy) = policy::active(lua) else {
291 return Ok(());
292 };
293 for name in policy.blocked_globals() {
294 nil_dotted_path(lua, name)?;
295 }
296 Ok(())
297}
298
299fn sandbox(lua: &Lua) -> mlua::Result<()> {
300 let globals = lua.globals();
307 let string_lib: mlua::Table = globals.get("string")?;
308 string_lib.set("dump", mlua::Value::Nil)?;
309 Ok(())
310}
311
312fn block_globals_from_env(lua: &Lua) -> mlua::Result<()> {
314 let Ok(extra) = std::env::var(BLOCK_GLOBALS_ENV) else {
315 return Ok(());
316 };
317 for raw in extra.split(',') {
318 let name = raw.trim();
319 if name.is_empty() {
320 continue;
321 }
322 nil_dotted_path(lua, name)?;
323 }
324 Ok(())
325}
326
327fn nil_dotted_path(lua: &Lua, path: &str) -> mlua::Result<()> {
337 let parts: Vec<&str> = path.split('.').filter(|s| !s.is_empty()).collect();
338 let Some((leaf, prefix)) = parts.split_last() else {
339 return Ok(());
340 };
341 if prefix.is_empty() {
342 lua.globals().set(*leaf, mlua::Value::Nil)?;
343 return clear_package_entry(lua, leaf);
344 }
345 for root in builtins::gated::tables_for(lua, prefix[0])? {
346 let mut current = root;
347 let mut reached = true;
348 for segment in &prefix[1..] {
349 match current.get::<mlua::Value>(*segment)? {
350 mlua::Value::Table(t) => current = t,
351 _ => {
352 reached = false;
353 break;
354 }
355 }
356 }
357 if reached {
358 current.set(*leaf, mlua::Value::Nil)?;
359 }
360 }
361 Ok(())
362}
363
364fn clear_package_entry(lua: &Lua, name: &str) -> mlua::Result<()> {
367 let Some(package) = lua.globals().get::<Option<mlua::Table>>("package")? else {
368 return Ok(());
369 };
370 for registry in ["loaded", "preload"] {
371 if let Some(sub) = package.get::<Option<mlua::Table>>(registry)? {
372 sub.set(name, mlua::Value::Nil)?;
373 }
374 }
375 Ok(())
376}
377
378fn module_candidates(lua: &Lua, module_name: &str) -> mlua::Result<Option<[String; 2]>> {
382 let Some(rest) = module_name.strip_prefix("assay.") else {
383 return Ok(None);
384 };
385 policy::guard_require(lua, module_name)?;
386 let base = rest.replace('.', "/");
387 Ok(Some([format!("{base}.lua"), format!("{base}/init.lua")]))
388}
389
390fn not_an_assay_module(lua: &Lua, module_name: &str) -> mlua::Result<mlua::Value> {
391 Ok(mlua::Value::String(lua.create_string(format!(
392 "not an assay.* module: {module_name}"
393 ))?))
394}
395
396fn register_stdlib_loader(lua: &Lua) -> mlua::Result<()> {
397 let package: mlua::Table = lua.globals().get("package")?;
398 let searchers: mlua::Table = package.get("searchers")?;
399
400 let stdlib_searcher = lua.create_function(|lua, module_name: String| {
406 let candidates = match module_candidates(lua, &module_name)? {
407 Some(c) => c,
408 None => return not_an_assay_module(lua, &module_name),
409 };
410
411 for path in &candidates {
412 if let Some(file) = STDLIB_DIR.get_file(path) {
413 let source = file
414 .contents_utf8()
415 .ok_or_else(|| mlua::Error::runtime(format!("stdlib {path}: invalid UTF-8")))?;
416 let loader = lua
417 .load(source)
418 .set_name(format!("@assay/{path}"))
419 .into_function()?;
420 return Ok(mlua::Value::Function(loader));
421 }
422 }
423
424 Ok(mlua::Value::String(lua.create_string(format!(
425 "no embedded stdlib file: {}",
426 candidates[0]
427 ))?))
428 })?;
429
430 let len = searchers.len()?;
431 searchers.set(len + 1, stdlib_searcher)?;
432
433 Ok(())
434}
435
436fn register_fs_loader(lua: &Lua, global_modules_path: Option<String>) -> mlua::Result<()> {
437 let package: mlua::Table = lua.globals().get("package")?;
438 let searchers: mlua::Table = package.get("searchers")?;
439
440 let fs_searcher = lua.create_function(move |lua, module_name: String| {
443 let candidates = match module_candidates(lua, &module_name)? {
444 Some(c) => c,
445 None => return not_an_assay_module(lua, &module_name),
446 };
447
448 let try_load = |dir: &std::path::Path| -> Option<(std::path::PathBuf, String)> {
449 for rel in &candidates {
450 let full = dir.join(rel);
451 if let Ok(source) = std::fs::read_to_string(&full) {
452 return Some((full, source));
453 }
454 }
455 None
456 };
457
458 if let Some((full, source)) = try_load(std::path::Path::new("./modules")) {
460 let loader = lua
461 .load(source)
462 .set_name(format!("@{}", full.display()))
463 .into_function()?;
464 return Ok(mlua::Value::Function(loader));
465 }
466
467 let global_path = if let Some(ref custom_path) = global_modules_path {
469 std::path::PathBuf::from(custom_path)
470 } else if let Ok(modules_env) = std::env::var(MODULES_PATH_ENV) {
471 std::path::PathBuf::from(modules_env)
472 } else if let Ok(home) = std::env::var("HOME") {
473 std::path::Path::new(&home).join(".assay/modules")
474 } else {
475 std::path::PathBuf::new()
476 };
477
478 if !global_path.as_os_str().is_empty()
479 && let Some((full, source)) = try_load(&global_path)
480 {
481 let loader = lua
482 .load(source)
483 .set_name(format!("@{}", full.display()))
484 .into_function()?;
485 return Ok(mlua::Value::Function(loader));
486 }
487
488 Ok(mlua::Value::Nil)
491 })?;
492
493 let len = searchers.len()?;
494 searchers.set(len + 1, fs_searcher)?;
495
496 Ok(())
497}
498
499pub fn inject_env(lua: &Lua, env: &std::collections::HashMap<String, String>) -> Result<()> {
500 if env.is_empty() {
501 return Ok(());
502 }
503 let globals = lua.globals();
504 let env_table: mlua::Table = globals.get("env").map_err(lua_err)?;
505 let check_env: mlua::Table = env_table.get("_check_env").map_err(lua_err)?;
506 for (k, v) in env {
507 check_env.set(k.as_str(), v.as_str()).map_err(lua_err)?;
508 }
509 Ok(())
510}