Skip to main content

assay/lua/
mod.rs

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
15/// Environment variable to override the global module search path.
16pub const MODULES_PATH_ENV: &str = "ASSAY_MODULES_PATH";
17
18/// Comma-separated list of additional globals to nil out at VM
19/// creation time (defense-in-depth knob for hardened deployments).
20/// Names support dotted paths into stdlib tables — e.g.
21/// `ASSAY_BLOCK_GLOBALS=dofile,os.execute,debug.getinfo`.
22pub const BLOCK_GLOBALS_ENV: &str = "ASSAY_BLOCK_GLOBALS";
23
24/// Set to `1` or `true` to activate read-only mode for every VM the
25/// process creates. Mutating builtins stay registered but raise
26/// `readonly: <name> blocked` errors instead of executing. The
27/// `--readonly` CLI flag activates the same mode per invocation.
28pub 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
37fn lua_err(e: mlua::Error) -> anyhow::Error {
38    anyhow::anyhow!("{e}")
39}
40
41#[allow(dead_code)]
42pub fn create_vm(client: reqwest::Client) -> Result<Lua> {
43    create_vm_configured(client, None, readonly_from_env())
44}
45
46#[allow(dead_code)]
47pub fn create_vm_with_lib_path(client: reqwest::Client, lib_path: String) -> Result<Lua> {
48    create_vm_configured(client, Some(lib_path), readonly_from_env())
49}
50
51#[allow(dead_code)]
52pub fn create_vm_with_paths(
53    client: reqwest::Client,
54    global_modules_path: Option<String>,
55) -> Result<Lua> {
56    create_vm_configured(client, global_modules_path, readonly_from_env())
57}
58
59pub fn create_vm_configured(
60    client: reqwest::Client,
61    global_modules_path: Option<String>,
62    readonly: bool,
63) -> Result<Lua> {
64    let libs = StdLib::ALL_SAFE;
65    let lua = Lua::new_with(libs, LuaOptions::default()).map_err(lua_err)?;
66    lua.set_memory_limit(64 * 1024 * 1024).map_err(lua_err)?;
67    sandbox(&lua).map_err(lua_err)?;
68    register_fs_loader(&lua, global_modules_path).map_err(lua_err)?;
69    register_stdlib_loader(&lua).map_err(lua_err)?;
70    builtins::register_all(&lua, client).map_err(lua_err)?;
71    if readonly {
72        builtins::readonly::apply(&lua).map_err(lua_err)?;
73    }
74    Ok(lua)
75}
76
77fn sandbox(lua: &Lua) -> mlua::Result<()> {
78    // Block bytecode-level escape hatches only. Source-level loaders
79    // (`load` / `loadfile` / `dofile`) stay available — operator scripts
80    // are trusted to compose themselves out of multiple files (seed +
81    // init bootstraps, shared helpers, etc.). `string.dump` stays
82    // blocked because it produces native bytecode that defeats the
83    // memory/CPU caps the runtime relies on.
84    let globals = lua.globals();
85    let string_lib: mlua::Table = globals.get("string")?;
86    string_lib.set("dump", mlua::Value::Nil)?;
87
88    if let Ok(extra) = std::env::var(BLOCK_GLOBALS_ENV) {
89        for raw in extra.split(',') {
90            let name = raw.trim();
91            if name.is_empty() {
92                continue;
93            }
94            nil_dotted_path(lua, name)?;
95        }
96    }
97
98    Ok(())
99}
100
101/// Resolve a dotted Lua path (e.g. `"os.execute"` or `"debug.getinfo"`)
102/// against globals and set the leaf to nil. A bare name (e.g.
103/// `"dofile"`) clears it from `_G`. Missing intermediate tables are
104/// silently skipped so a typo in `ASSAY_BLOCK_GLOBALS` doesn't fail
105/// VM creation.
106fn nil_dotted_path(lua: &Lua, path: &str) -> mlua::Result<()> {
107    let parts: Vec<&str> = path.split('.').filter(|s| !s.is_empty()).collect();
108    if parts.is_empty() {
109        return Ok(());
110    }
111    let mut current: mlua::Table = lua.globals();
112    for segment in &parts[..parts.len() - 1] {
113        let next: mlua::Value = current.get(*segment)?;
114        match next {
115            mlua::Value::Table(t) => current = t,
116            _ => return Ok(()),
117        }
118    }
119    current.set(parts[parts.len() - 1], mlua::Value::Nil)
120}
121
122fn register_stdlib_loader(lua: &Lua) -> mlua::Result<()> {
123    let package: mlua::Table = lua.globals().get("package")?;
124    let searchers: mlua::Table = package.get("searchers")?;
125
126    // Resolves `require("assay.ory.kratos")` -> "ory/kratos.lua" by replacing
127    // dots with slashes, matching standard Lua package loading convention.
128    // Tries "<path>.lua" first, then falls back to "<path>/init.lua" so
129    // both `stdlib/ory.lua` (flat convenience wrapper) and
130    // `stdlib/ory/kratos.lua` (nested submodule) resolve correctly.
131    let stdlib_searcher = lua.create_function(|lua, module_name: String| {
132        let rest = match module_name.strip_prefix("assay.") {
133            Some(r) => r,
134            None => {
135                return Ok(mlua::Value::String(
136                    lua.create_string(format!("not an assay.* module: {module_name}"))?,
137                ));
138            }
139        };
140
141        let base = rest.replace('.', "/");
142        let candidates = [format!("{base}.lua"), format!("{base}/init.lua")];
143
144        for path in &candidates {
145            if let Some(file) = STDLIB_DIR.get_file(path) {
146                let source = file
147                    .contents_utf8()
148                    .ok_or_else(|| mlua::Error::runtime(format!("stdlib {path}: invalid UTF-8")))?;
149                let loader = lua
150                    .load(source)
151                    .set_name(format!("@assay/{path}"))
152                    .into_function()?;
153                return Ok(mlua::Value::Function(loader));
154            }
155        }
156
157        Ok(mlua::Value::String(lua.create_string(format!(
158            "no embedded stdlib file: {}",
159            candidates[0]
160        ))?))
161    })?;
162
163    let len = searchers.len()?;
164    searchers.set(len + 1, stdlib_searcher)?;
165
166    Ok(())
167}
168
169fn register_fs_loader(lua: &Lua, global_modules_path: Option<String>) -> mlua::Result<()> {
170    let package: mlua::Table = lua.globals().get("package")?;
171    let searchers: mlua::Table = package.get("searchers")?;
172
173    // Same dotted-path resolution as the stdlib loader: `assay.ory.kratos`
174    // -> "ory/kratos.lua", falling back to "ory/kratos/init.lua".
175    let fs_searcher = lua.create_function(move |lua, module_name: String| {
176        let rest = match module_name.strip_prefix("assay.") {
177            Some(r) => r,
178            None => {
179                return Ok(mlua::Value::String(
180                    lua.create_string(format!("not an assay.* module: {module_name}"))?,
181                ));
182            }
183        };
184        let base = rest.replace('.', "/");
185        let candidates = [format!("{base}.lua"), format!("{base}/init.lua")];
186
187        let try_load = |dir: &std::path::Path| -> Option<(std::path::PathBuf, String)> {
188            for rel in &candidates {
189                let full = dir.join(rel);
190                if let Ok(source) = std::fs::read_to_string(&full) {
191                    return Some((full, source));
192                }
193            }
194            None
195        };
196
197        // Priority 1: ./modules/<path>.lua (per-project)
198        if let Some((full, source)) = try_load(std::path::Path::new("./modules")) {
199            let loader = lua
200                .load(source)
201                .set_name(format!("@{}", full.display()))
202                .into_function()?;
203            return Ok(mlua::Value::Function(loader));
204        }
205
206        // Priority 2: $ASSAY_MODULES_PATH or ~/.assay/modules/<path>.lua
207        let global_path = if let Some(ref custom_path) = global_modules_path {
208            std::path::PathBuf::from(custom_path)
209        } else if let Ok(modules_env) = std::env::var(MODULES_PATH_ENV) {
210            std::path::PathBuf::from(modules_env)
211        } else if let Ok(home) = std::env::var("HOME") {
212            std::path::Path::new(&home).join(".assay/modules")
213        } else {
214            std::path::PathBuf::new()
215        };
216
217        if !global_path.as_os_str().is_empty()
218            && let Some((full, source)) = try_load(&global_path)
219        {
220            let loader = lua
221                .load(source)
222                .set_name(format!("@{}", full.display()))
223                .into_function()?;
224            return Ok(mlua::Value::Function(loader));
225        }
226
227        // Priority 3: Built-in modules are handled by register_stdlib_loader
228        // Return nil to fall through to the next searcher
229        Ok(mlua::Value::Nil)
230    })?;
231
232    let len = searchers.len()?;
233    searchers.set(len + 1, fs_searcher)?;
234
235    Ok(())
236}
237
238pub fn inject_env(lua: &Lua, env: &std::collections::HashMap<String, String>) -> Result<()> {
239    if env.is_empty() {
240        return Ok(());
241    }
242    let globals = lua.globals();
243    let env_table: mlua::Table = globals.get("env").map_err(lua_err)?;
244    let check_env: mlua::Table = env_table.get("_check_env").map_err(lua_err)?;
245    for (k, v) in env {
246        check_env.set(k.as_str(), v.as_str()).map_err(lua_err)?;
247    }
248    Ok(())
249}