use std::sync::Arc;
use crate::error::{Error, Result};
use crate::modules::env;
use crate::modules::{HostModule, InstallContext};
use crate::sandbox::GrantSet;
use crate::types::ModuleName;
#[derive(Debug)]
pub struct Proc {
name: ModuleName,
}
impl Proc {
#[must_use]
pub fn new() -> Self {
Self {
name: ModuleName::new("proc")
.unwrap_or_else(|_| unreachable!("`proc` is a valid module name")),
}
}
}
impl Default for Proc {
fn default() -> Self {
Self::new()
}
}
fn denied(grants: &GrantSet, operation: &'static str, program: &str) -> Error {
let allowed: Vec<_> = grants.proc().executables().collect();
let detail = if allowed.is_empty() {
format!("`{program}` is not granted — no executables are")
} else {
format!(
"`{program}` is not granted — the allowed executables are {}",
allowed.join(", ")
)
};
Error::Denied {
module: "proc",
operation,
detail,
}
}
fn argv(command: &mlua::Table) -> mlua::Result<(String, Vec<String>)> {
let mut parts = Vec::new();
for value in command.sequence_values::<mlua::LuaString>() {
parts.push(value?.to_str()?.to_owned());
}
let mut parts = parts.into_iter();
let program = parts.next().ok_or_else(|| {
mlua::Error::from(Error::Denied {
module: "proc",
operation: "run",
detail: String::from("the argv array is empty, so there is no program to run"),
})
})?;
Ok((program, parts.collect()))
}
impl HostModule for Proc {
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("proc"),
reason: e.to_string(),
};
let grants = Arc::new(context.grants().clone());
let overlay = env::overlay();
let (g, o) = (Arc::clone(&grants), Arc::clone(&overlay));
let run = lua
.create_function(move |lua, command: mlua::Table| {
let (program, arguments) = argv(&command)?;
if !g.is_unrestricted() && !g.proc().allows(&program) {
return Err(mlua::Error::from(denied(&g, "run", &program)));
}
let mut child = std::process::Command::new(&program);
child.args(&arguments);
for (name, value) in overlay_entries(&o) {
match value {
Some(value) => child.env(name, value),
None => child.env_remove(name),
};
}
let output = child.output().map_err(|source| Error::Io {
operation: "run",
path: program.clone(),
source,
})?;
let result = lua.create_table()?;
result.set("stdout", lua.create_string(&output.stdout)?)?;
result.set("stderr", lua.create_string(&output.stderr)?)?;
result.set("status", output.status.code().unwrap_or(-1))?;
Ok(result)
})
.map_err(fail)?;
table.set("run", run).map_err(fail)?;
let g = grants;
let which = lua
.create_function(move |_, program: mlua::LuaString| {
let program = program.to_str()?;
if !g.is_unrestricted() && !g.proc().allows(&program) {
return Err(mlua::Error::from(denied(&g, "which", &program)));
}
Ok(which(&program))
})
.map_err(fail)?;
table.set("which", which).map_err(fail)?;
Ok(())
}
}
fn overlay_entries(overlay: &env::Overlay) -> Vec<(String, Option<String>)> {
overlay.child_entries()
}
fn which(program: &str) -> Option<String> {
let path = env::overlay().get("PATH")?;
std::env::split_paths(&path)
.map(|directory| directory.join(program))
.find(|candidate| is_executable(candidate))
.map(|found| found.to_string_lossy().into_owned())
}
fn is_executable(path: &std::path::Path) -> bool {
use std::os::unix::fs::PermissionsExt as _;
std::fs::metadata(path)
.is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
}
#[cfg(test)]
mod tests {
#![expect(
clippy::unwrap_used,
reason = "tests unwrap known-valid fixtures; a panic is the intended failure signal"
)]
use super::Proc;
use crate::{Engine, GrantSet, HostModule as _, Policy, Script};
fn granted(programs: &[&str]) -> Engine {
let programs: Vec<String> = programs.iter().map(|s| (*s).to_owned()).collect();
Engine::builder()
.policy(
Policy::confined()
.with_grants(GrantSet::declared().with_proc(|proc| proc.allow(programs))),
)
.build()
.unwrap()
}
fn eval<T: mlua::FromLuaMulti>(engine: &Engine, source: &str) -> crate::Result<T> {
engine.eval_to::<T>(&Script::from_source(source, "test").unwrap())
}
#[test]
fn the_module_is_named_proc() {
assert_eq!(Proc::new().name().as_str(), "proc");
}
#[test]
fn run_captures_stdout_and_the_exit_status() {
let engine = granted(&["echo"]);
let out: String = eval(
&engine,
"local r = airsstack.proc.run({'echo', 'hello'})
return r.stdout .. ':' .. tostring(r.status)",
)
.unwrap();
assert_eq!(out, "hello\n:0");
}
#[test]
fn run_captures_a_non_zero_status_without_raising() {
let engine = granted(&["false"]);
let status: i64 = eval(&engine, "return airsstack.proc.run({'false'}).status").unwrap();
assert_ne!(status, 0);
}
#[test]
fn run_captures_stderr_separately() {
let engine = granted(&["sh"]);
let err: String = eval(
&engine,
"return airsstack.proc.run({'sh', '-c', 'echo oops 1>&2'}).stderr",
)
.unwrap();
assert_eq!(err, "oops\n");
}
#[test]
fn arguments_are_passed_without_a_shell() {
let engine = granted(&["echo"]);
let out: String = eval(
&engine,
"return airsstack.proc.run({'echo', 'a b; rm -rf *'}).stdout",
)
.unwrap();
assert_eq!(out, "a b; rm -rf *\n");
}
#[test]
fn an_ungranted_program_is_refused() {
let engine = granted(&["echo"]);
let err = eval::<mlua::Value>(&engine, "return airsstack.proc.run({'curl'})").unwrap_err();
assert!(err.to_string().contains("proc.run denied"), "{err}");
}
#[test]
fn a_path_to_a_granted_program_is_still_refused() {
let engine = granted(&["echo"]);
let err =
eval::<mlua::Value>(&engine, "return airsstack.proc.run({'/bin/echo'})").unwrap_err();
assert!(err.to_string().contains("denied"), "{err}");
}
#[test]
fn an_empty_argv_is_refused_with_a_reason() {
let engine = granted(&["echo"]);
let err = eval::<mlua::Value>(&engine, "return airsstack.proc.run({})").unwrap_err();
assert!(err.to_string().contains("empty"), "{err}");
}
#[test]
fn which_finds_a_granted_program_on_the_path() {
let engine = granted(&["sh"]);
let found: String = eval(&engine, "return airsstack.proc.which('sh')").unwrap();
assert!(found.ends_with("/sh"), "{found}");
}
#[test]
fn which_returns_nil_for_something_that_is_not_installed() {
let engine = granted(&["airsl-definitely-not-installed"]);
let kind: String = eval(
&engine,
"return type(airsstack.proc.which('airsl-definitely-not-installed'))",
)
.unwrap();
assert_eq!(kind, "nil");
}
#[test]
fn which_is_refused_for_an_ungranted_program() {
let engine = granted(&["echo"]);
let err = eval::<mlua::Value>(&engine, "return airsstack.proc.which('curl')").unwrap_err();
assert!(err.to_string().contains("proc.which denied"), "{err}");
}
#[test]
fn a_policy_granting_nothing_refuses_every_program() {
let engine = Engine::builder()
.policy(Policy::confined())
.build()
.unwrap();
let err = eval::<mlua::Value>(&engine, "return airsstack.proc.run({'echo'})").unwrap_err();
assert!(err.to_string().contains("no executables"), "{err}");
}
#[test]
fn a_trusted_policy_runs_anything() {
let engine = Engine::builder().policy(Policy::trusted()).build().unwrap();
let out: String =
eval(&engine, "return airsstack.proc.run({'echo', 'ok'}).stdout").unwrap();
assert_eq!(out, "ok\n");
}
}