use std::collections::BTreeSet;
use std::path::PathBuf;
use crate::error::{Error, Result};
use crate::modules::{HostModule, InstallContext};
use crate::sandbox::{GrantSet, ResourceLimits};
use crate::types::{EventName, ModuleName};
pub(crate) const HANDLERS_KEY: &str = "airsl.ext.handlers";
#[derive(Debug)]
pub struct Ext {
name: ModuleName,
events: BTreeSet<EventName>,
}
impl Ext {
#[must_use]
pub fn new() -> Self {
Self::with_events([])
}
#[must_use]
pub fn with_events(events: impl IntoIterator<Item = EventName>) -> Self {
Self {
name: ModuleName::new("ext")
.unwrap_or_else(|_| unreachable!("`ext` is a valid module name")),
events: events.into_iter().collect(),
}
}
}
impl Default for Ext {
fn default() -> Self {
Self::new()
}
}
impl HostModule for Ext {
fn name(&self) -> &ModuleName {
&self.name
}
fn install(
&self,
lua: &mlua::Lua,
table: &mlua::Table,
context: &InstallContext<'_>,
) -> Result<()> {
let fail = |e: mlua::Error| Error::ModuleInstall {
module: String::from("ext"),
reason: e.to_string(),
};
let handlers = lua.create_table().map_err(fail)?;
lua.set_named_registry_value(HANDLERS_KEY, handlers)
.map_err(fail)?;
let declared: Vec<String> = self.events.iter().map(ToString::to_string).collect();
let accepted = self.events.clone();
let on = lua
.create_function(
move |lua, (event, handler): (mlua::LuaString, mlua::Function)| {
let raw = event.to_str()?.to_owned();
let name = EventName::new(raw.clone())?;
if !accepted.contains(&name) {
return Err(mlua::Error::from(Error::UnknownEvent {
event: raw,
declared: declared.clone(),
}));
}
let handlers: mlua::Table = lua.named_registry_value(HANDLERS_KEY)?;
handlers.set(name.as_str(), handler)
},
)
.map_err(fail)?;
table.set("on", on).map_err(fail)?;
let snapshot =
granted_table(lua, context.grants(), context.policy().limits()).map_err(fail)?;
lua.set_named_registry_value(GRANTED_KEY, snapshot)
.map_err(fail)?;
let granted = lua
.create_function(|lua, ()| {
let snapshot: mlua::Table = lua.named_registry_value(GRANTED_KEY)?;
deep_copy(lua, &snapshot)
})
.map_err(fail)?;
table.set("granted", granted).map_err(fail)?;
Ok(())
}
}
const GRANTED_KEY: &str = "airsl.ext.granted";
fn sorted_roots(roots: &[PathBuf]) -> Vec<String> {
let mut rendered: Vec<String> = roots
.iter()
.map(|path| path.to_string_lossy().into_owned())
.collect();
rendered.sort();
rendered
}
fn granted_table(
lua: &mlua::Lua,
grants: &GrantSet,
limits: &ResourceLimits,
) -> mlua::Result<mlua::Table> {
let root = lua.create_table()?;
let fs = lua.create_table()?;
let read = lua.create_table()?;
for (i, path) in sorted_roots(grants.fs().read_roots())
.into_iter()
.enumerate()
{
read.set(i + 1, path)?;
}
let write = lua.create_table()?;
for (i, path) in sorted_roots(grants.fs().write_roots())
.into_iter()
.enumerate()
{
write.set(i + 1, path)?;
}
fs.set("read", read)?;
fs.set("write", write)?;
root.set("fs", fs)?;
let env = lua.create_table()?;
for name in grants.env().names() {
env.set(name, true)?;
}
root.set("env", env)?;
let proc = lua.create_table()?;
let run = lua.create_table()?;
for program in grants.proc().executables() {
run.set(program, true)?;
}
proc.set("run", run)?;
root.set("proc", proc)?;
let ceilings = lua.create_table()?;
if let Some(memory) = limits.memory() {
ceilings.set("memory", memory.get())?;
}
if let Some(instructions) = limits.instructions() {
ceilings.set("instructions", instructions.get())?;
}
root.set("limits", ceilings)?;
root.set("unrestricted", grants.is_unrestricted())?;
Ok(root)
}
fn deep_copy(lua: &mlua::Lua, source: &mlua::Table) -> mlua::Result<mlua::Table> {
let target = lua.create_table()?;
for pair in source.pairs::<mlua::Value, mlua::Value>() {
let (key, value) = pair?;
let value = match value {
mlua::Value::Table(inner) => mlua::Value::Table(deep_copy(lua, &inner)?),
other => other,
};
target.set(key, value)?;
}
Ok(target)
}
#[cfg(test)]
mod tests {
#![expect(
clippy::unwrap_used,
reason = "tests unwrap known-valid fixtures; a panic is the intended failure signal"
)]
use super::Ext;
use crate::modules::stdlib;
use crate::{Engine, EventName, GrantSet, Policy, Script};
fn engine_with_events(events: &[&str]) -> Engine {
let mut set = stdlib().unwrap();
set.replace(Box::new(Ext::with_events(
events.iter().map(|e| EventName::new(*e).unwrap()),
)))
.unwrap();
Engine::builder()
.policy(Policy::confined())
.stdlib(set)
.build()
.unwrap()
}
fn run(engine: &Engine, source: &str) -> crate::Result<String> {
engine.eval_to::<String>(&Script::from_source(source, "t").unwrap())
}
#[test]
fn the_module_is_installed_under_every_preset() {
for policy in [Policy::trusted(), Policy::confined(), Policy::pure()] {
let engine = Engine::builder().policy(policy).build().unwrap();
assert_eq!(
run(&engine, "return type(airsstack.ext.on)").unwrap(),
"function"
);
}
}
#[test]
fn on_accepts_a_declared_event() {
let engine = engine_with_events(&["note_saved"]);
assert!(
run(
&engine,
"airsstack.ext.on('note_saved', function() end) return 'ok'"
)
.is_ok()
);
}
#[test]
fn on_refuses_an_undeclared_event_and_names_the_declared_ones() {
let engine = engine_with_events(&["note_saved", "query"]);
let err = run(&engine, "airsstack.ext.on('tpyo', function() end)").unwrap_err();
let text = err.to_string();
assert!(text.contains("`tpyo`"), "{text}");
assert!(text.contains("note_saved, query"), "{text}");
}
#[test]
fn on_under_the_default_stdlib_says_the_runtime_dispatches_no_events() {
let engine = Engine::builder()
.policy(Policy::confined())
.build()
.unwrap();
let err = run(&engine, "airsstack.ext.on('anything', function() end)").unwrap_err();
assert!(
err.to_string()
.contains("this runtime dispatches no events"),
"{err}"
);
}
#[test]
fn on_refuses_a_malformed_event_name() {
let engine = engine_with_events(&["note_saved"]);
let err = run(&engine, "airsstack.ext.on('Note-Saved', function() end)").unwrap_err();
assert!(err.to_string().contains("event name"), "{err}");
}
#[test]
fn granted_reports_the_install_time_grants() {
let dir = tempfile::tempdir().unwrap();
let policy = Policy::confined().with_grants(
GrantSet::declared()
.with_fs(|fs| fs.read(dir.path()))
.with_env(|env| env.read(["APP_HOME"]))
.with_proc(|proc| proc.allow(["git"])),
);
let engine = Engine::builder().policy(policy).build().unwrap();
let found = run(
&engine,
"local g = airsstack.ext.granted() \
return table.concat({#g.fs.read, #g.fs.write, tostring(g.env.APP_HOME), \
tostring(g.proc.run.git), tostring(g.limits.memory ~= nil)}, ',')",
)
.unwrap();
assert_eq!(found, "1,0,true,true,true");
}
#[test]
fn granted_lists_fs_roots_in_sorted_order_rather_than_insertion_order() {
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
let (later, earlier) = if first.path() < second.path() {
(second.path(), first.path())
} else {
(first.path(), second.path())
};
let policy = Policy::confined().with_grants(
GrantSet::declared()
.with_fs(|fs| fs.read(later).read(earlier).write(later).write(earlier)),
);
let engine = Engine::builder().policy(policy).build().unwrap();
let found = run(
&engine,
"local g = airsstack.ext.granted() \
return tostring(g.fs.read[1] < g.fs.read[2]) .. ',' .. \
tostring(g.fs.write[1] < g.fs.write[2])",
)
.unwrap();
assert_eq!(found, "true,true");
}
#[test]
fn granted_returns_a_copy_each_time() {
let engine = Engine::builder()
.policy(Policy::confined())
.build()
.unwrap();
let found = run(
&engine,
"airsstack.ext.granted().env.INJECTED = true \
return tostring(airsstack.ext.granted().env.INJECTED)",
)
.unwrap();
assert_eq!(found, "nil");
}
#[test]
fn granted_omits_a_ceiling_that_is_unlimited() {
let engine = Engine::builder().policy(Policy::trusted()).build().unwrap();
let found = run(
&engine,
"local g = airsstack.ext.granted() \
return tostring(g.limits.memory) .. ',' .. tostring(g.unrestricted)",
)
.unwrap();
assert_eq!(found, "nil,true");
}
}