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.
148 lines
5.7 KiB
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(())
|
|
}
|
|
|