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 Ok(lua)
261}
262
263fn sandbox(lua: &Lua) -> mlua::Result<()> {
264 let globals = lua.globals();
271 let string_lib: mlua::Table = globals.get("string")?;
272 string_lib.set("dump", mlua::Value::Nil)?;
273
274 if let Ok(extra) = std::env::var(BLOCK_GLOBALS_ENV) {
275 for raw in extra.split(',') {
276 let name = raw.trim();
277 if name.is_empty() {
278 continue;
279 }
280 nil_dotted_path(lua, name)?;
281 }
282 }
283
284 Ok(())
285}
286
287fn nil_dotted_path(lua: &Lua, path: &str) -> mlua::Result<()> {
293 let parts: Vec<&str> = path.split('.').filter(|s| !s.is_empty()).collect();
294 if parts.is_empty() {
295 return Ok(());
296 }
297 let mut current: mlua::Table = lua.globals();
298 for segment in &parts[..parts.len() - 1] {
299 let next: mlua::Value = current.get(*segment)?;
300 match next {
301 mlua::Value::Table(t) => current = t,
302 _ => return Ok(()),
303 }
304 }
305 current.set(parts[parts.len() - 1], mlua::Value::Nil)
306}
307
308fn module_candidates(lua: &Lua, module_name: &str) -> mlua::Result<Option<[String; 2]>> {
312 let Some(rest) = module_name.strip_prefix("assay.") else {
313 return Ok(None);
314 };
315 policy::guard_require(lua, module_name)?;
316 let base = rest.replace('.', "/");
317 Ok(Some([format!("{base}.lua"), format!("{base}/init.lua")]))
318}
319
320fn not_an_assay_module(lua: &Lua, module_name: &str) -> mlua::Result<mlua::Value> {
321 Ok(mlua::Value::String(lua.create_string(format!(
322 "not an assay.* module: {module_name}"
323 ))?))
324}
325
326fn register_stdlib_loader(lua: &Lua) -> mlua::Result<()> {
327 let package: mlua::Table = lua.globals().get("package")?;
328 let searchers: mlua::Table = package.get("searchers")?;
329
330 let stdlib_searcher = lua.create_function(|lua, module_name: String| {
336 let candidates = match module_candidates(lua, &module_name)? {
337 Some(c) => c,
338 None => return not_an_assay_module(lua, &module_name),
339 };
340
341 for path in &candidates {
342 if let Some(file) = STDLIB_DIR.get_file(path) {
343 let source = file
344 .contents_utf8()
345 .ok_or_else(|| mlua::Error::runtime(format!("stdlib {path}: invalid UTF-8")))?;
346 let loader = lua
347 .load(source)
348 .set_name(format!("@assay/{path}"))
349 .into_function()?;
350 return Ok(mlua::Value::Function(loader));
351 }
352 }
353
354 Ok(mlua::Value::String(lua.create_string(format!(
355 "no embedded stdlib file: {}",
356 candidates[0]
357 ))?))
358 })?;
359
360 let len = searchers.len()?;
361 searchers.set(len + 1, stdlib_searcher)?;
362
363 Ok(())
364}
365
366fn register_fs_loader(lua: &Lua, global_modules_path: Option<String>) -> mlua::Result<()> {
367 let package: mlua::Table = lua.globals().get("package")?;
368 let searchers: mlua::Table = package.get("searchers")?;
369
370 let fs_searcher = lua.create_function(move |lua, module_name: String| {
373 let candidates = match module_candidates(lua, &module_name)? {
374 Some(c) => c,
375 None => return not_an_assay_module(lua, &module_name),
376 };
377
378 let try_load = |dir: &std::path::Path| -> Option<(std::path::PathBuf, String)> {
379 for rel in &candidates {
380 let full = dir.join(rel);
381 if let Ok(source) = std::fs::read_to_string(&full) {
382 return Some((full, source));
383 }
384 }
385 None
386 };
387
388 if let Some((full, source)) = try_load(std::path::Path::new("./modules")) {
390 let loader = lua
391 .load(source)
392 .set_name(format!("@{}", full.display()))
393 .into_function()?;
394 return Ok(mlua::Value::Function(loader));
395 }
396
397 let global_path = if let Some(ref custom_path) = global_modules_path {
399 std::path::PathBuf::from(custom_path)
400 } else if let Ok(modules_env) = std::env::var(MODULES_PATH_ENV) {
401 std::path::PathBuf::from(modules_env)
402 } else if let Ok(home) = std::env::var("HOME") {
403 std::path::Path::new(&home).join(".assay/modules")
404 } else {
405 std::path::PathBuf::new()
406 };
407
408 if !global_path.as_os_str().is_empty()
409 && let Some((full, source)) = try_load(&global_path)
410 {
411 let loader = lua
412 .load(source)
413 .set_name(format!("@{}", full.display()))
414 .into_function()?;
415 return Ok(mlua::Value::Function(loader));
416 }
417
418 Ok(mlua::Value::Nil)
421 })?;
422
423 let len = searchers.len()?;
424 searchers.set(len + 1, fs_searcher)?;
425
426 Ok(())
427}
428
429pub fn inject_env(lua: &Lua, env: &std::collections::HashMap<String, String>) -> Result<()> {
430 if env.is_empty() {
431 return Ok(());
432 }
433 let globals = lua.globals();
434 let env_table: mlua::Table = globals.get("env").map_err(lua_err)?;
435 let check_env: mlua::Table = env_table.get("_check_env").map_err(lua_err)?;
436 for (k, v) in env {
437 check_env.set(k.as_str(), v.as_str()).map_err(lua_err)?;
438 }
439 Ok(())
440}