#![expect(
clippy::redundant_pub_crate,
reason = "explicit pub(crate) documents the crate-wide visibility intent at each item"
)]
use std::path::{Path, PathBuf};
use mlua::Value;
use crate::error::{Error, Result};
use crate::sandbox::LanguageSurface;
use crate::types::RequireTarget;
const LOADED_KEY: &str = "airsl.require.loaded";
const REQUIRE: &str = "require";
#[derive(Debug, Clone)]
pub(crate) struct RequireLoader {
root: PathBuf,
}
impl RequireLoader {
pub(crate) fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub(crate) const fn applies_to(surface: LanguageSurface) -> bool {
matches!(surface, LanguageSurface::Restricted)
}
pub(crate) fn install(&self, lua: &mlua::Lua) -> Result<()> {
let fail = |source: mlua::Error| Error::EngineSetup {
stage: "confined require",
source: Box::new(source),
};
if !lua
.named_registry_value::<Value>(LOADED_KEY)
.is_ok_and(|v| v.is_table())
{
let loaded = lua.create_table().map_err(fail)?;
lua.set_named_registry_value(LOADED_KEY, loaded)
.map_err(fail)?;
}
let root = self.root.clone();
let require = lua
.create_function(move |lua, target: mlua::LuaString| {
let target = RequireTarget::new(target.to_str()?.to_owned())?;
load(lua, &root, &target).map_err(mlua::Error::from)
})
.map_err(fail)?;
lua.globals().set(REQUIRE, require).map_err(fail)
}
pub(crate) fn remove(lua: &mlua::Lua) -> Result<()> {
lua.globals()
.set(REQUIRE, Value::Nil)
.map_err(|source| Error::EngineSetup {
stage: "confined require",
source: Box::new(source),
})
}
}
fn load(lua: &mlua::Lua, root: &Path, target: &RequireTarget) -> Result<Value> {
let fail = |source: mlua::Error| Error::lua(target.as_str(), source);
let path = resolve(root, target)?;
let key = path.display().to_string();
let loaded: mlua::Table = lua.named_registry_value(LOADED_KEY).map_err(fail)?;
match loaded.get::<Value>(key.as_str()).map_err(fail)? {
Value::LightUserData(_) => {
return Err(Error::RequireCycle {
module: target.to_string(),
root: root.display().to_string(),
});
}
Value::Nil => {}
cached => return Ok(cached),
}
let in_progress = Value::LightUserData(mlua::LightUserData(std::ptr::null_mut()));
loaded.set(key.as_str(), in_progress).map_err(fail)?;
match run(lua, &path, target) {
Ok(value) => {
let recorded = if matches!(value, Value::Nil) {
Value::Boolean(true)
} else {
value.clone()
};
loaded.set(key.as_str(), recorded).map_err(fail)?;
Ok(value)
}
Err(error) => {
loaded.set(key.as_str(), Value::Nil).map_err(fail)?;
Err(error)
}
}
}
fn run(lua: &mlua::Lua, path: &Path, target: &RequireTarget) -> Result<Value> {
let source = std::fs::read_to_string(path).map_err(|source| Error::ScriptRead {
path: path.display().to_string(),
source,
})?;
lua.load(&source)
.set_name(format!("@{}", path.display()))
.eval::<Value>()
.map_err(|source| Error::lua(target.as_str(), source))
}
fn resolve(root: &Path, target: &RequireTarget) -> Result<PathBuf> {
let root = root.canonicalize().map_err(|_| Error::RequireNotFound {
module: target.to_string(),
root: root.display().to_string(),
})?;
for candidate in target.candidates() {
let Ok(path) = root.join(&candidate).canonicalize() else {
continue;
};
if !path.starts_with(&root) {
return Err(Error::RequireEscape {
module: target.to_string(),
root: root.display().to_string(),
});
}
return Ok(path);
}
Err(Error::RequireNotFound {
module: target.to_string(),
root: root.display().to_string(),
})
}
#[cfg(test)]
mod tests {
#![expect(
clippy::unwrap_used,
reason = "tests unwrap known-valid fixtures; a panic is the intended failure signal"
)]
use super::RequireLoader;
use crate::sandbox::LanguageSurface;
use crate::types::RequireTarget;
use std::io::Write as _;
use std::path::Path;
fn write(dir: &Path, name: &str, body: &str) {
let path = dir.join(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
let mut file = std::fs::File::create(&path).unwrap();
file.write_all(body.as_bytes()).unwrap();
}
fn resolve(root: &Path, target: &str) -> crate::Result<std::path::PathBuf> {
super::resolve(root, &RequireTarget::new(target).unwrap())
}
#[test]
fn only_the_restricted_surface_gets_a_confined_require() {
assert!(RequireLoader::applies_to(LanguageSurface::Restricted));
assert!(!RequireLoader::applies_to(LanguageSurface::Full));
assert!(!RequireLoader::applies_to(LanguageSurface::Minimal));
}
#[test]
fn a_sibling_module_resolves() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "index.lua", "return 1");
assert!(resolve(dir.path(), "index").is_ok());
}
#[test]
fn a_nested_module_resolves_through_its_dotted_name() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "lib/index.lua", "return 1");
assert!(resolve(dir.path(), "lib.index").is_ok());
}
#[test]
fn a_directory_module_resolves_through_init() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "lib/init.lua", "return 1");
assert!(resolve(dir.path(), "lib").is_ok());
}
#[test]
fn a_missing_module_names_the_directory_it_searched() {
let dir = tempfile::tempdir().unwrap();
let err = resolve(dir.path(), "absent").unwrap_err();
assert!(err.to_string().contains("not found"), "{err}");
}
#[test]
fn a_symlink_out_of_the_root_is_refused() {
let outside = tempfile::tempdir().unwrap();
write(outside.path(), "secrets.lua", "return 'leaked'");
let dir = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink(outside.path().join("secrets.lua"), dir.path().join("s.lua"))
.unwrap();
let err = resolve(dir.path(), "s").unwrap_err();
assert!(err.to_string().contains("outside"), "{err}");
}
#[test]
fn a_sibling_directory_sharing_a_name_prefix_is_not_inside_the_root() {
let parent = tempfile::tempdir().unwrap();
let root = parent.path().join("app");
let decoy = parent.path().join("app-extra");
std::fs::create_dir_all(&root).unwrap();
write(&decoy, "m.lua", "return 1");
std::os::unix::fs::symlink(decoy.join("m.lua"), root.join("m.lua")).unwrap();
let err = resolve(&root, "m").unwrap_err();
assert!(err.to_string().contains("outside"), "{err}");
}
}