use std::collections::BTreeSet;
use std::path::Path;
use crate::error::{Error, Result};
use crate::extension::{
Approver, Ceiling, Dispatch, Extension, LoadContext, LoadReport, MANIFEST_FILE,
ManifestApprover, Variables,
};
use crate::modules::ModuleSet;
use crate::types::{EventName, ExtensionName, RootTable};
pub trait ModuleFactory: Send + Sync {
fn modules(&self) -> Result<ModuleSet>;
}
impl<F> ModuleFactory for F
where
F: Fn() -> Result<ModuleSet> + Send + Sync,
{
fn modules(&self) -> Result<ModuleSet> {
self()
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Stdlib;
impl ModuleFactory for Stdlib {
fn modules(&self) -> Result<ModuleSet> {
crate::modules::stdlib()
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct NoCeiling;
#[derive(Debug, Clone)]
pub struct WithCeiling(Ceiling);
#[derive(Debug)]
pub struct HostBuilder<C, A = ManifestApprover, F = Stdlib> {
ceiling: C,
events: BTreeSet<EventName>,
variables: Variables,
approver: A,
root_table: RootTable,
factory: F,
}
impl HostBuilder<NoCeiling> {
pub(crate) fn new() -> Self {
Self {
ceiling: NoCeiling,
events: BTreeSet::new(),
variables: Variables::none(),
approver: ManifestApprover,
root_table: RootTable::default(),
factory: Stdlib,
}
}
}
impl<C, A: Approver, F: ModuleFactory> HostBuilder<C, A, F> {
#[must_use]
pub fn ceiling(self, ceiling: Ceiling) -> HostBuilder<WithCeiling, A, F> {
HostBuilder {
ceiling: WithCeiling(ceiling),
events: self.events,
variables: self.variables,
approver: self.approver,
root_table: self.root_table,
factory: self.factory,
}
}
pub fn events(mut self, events: impl IntoIterator<Item = impl AsRef<str>>) -> Result<Self> {
for name in events {
self.events.insert(EventName::new(name.as_ref())?);
}
Ok(self)
}
#[must_use]
pub fn variables(
mut self,
vars: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
) -> Self {
for (name, value) in vars {
self.variables = self.variables.with(name, value);
}
self
}
#[must_use]
pub fn approver<B: Approver>(self, approver: B) -> HostBuilder<C, B, F> {
HostBuilder {
ceiling: self.ceiling,
events: self.events,
variables: self.variables,
approver,
root_table: self.root_table,
factory: self.factory,
}
}
#[must_use]
pub fn root_table(mut self, root: RootTable) -> Self {
self.root_table = root;
self
}
#[must_use]
pub fn modules<G: ModuleFactory>(self, factory: G) -> HostBuilder<C, A, G> {
HostBuilder {
ceiling: self.ceiling,
events: self.events,
variables: self.variables,
approver: self.approver,
root_table: self.root_table,
factory,
}
}
}
impl<A: Approver, F: ModuleFactory> HostBuilder<WithCeiling, A, F> {
pub fn build(self) -> Result<ExtensionHost<A, F>> {
Ok(ExtensionHost {
ceiling: self.ceiling.0,
events: self.events,
variables: self.variables,
approver: self.approver,
root_table: self.root_table,
factory: self.factory,
extensions: Vec::new(),
})
}
}
#[derive(Debug)]
pub struct ExtensionHost<A: Approver = ManifestApprover, F: ModuleFactory = Stdlib> {
ceiling: Ceiling,
events: BTreeSet<EventName>,
variables: Variables,
approver: A,
root_table: RootTable,
factory: F,
extensions: Vec<Extension>,
}
impl ExtensionHost {
#[must_use]
pub fn builder() -> HostBuilder<NoCeiling> {
HostBuilder::new()
}
}
impl<A: Approver, F: ModuleFactory> ExtensionHost<A, F> {
fn context(&self) -> Result<LoadContext<'_, A>> {
Ok(LoadContext::new(
&self.ceiling,
&self.events,
&self.variables,
&self.approver,
&self.root_table,
self.factory.modules()?,
))
}
pub fn load(&mut self, dir: impl AsRef<Path>) -> Result<&Extension> {
let approved = Extension::approve(dir, self.context()?)?;
if self.get(approved.name()).is_some() {
return Err(Error::DuplicateExtension {
extension: approved.name().to_string(),
});
}
let extension = approved.start()?;
self.extensions.push(extension);
let index = self.extensions.len() - 1;
Ok(&self.extensions[index])
}
pub fn load_dir(&mut self, root: impl AsRef<Path>) -> Result<LoadReport> {
let root = root.as_ref();
let io = |source: std::io::Error| Error::Io {
operation: "read_dir",
path: root.display().to_string(),
source,
};
let mut candidates = Vec::new();
for entry in std::fs::read_dir(root).map_err(io)? {
let path = entry.map_err(io)?.path();
if path.is_dir() && path.join(MANIFEST_FILE).is_file() {
candidates.push(path);
}
}
candidates.sort();
let mut report = LoadReport::default();
for dir in candidates {
match self.load(&dir) {
Ok(extension) => report.record_loaded(extension.name().clone()),
Err(error) => report.record_failed(dir, error),
}
}
Ok(report)
}
#[must_use]
pub fn broadcast(&self, event: &EventName, payload: &serde_json::Value) -> Vec<Dispatch> {
self.extensions
.iter()
.map(|extension| {
Dispatch::new(extension.name().clone(), extension.call(event, payload))
})
.collect()
}
#[must_use]
pub fn get(&self, name: &ExtensionName) -> Option<&Extension> {
self.extensions
.iter()
.find(|extension| extension.name() == name)
}
pub fn extensions(&self) -> impl Iterator<Item = &Extension> {
self.extensions.iter()
}
pub fn events(&self) -> impl Iterator<Item = &EventName> {
self.events.iter()
}
#[must_use]
pub const fn ceiling(&self) -> &Ceiling {
&self.ceiling
}
}
#[cfg(test)]
mod tests {
#![expect(
clippy::unwrap_used,
reason = "tests unwrap known-valid fixtures; a panic is the intended failure signal"
)]
use std::fs;
use serde_json::json;
use tempfile::TempDir;
use super::*;
use crate::extension::approver::{ApprovalRequest, Decision};
use crate::modules::{HostModule, InstallContext, stdlib};
use crate::sandbox::{GrantSet, Policy};
use crate::types::ModuleName;
fn fixture(name: &str, manifest_extra: &str, lua: &str) -> TempDir {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("extension.toml"),
format!(
"[extension]\nname = \"{name}\"\nversion = \"0.1.0\"\nentry = \"main.lua\"\napi = 1\n{manifest_extra}"
),
)
.unwrap();
fs::write(dir.path().join("main.lua"), lua).unwrap();
dir
}
fn host() -> ExtensionHost<ManifestApprover> {
ExtensionHost::builder()
.ceiling(Ceiling::new(Policy::confined()).unwrap())
.events(["ping", "pong"])
.unwrap()
.build()
.unwrap()
}
const ECHO: &str = r#"
airsstack.ext.on("ping", function(p) return { got = p.n } end)
"#;
#[test]
fn build_is_unreachable_without_a_ceiling() {
let _: HostBuilder<NoCeiling> = ExtensionHost::builder();
}
#[test]
fn events_rejects_an_invalid_name() {
let err = ExtensionHost::builder().events(["not valid!"]).unwrap_err();
assert!(matches!(err, Error::InvalidName { .. }), "{err}");
}
#[test]
fn events_are_sorted_deduplicated_and_union_across_calls() {
let host = ExtensionHost::builder()
.ceiling(Ceiling::new(Policy::confined()).unwrap())
.events(["pong", "ping", "ping"])
.unwrap()
.events(["alpha"])
.unwrap()
.build()
.unwrap();
let events: Vec<_> = host.events().map(EventName::as_str).collect();
assert_eq!(events, ["alpha", "ping", "pong"]);
}
#[test]
fn ceiling_returns_the_bound_the_host_was_built_with() {
let ceiling = Ceiling::new(Policy::confined()).unwrap();
let host = ExtensionHost::builder()
.ceiling(ceiling.clone())
.build()
.unwrap();
assert_eq!(host.ceiling(), &ceiling);
}
#[test]
fn extensions_iterates_every_loaded_extension_in_load_order() {
let first = fixture("first", "", "return 1");
let second = fixture("second", "", "return 1");
let mut host = host();
host.load(first.path()).unwrap();
host.load(second.path()).unwrap();
let names: Vec<_> = host
.extensions()
.map(|extension| extension.name().as_str())
.collect();
assert_eq!(names, ["first", "second"]);
}
#[test]
fn load_registers_the_extension_and_get_finds_it() {
let dir = fixture("echo", "", ECHO);
let mut host = host();
host.load(dir.path()).unwrap();
assert!(host.get(&ExtensionName::new("echo").unwrap()).is_some());
}
#[test]
fn loading_the_same_name_twice_is_a_duplicate_and_runs_no_code() {
let first = fixture("dup", "", "return 1");
let second = TempDir::new().unwrap();
let marker = second.path().join("marker");
fs::write(
second.path().join("extension.toml"),
format!(
"[extension]\nname = \"dup\"\nversion = \"0.1.0\"\nentry = \"main.lua\"\napi = 1\n\
[capabilities]\nfs.write = [\"{}\"]\n",
second.path().display()
),
)
.unwrap();
fs::write(
second.path().join("main.lua"),
format!("airsstack.fs.write('{}', 'ran')\n", marker.display()),
)
.unwrap();
let mut host = ExtensionHost::builder()
.ceiling(
Ceiling::new(
Policy::confined()
.with_grants(GrantSet::declared().with_fs(|fs| fs.write(second.path()))),
)
.unwrap(),
)
.build()
.unwrap();
host.load(first.path()).unwrap();
let err = host.load(second.path()).unwrap_err();
assert!(
matches!(&err, Error::DuplicateExtension { extension } if extension == "dup"),
"{err}"
);
assert!(
!marker.exists(),
"the second extension's entry must not have run"
);
}
#[test]
fn load_dir_loads_in_directory_order_and_reports_failures() {
let root = TempDir::new().unwrap();
for name in ["b-good", "a-good"] {
let dir = root.path().join(name);
fs::create_dir(&dir).unwrap();
fs::write(
dir.join("extension.toml"),
format!("[extension]\nname = \"{name}\"\nversion = \"0.1.0\"\nentry = \"main.lua\"\napi = 1\n"),
)
.unwrap();
fs::write(dir.join("main.lua"), "return 1").unwrap();
}
let broken = root.path().join("c-broken");
fs::create_dir(&broken).unwrap();
fs::write(
broken.join("extension.toml"),
"[extension]\nname = \"c-broken\"\nversion = \"0.1.0\"\nentry = \"main.lua\"\napi = 1\n\
[capabilities]\nfs.read = [\"/\"]\n",
)
.unwrap();
fs::write(broken.join("main.lua"), "return 1").unwrap();
let mut host = host();
let report = host.load_dir(root.path()).unwrap();
let loaded: Vec<_> = report.loaded().iter().map(ExtensionName::as_str).collect();
assert_eq!(loaded, ["a-good", "b-good"]);
assert_eq!(report.failed().len(), 1);
let (dir, err) = &report.failed()[0];
assert_eq!(*dir, broken);
assert!(matches!(err, Error::ExtensionDenied { .. }), "{err}");
}
#[test]
fn load_dir_skips_directories_without_a_manifest() {
let root = TempDir::new().unwrap();
let no_manifest = root.path().join("no-manifest");
fs::create_dir(&no_manifest).unwrap();
fs::write(no_manifest.join("main.lua"), "return 1").unwrap();
let mut host = host();
let report = host.load_dir(root.path()).unwrap();
assert!(report.is_clean());
assert!(report.loaded().is_empty());
}
#[test]
fn load_dir_on_a_missing_root_is_io() {
let mut host = host();
let err = host.load_dir("/does/not/exist/for/this/test").unwrap_err();
assert!(
matches!(
&err,
Error::Io {
operation: "read_dir",
..
}
),
"{err}"
);
}
#[test]
fn broadcast_visits_every_extension_in_load_order_and_isolates_a_failure() {
let echo = fixture("echo", "", ECHO);
let erroring = fixture(
"erroring",
"",
r#"airsstack.ext.on("ping", function() error("boom") end)"#,
);
let quiet = fixture("quiet", "", "return 1");
let mut host = host();
host.load(echo.path()).unwrap();
host.load(erroring.path()).unwrap();
host.load(quiet.path()).unwrap();
let results = host.broadcast(&EventName::new("ping").unwrap(), &json!({"n": 3}));
assert_eq!(results.len(), 3);
assert!(
matches!(results[0].result(), Ok(Some(_))),
"{:?}",
results[0].result()
);
assert!(
matches!(results[1].result(), Err(Error::Lua { .. })),
"{:?}",
results[1].result()
);
assert!(
matches!(results[2].result(), Ok(None)),
"{:?}",
results[2].result()
);
}
struct DenyXPrefixed;
impl Approver for DenyXPrefixed {
fn decide(&self, request: &ApprovalRequest<'_>) -> Decision {
if request.manifest().name().as_str().starts_with('x') {
Decision::Deny(String::from("names starting with `x` are refused"))
} else {
Decision::Approve
}
}
}
#[test]
fn a_custom_approver_can_narrow_what_loads() {
let dir = fixture("x-blocked", "", "return 1");
let mut host = ExtensionHost::builder()
.ceiling(Ceiling::new(Policy::confined()).unwrap())
.approver(DenyXPrefixed)
.build()
.unwrap();
let err = host.load(dir.path()).unwrap_err();
assert!(
matches!(&err, Error::ExtensionDenied { detail, .. } if detail.contains('x')),
"{err}"
);
}
#[test]
fn variables_reach_the_manifest() {
let home = TempDir::new().unwrap();
let data = home.path().canonicalize().unwrap().join("data");
fs::create_dir(&data).unwrap();
let dir = fixture(
"var-ext",
"[capabilities]\nfs.read = [\"$HOME_DIR/data\"]\n",
"return 1",
);
let mut host = ExtensionHost::builder()
.ceiling(
Ceiling::new(
Policy::confined()
.with_grants(GrantSet::declared().with_fs(|fs| fs.read(&data))),
)
.unwrap(),
)
.variables([("HOME_DIR", home.path().to_str().unwrap())])
.build()
.unwrap();
let extension = host.load(dir.path()).unwrap();
assert!(
extension
.granted()
.grants()
.fs()
.allows_read(&data.join("notes.txt"))
);
}
struct Probe(ModuleName);
impl Probe {
fn new() -> Self {
Self(ModuleName::new("probe").unwrap())
}
}
impl HostModule for Probe {
fn name(&self) -> &ModuleName {
&self.0
}
fn install(
&self,
_lua: &mlua::Lua,
_table: &mlua::Table,
_context: &InstallContext<'_>,
) -> Result<()> {
Ok(())
}
}
#[test]
fn a_custom_module_factory_is_used() {
let dir = fixture("probe-ext", "[capabilities]\nprobe = true\n", "return 1");
let mut host = ExtensionHost::builder()
.ceiling(Ceiling::new(Policy::confined()).unwrap())
.modules(|| {
let mut modules = stdlib()?;
modules.insert(Box::new(Probe::new()))?;
Ok(modules)
})
.build()
.unwrap();
assert!(host.load(dir.path()).is_ok());
}
#[test]
fn host_is_send_and_sync() {
const fn assert<T: Send + Sync>() {}
assert::<ExtensionHost>();
}
}