use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use crate::engine::Engine;
use crate::error::{Error, Result};
use crate::extension::{
ApprovalRequest, Approver, Ceiling, Decision, Manifest, Negotiation, Variables, negotiate,
};
use crate::modules::ModuleSet;
use crate::modules::ext::Ext;
use crate::sandbox::Policy;
use crate::script::Script;
use crate::types::{EventName, ExtensionName, RootTable};
pub struct LoadContext<'a, A: Approver> {
ceiling: &'a Ceiling,
events: &'a BTreeSet<EventName>,
variables: &'a Variables,
approver: &'a A,
root_table: &'a RootTable,
modules: ModuleSet,
}
impl<'a, A: Approver> LoadContext<'a, A> {
#[must_use]
pub const fn new(
ceiling: &'a Ceiling,
events: &'a BTreeSet<EventName>,
variables: &'a Variables,
approver: &'a A,
root_table: &'a RootTable,
modules: ModuleSet,
) -> Self {
Self {
ceiling,
events,
variables,
approver,
root_table,
modules,
}
}
}
pub struct Approved<'a, A: Approver> {
dir: PathBuf,
manifest: Manifest,
negotiation: Negotiation,
context: LoadContext<'a, A>,
}
impl<A: Approver> Approved<'_, A> {
#[must_use]
pub const fn name(&self) -> &ExtensionName {
self.manifest.name()
}
pub fn start(self) -> Result<Extension> {
let Self {
dir,
manifest,
negotiation,
mut context,
} = self;
let entry = Self::recheck_entry(&dir, manifest.entry())?;
context
.modules
.replace(Box::new(Ext::with_events(context.events.iter().cloned())))?;
let engine = Engine::builder()
.policy(negotiation.policy().clone())
.root_table(context.root_table.clone())
.stdlib(context.modules)
.build()?;
let script = Script::from_file(&entry)?
.with_root(&dir)
.with_name(format!(
"{}/{}",
manifest.name(),
manifest.entry().display()
))?;
engine.eval(&script)?;
Ok(Extension {
dir,
manifest,
negotiation,
engine,
})
}
fn recheck_entry(dir: &Path, entry: &Path) -> Result<PathBuf> {
let invalid = |reason: String| Error::ManifestInvalid {
field: "extension.entry",
reason,
};
let root = dir
.canonicalize()
.map_err(|e| invalid(format!("{}: {e}", dir.display())))?;
let full = dir.join(entry);
let resolved = full
.canonicalize()
.map_err(|e| invalid(format!("{}: {e}", full.display())))?;
if !resolved.starts_with(&root) {
return Err(invalid(format!(
"`{}` resolves outside the extension directory",
entry.display()
)));
}
Ok(resolved)
}
}
#[derive(Debug)]
pub struct Extension {
dir: PathBuf,
manifest: Manifest,
negotiation: Negotiation,
engine: Engine,
}
impl Extension {
pub fn approve<A: Approver>(
dir: impl AsRef<Path>,
context: LoadContext<'_, A>,
) -> Result<Approved<'_, A>> {
let dir = dir.as_ref().to_path_buf();
let manifest = Manifest::from_dir(&dir, context.variables)?;
let negotiation = negotiate(&manifest, context.ceiling, &context.modules);
if !negotiation.denied().is_empty() {
let detail = negotiation
.denied()
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("; ");
return Err(Error::ExtensionDenied {
extension: manifest.name().to_string(),
detail,
});
}
let request = ApprovalRequest::new(&dir, &manifest, &negotiation);
if let Decision::Deny(reason) = context.approver.decide(&request) {
return Err(Error::ExtensionDenied {
extension: manifest.name().to_string(),
detail: reason,
});
}
Ok(Approved {
dir,
manifest,
negotiation,
context,
})
}
pub fn load<A: Approver>(dir: impl AsRef<Path>, context: LoadContext<'_, A>) -> Result<Self> {
Self::approve(dir, context)?.start()
}
pub fn call(
&self,
event: &EventName,
payload: &serde_json::Value,
) -> Result<Option<serde_json::Value>> {
self.engine.dispatch(event, payload)
}
#[must_use]
pub const fn name(&self) -> &ExtensionName {
self.manifest.name()
}
#[must_use]
pub const fn manifest(&self) -> &Manifest {
&self.manifest
}
#[must_use]
pub const fn granted(&self) -> &Policy {
self.negotiation.policy()
}
#[must_use]
pub const fn report(&self) -> &Negotiation {
&self.negotiation
}
#[must_use]
pub fn dir(&self) -> &Path {
&self.dir
}
}
#[cfg(test)]
mod tests {
#![expect(
clippy::unwrap_used,
reason = "tests unwrap known-valid fixtures; a panic is the intended failure signal"
)]
use std::collections::BTreeSet;
use std::fs;
use serde_json::json;
use tempfile::TempDir;
use super::*;
use crate::extension::{DenyAll, ManifestApprover};
use crate::modules::stdlib;
use crate::sandbox::{GrantSet, InstructionLimit, MemoryLimit, Policy, ResourceLimits};
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 events(names: &[&str]) -> BTreeSet<EventName> {
names.iter().map(|n| EventName::new(*n).unwrap()).collect()
}
fn context<'a, A: Approver>(
ceiling: &'a Ceiling,
events: &'a BTreeSet<EventName>,
variables: &'a Variables,
approver: &'a A,
root: &'a RootTable,
) -> LoadContext<'a, A> {
LoadContext::new(
ceiling,
events,
variables,
approver,
root,
stdlib().unwrap(),
)
}
const ECHO: &str = r#"
airsstack.ext.on("ping", function(p) return { got = p.n } end)
"#;
#[test]
fn a_clean_extension_loads_and_answers_a_call() {
let dir = fixture("echo", "", ECHO);
let ceiling = Ceiling::new(Policy::confined()).unwrap();
let events = events(&["ping"]);
let variables = Variables::none();
let approver = ManifestApprover;
let root = RootTable::default();
let ext = Extension::load(
dir.path(),
context(&ceiling, &events, &variables, &approver, &root),
)
.unwrap();
let result = ext
.call(&EventName::new("ping").unwrap(), &json!({"n": 3}))
.unwrap();
assert_eq!(result, Some(json!({"got": 3})));
}
#[test]
fn an_event_with_no_handler_answers_none() {
let dir = fixture("echo", "", ECHO);
let ceiling = Ceiling::new(Policy::confined()).unwrap();
let events = events(&["ping", "pong"]);
let variables = Variables::none();
let approver = ManifestApprover;
let root = RootTable::default();
let ext = Extension::load(
dir.path(),
context(&ceiling, &events, &variables, &approver, &root),
)
.unwrap();
let result = ext
.call(&EventName::new("pong").unwrap(), &json!({}))
.unwrap();
assert_eq!(result, None);
}
#[test]
fn a_required_capability_outside_the_ceiling_is_denied_before_any_engine_exists() {
let dir = TempDir::new().unwrap();
let marker = dir.path().join("marker");
fs::write(
dir.path().join("extension.toml"),
format!(
"[extension]\nname = \"x\"\nversion = \"0.1.0\"\nentry = \"main.lua\"\napi = 1\n\
[capabilities]\nfs.read = [\"/\"]\nfs.write = [\"{}\"]\n",
dir.path().display()
),
)
.unwrap();
fs::write(
dir.path().join("main.lua"),
format!("airsstack.fs.write('{}', 'ran')\n", marker.display()),
)
.unwrap();
let ceiling = Ceiling::new(
Policy::confined().with_grants(GrantSet::declared().with_fs(|fs| fs.write(dir.path()))),
)
.unwrap();
let events = events(&[]);
let variables = Variables::none();
let approver = ManifestApprover;
let root = RootTable::default();
let err = Extension::load(
dir.path(),
context(&ceiling, &events, &variables, &approver, &root),
)
.unwrap_err();
assert!(
matches!(&err, Error::ExtensionDenied { detail, .. } if detail.contains("fs.read") && detail.contains("outside the granted read roots")),
"{err}"
);
assert!(!marker.exists(), "the entry script must not have run");
}
#[test]
fn the_approver_can_refuse_an_otherwise_satisfied_request() {
let dir = fixture("echo", "", ECHO);
let ceiling = Ceiling::new(Policy::confined()).unwrap();
let events = events(&["ping"]);
let variables = Variables::none();
let approver = DenyAll;
let root = RootTable::default();
let err = Extension::load(
dir.path(),
context(&ceiling, &events, &variables, &approver, &root),
)
.unwrap_err();
assert!(
matches!(&err, Error::ExtensionDenied { detail, .. } if detail == "extensions are disabled"),
"{err}"
);
}
#[test]
fn an_undeclared_event_in_ext_on_fails_the_load() {
let dir = fixture("echo", "", r#"airsstack.ext.on("other", function() end)"#);
let ceiling = Ceiling::new(Policy::confined()).unwrap();
let events = events(&["ping"]);
let variables = Variables::none();
let approver = ManifestApprover;
let root = RootTable::default();
let err = Extension::load(
dir.path(),
context(&ceiling, &events, &variables, &approver, &root),
)
.unwrap_err();
assert!(matches!(err, Error::Lua { .. }), "{err}");
assert!(
err.to_string().contains("is not one this host dispatches"),
"{err}"
);
}
#[test]
fn a_runaway_entry_script_is_an_instruction_breach() {
let dir = fixture("runaway", "", "while true do end");
let ceiling = Ceiling::new(Policy::confined().with_limits(ResourceLimits::new(
Some(MemoryLimit::mebibytes(64)),
Some(InstructionLimit::count(10_000)),
)))
.unwrap();
let events = events(&[]);
let variables = Variables::none();
let approver = ManifestApprover;
let root = RootTable::default();
let err = Extension::load(
dir.path(),
context(&ceiling, &events, &variables, &approver, &root),
)
.unwrap_err();
assert!(err.exhausted_limit().is_some(), "{err}");
}
#[test]
fn granted_is_the_negotiated_policy() {
let dir = fixture("echo", "", ECHO);
let ceiling = Ceiling::new(Policy::confined()).unwrap();
let events = events(&["ping"]);
let variables = Variables::none();
let approver = ManifestApprover;
let root = RootTable::default();
let ext = Extension::load(
dir.path(),
context(&ceiling, &events, &variables, &approver, &root),
)
.unwrap();
assert_eq!(ext.granted(), ext.report().policy());
}
#[test]
fn an_entry_swapped_for_an_escaping_symlink_after_validation_is_refused() {
let dir = fixture("echo", "", "return 1");
let ceiling = Ceiling::new(Policy::confined()).unwrap();
let events = events(&[]);
let variables = Variables::none();
let approver = ManifestApprover;
let root = RootTable::default();
let pending = Extension::approve(
dir.path(),
context(&ceiling, &events, &variables, &approver, &root),
)
.unwrap();
let outside = TempDir::new().unwrap();
let outside_file = outside.path().join("evil.lua");
fs::write(&outside_file, "return 1").unwrap();
fs::remove_file(dir.path().join("main.lua")).unwrap();
std::os::unix::fs::symlink(&outside_file, dir.path().join("main.lua")).unwrap();
let err = pending.start().unwrap_err();
assert!(
matches!(&err, Error::ManifestInvalid { field, .. } if *field == "extension.entry"),
"{err}"
);
}
#[test]
fn approve_exposes_the_name_before_start() {
let dir = fixture("echo", "", "return 1");
let ceiling = Ceiling::new(Policy::confined()).unwrap();
let events = events(&[]);
let variables = Variables::none();
let approver = ManifestApprover;
let root = RootTable::default();
let pending = Extension::approve(
dir.path(),
context(&ceiling, &events, &variables, &approver, &root),
)
.unwrap();
assert_eq!(pending.name().as_str(), "echo");
}
#[test]
fn extension_is_send_and_sync() {
const fn assert<T: Send + Sync>() {}
assert::<Extension>();
}
}