Lua runner with rich builtin stdlib
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
a/src/main.rs

148 lines
5.7 KiB

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<PathBuf>,
/// 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<String>) -> 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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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(())
}