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.
222 lines
7.5 KiB
222 lines
7.5 KiB
//! A sandboxed `require` for loading Lua modules from files.
|
|
//!
|
|
//! Module names use stock Lua dot notation (`require "db.migrations"`) and are
|
|
//! resolved against a fixed list of root directories — the main script's real
|
|
//! directory (the CWD in the REPL) followed by the entries of
|
|
//! `$A_INCLUDE_PATHS` — through the standard `?.lua` / `?/init.lua` templates.
|
|
//! Because a name may only contain identifier-like segments, a module can
|
|
//! never name a file outside the roots.
|
|
//!
|
|
//! Each file is executed once and its return value cached by canonical path,
|
|
//! so repeated requires — even under different names or through symlinks —
|
|
//! return the same value.
|
|
|
|
use std::cell::RefCell;
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::path::PathBuf;
|
|
use std::rc::Rc;
|
|
|
|
use mlua::prelude::{LuaResult, LuaTable, LuaValue};
|
|
use mlua::Lua;
|
|
|
|
/// Globals pre-seeded into the module cache, so `require "http"` returns the
|
|
/// same table as the global: scripts read more portably and LSPs get an
|
|
/// anchor for the stdlib. Covers the project stdlib plus the stock libraries
|
|
/// the sandbox loads.
|
|
const BUILTINS: &[&str] = &[
|
|
"utils",
|
|
"log",
|
|
"sqlite",
|
|
"http",
|
|
"fs",
|
|
"task",
|
|
"tui",
|
|
"coroutine",
|
|
"math",
|
|
"os",
|
|
"string",
|
|
"table",
|
|
"utf8",
|
|
];
|
|
|
|
/// Strip a leading UTF-8 BOM and a shebang line from Lua source, as stock Lua
|
|
/// does. The shebang's newline is kept so error messages still report correct
|
|
/// line numbers. Used for the main script and for every required file.
|
|
pub(crate) fn prepare_chunk(source: &[u8]) -> &[u8] {
|
|
let source = source.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(source);
|
|
if source.first() == Some(&b'#') {
|
|
let nl = source.iter().position(|&b| b == b'\n').unwrap_or(source.len());
|
|
&source[nl..]
|
|
} else {
|
|
source
|
|
}
|
|
}
|
|
|
|
/// Install the global `require` function, searching `roots` in order.
|
|
pub(crate) fn install(lua: &Lua, roots: Vec<PathBuf>) -> LuaResult<()> {
|
|
// Cache of loaded modules, held Lua-side so the values stay GC-anchored.
|
|
// Built-ins are keyed by name, file modules by canonical path; the key
|
|
// spaces cannot collide because canonical paths are absolute.
|
|
let loaded = lua.create_table()?;
|
|
for name in BUILTINS {
|
|
let value: LuaValue = lua.globals().get(*name)?;
|
|
if !value.is_nil() {
|
|
loaded.set(*name, value)?;
|
|
}
|
|
}
|
|
|
|
let roots = Rc::new(roots);
|
|
// Module name -> canonical path it resolved to; repeated requires of the
|
|
// same name skip the filesystem probing.
|
|
let resolved: Rc<RefCell<HashMap<String, String>>> = Rc::default();
|
|
// Canonical paths of modules currently executing, to catch require cycles.
|
|
let in_flight: Rc<RefCell<HashSet<String>>> = Rc::default();
|
|
|
|
let require = lua.create_async_function(move |lua, name: String| {
|
|
let loaded = loaded.clone();
|
|
let roots = roots.clone();
|
|
let resolved = resolved.clone();
|
|
let in_flight = in_flight.clone();
|
|
async move { require_impl(lua, name, loaded, roots, resolved, in_flight).await }
|
|
})?;
|
|
lua.globals().set("require", require)
|
|
}
|
|
|
|
async fn require_impl(
|
|
lua: Lua,
|
|
name: String,
|
|
loaded: LuaTable,
|
|
roots: Rc<Vec<PathBuf>>,
|
|
resolved: Rc<RefCell<HashMap<String, String>>>,
|
|
in_flight: Rc<RefCell<HashSet<String>>>,
|
|
) -> LuaResult<(LuaValue, Option<String>)> {
|
|
// Built-in module?
|
|
let builtin: LuaValue = loaded.get(name.as_str())?;
|
|
if !builtin.is_nil() {
|
|
return Ok((builtin, None));
|
|
}
|
|
|
|
// A name that already resolved to a loaded file?
|
|
if let Some(key) = resolved.borrow().get(&name).cloned() {
|
|
let value: LuaValue = loaded.get(key.as_str())?;
|
|
if !value.is_nil() {
|
|
return Ok((value, Some(key)));
|
|
}
|
|
}
|
|
|
|
let Some(rel) = module_relpath(&name) else {
|
|
return Err(mlua::Error::runtime(format!(
|
|
"invalid module name '{name}' (expected dot-separated segments of [A-Za-z0-9_-], like 'dir.mod')"
|
|
)));
|
|
};
|
|
|
|
// Probe every root so a hit shadowed by an earlier one can be warned about.
|
|
let mut hits: Vec<PathBuf> = Vec::new();
|
|
let mut misses: Vec<PathBuf> = Vec::new();
|
|
for root in roots.iter() {
|
|
let base = root.join(&rel);
|
|
for candidate in [base.with_extension("lua"), base.join("init.lua")] {
|
|
match tokio::fs::metadata(&candidate).await {
|
|
Ok(meta) if meta.is_file() => hits.push(candidate),
|
|
_ => misses.push(candidate),
|
|
}
|
|
}
|
|
}
|
|
|
|
let Some(found) = hits.first() else {
|
|
let mut msg = format!("module '{name}' not found:");
|
|
for miss in &misses {
|
|
msg.push_str(&format!("\n\tno file '{}'", miss.display()));
|
|
}
|
|
return Err(mlua::Error::runtime(msg));
|
|
};
|
|
for shadowed in &hits[1..] {
|
|
log::warn!(
|
|
"require '{name}': using '{}', ignoring shadowed '{}'",
|
|
found.display(),
|
|
shadowed.display()
|
|
);
|
|
}
|
|
|
|
let real = tokio::fs::canonicalize(found).await.map_err(|e| {
|
|
mlua::Error::runtime(format!(
|
|
"require '{name}': cannot resolve '{}': {e}",
|
|
found.display()
|
|
))
|
|
})?;
|
|
let key = real.display().to_string();
|
|
|
|
// The same file may already be loaded under another name or via a symlink.
|
|
let value: LuaValue = loaded.get(key.as_str())?;
|
|
if !value.is_nil() {
|
|
resolved.borrow_mut().insert(name, key.clone());
|
|
return Ok((value, Some(key)));
|
|
}
|
|
|
|
if !in_flight.borrow_mut().insert(key.clone()) {
|
|
return Err(mlua::Error::runtime(format!(
|
|
"circular require of module '{name}' ('{key}')"
|
|
)));
|
|
}
|
|
// Clears the in-flight marker even when the load errors or is cancelled,
|
|
// so a failed module can be required again.
|
|
let _guard = InFlightGuard {
|
|
set: in_flight.clone(),
|
|
key: key.clone(),
|
|
};
|
|
|
|
let source = tokio::fs::read(&real)
|
|
.await
|
|
.map_err(|e| mlua::Error::runtime(format!("require '{name}': cannot read '{key}': {e}")))?;
|
|
|
|
let value: LuaValue = lua
|
|
.load(prepare_chunk(&source))
|
|
// "@" marks the chunk name as a file path in error messages / tracebacks
|
|
.set_name(format!("@{key}"))
|
|
// like stock Lua, the chunk receives the module name and file path as `...`
|
|
.call_async((name.as_str(), key.as_str()))
|
|
.await?;
|
|
|
|
// A module that returns nothing is cached as `true`, as stock require does.
|
|
let value = if value.is_nil() {
|
|
LuaValue::Boolean(true)
|
|
} else {
|
|
value
|
|
};
|
|
loaded.set(key.as_str(), value.clone())?;
|
|
resolved.borrow_mut().insert(name, key.clone());
|
|
Ok((value, Some(key)))
|
|
}
|
|
|
|
/// Convert a dot-separated module name into a relative path, or `None` when
|
|
/// the name is malformed. Restricting segments to identifier-like characters
|
|
/// is what keeps `require` inside its roots: `..`, `/` and absolute paths are
|
|
/// unrepresentable.
|
|
fn module_relpath(name: &str) -> Option<PathBuf> {
|
|
if name.is_empty() {
|
|
return None;
|
|
}
|
|
let mut rel = PathBuf::new();
|
|
for segment in name.split('.') {
|
|
let ok = !segment.is_empty()
|
|
&& segment
|
|
.bytes()
|
|
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-');
|
|
if !ok {
|
|
return None;
|
|
}
|
|
rel.push(segment);
|
|
}
|
|
Some(rel)
|
|
}
|
|
|
|
struct InFlightGuard {
|
|
set: Rc<RefCell<HashSet<String>>>,
|
|
key: String,
|
|
}
|
|
|
|
impl Drop for InFlightGuard {
|
|
fn drop(&mut self) {
|
|
self.set.borrow_mut().remove(&self.key);
|
|
}
|
|
}
|
|
|