use std::path::{Path, PathBuf}; use clap::Parser; // Lua source embedded in test strings (e.g. `math.round(3.1415, 2)`) trips // clippy's approx_constant lint, which mistakes the literals for `PI`. #[cfg(test)] #[allow(clippy::approx_constant)] mod lua_tests; mod repl; mod sandbox; mod stdlib; /// Runs a Lua script in a customized environment, or an interactive REPL when /// no script path is given. #[derive(Parser, Debug)] #[command(version, about)] struct Args { /// Path to the Lua script to execute. Omit to start an interactive REPL. filepath: Option, /// Grant all filesystem access without prompting or recording anything. #[arg(long, conflicts_with = "no_fs")] allow_fs: bool, /// Deny all filesystem access (fs module, sqlite databases on disk, /// cookie-jar files). #[arg(long)] no_fs: bool, /// Deny filesystem access AND networking; overrides --allow-fs. #[arg(long)] sandbox: bool, } impl Args { /// The fs/network permission policy the flags call for. In the default /// interactive mode, grants recorded for this script (keyed by its /// canonical path) are loaded from access.json; the REPL passes `None` /// and never persists anything. fn fs_policy(&self, script_key: Option) -> stdlib::fs::FsPolicy { use stdlib::fs::{store, FsPolicy}; if self.sandbox { FsPolicy::deny_all("--sandbox", false) } else if self.no_fs { FsPolicy::deny_all("--no-fs", true) } else if self.allow_fs { FsPolicy::allow_all() } else { let store_path = store::default_store_path(); let persisted = match (&script_key, &store_path) { (Some(key), Some(path)) => store::load_for_script(path, key), _ => Default::default(), }; FsPolicy::interactive(script_key, persisted, store_path) } } } #[tokio::main] async fn main() { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); // A panicking runtime must not strand the user on the alternate screen or // with a hidden cursor; restoring first also puts the panic message on // the normal screen. Normal exits are covered below. let default_panic = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { stdlib::tui::cleanup(); default_panic(info); })); let result = run().await; // Restore any terminal state the script left behind (tui.altScreen & co.) // before printing an error, so the message lands on the normal screen. stdlib::tui::cleanup(); if let Err(err) = result { // Display (not Debug) so a Lua error's traceback and multi-line messages // read as intended instead of as one escaped line. eprintln!("{err}"); std::process::exit(1); } } async fn run() -> Result<(), Box> { let args = Args::parse(); let lua = sandbox::create_sandboxed_lua()?; let Some(filepath) = args.filepath.clone() else { // In the REPL, `.env` and modules are looked up from the CWD. // Permissions are prompted for but never persisted (no script // identity to record them under). lua.set_app_data(std::sync::Arc::new(args.fs_policy(None))); setup_require(&lua, &std::env::current_dir()?)?; return repl::run(&lua).await; }; // Resolve symlinks first: installed scripts are typically symlinks (e.g. // from ~/.local/bin), and both `.env` and the script's libraries live next // to the real file. let realpath = tokio::fs::canonicalize(&filepath) .await .map_err(|e| format!("a: cannot open {}: {e}", filepath.display()))?; let script_dir = realpath.parent().unwrap_or(Path::new("/")); setup_require(&lua, script_dir)?; // Exposed to scripts as fs.getScriptDir() — the base for bundled assets. lua.set_app_data(stdlib::fs::ScriptDir(script_dir.to_path_buf())); // The canonical path doubles as the script's identity in the permission // store; this replaces the session-only default planted by fs::install. let script_key = realpath.to_string_lossy().into_owned(); lua.set_app_data(std::sync::Arc::new(args.fs_policy(Some(script_key)))); // Read as bytes, not a UTF-8 String: Lua source (and its string literals) are // byte strings, and stock Lua runs files that aren't valid UTF-8. let source = tokio::fs::read(&realpath) .await .map_err(|e| format!("a: cannot read {}: {e}", filepath.display()))?; lua.load(stdlib::require::prepare_chunk(&source)) // "@" marks the chunk name as a file path in error messages / tracebacks .set_name(format!("@{}", realpath.display())) .exec_async() .await?; Ok(()) } /// Prepare module loading rooted at `dir` — the real directory of the main /// script, or the CWD in the REPL. Loads `dir/.env` into the process /// environment (variables already set in the real environment win), then /// installs `require` searching `dir` followed by the `$A_INCLUDE_PATHS` /// entries (colon-separated, like `$PATH`). fn setup_require(lua: &mlua::Lua, dir: &Path) -> Result<(), Box> { match dotenvy::from_path(dir.join(".env")) { Ok(()) => {} Err(dotenvy::Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(format!("a: cannot load {}: {e}", dir.join(".env").display()).into()), } let mut roots = vec![dir.to_path_buf()]; if let Some(paths) = std::env::var_os("A_INCLUDE_PATHS") { roots.extend(std::env::split_paths(&paths).filter(|p| !p.as_os_str().is_empty())); } stdlib::require::install(lua, roots)?; Ok(()) }