use std::collections::BTreeMap;
use std::sync::{Arc, RwLock};
use crate::error::{Error, Result};
use crate::modules::{HostModule, InstallContext};
use crate::sandbox::GrantSet;
use crate::types::ModuleName;
#[derive(Debug, Default)]
pub(crate) struct Overlay {
entries: RwLock<BTreeMap<String, Option<String>>>,
}
impl Overlay {
const fn new() -> Self {
Self {
entries: RwLock::new(BTreeMap::new()),
}
}
fn set(&self, name: &str, value: Option<String>) {
if let Ok(mut entries) = self.entries.write() {
entries.insert(name.to_owned(), value);
}
}
pub(crate) fn get(&self, name: &str) -> Option<String> {
self.entries
.read()
.ok()
.and_then(|entries| entries.get(name).cloned())
.unwrap_or_else(|| std::env::var(name).ok())
}
pub(crate) fn child_entries(&self) -> Vec<(String, Option<String>)> {
self.entries
.read()
.map(|e| e.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default()
}
fn names(&self) -> Vec<String> {
self.entries
.read()
.map(|e| e.keys().cloned().collect())
.unwrap_or_default()
}
}
#[derive(Debug)]
pub struct Env {
name: ModuleName,
}
impl Env {
#[must_use]
pub fn new() -> Self {
Self {
name: ModuleName::new("env")
.unwrap_or_else(|_| unreachable!("`env` is a valid module name")),
}
}
}
impl Default for Env {
fn default() -> Self {
Self::new()
}
}
fn allows(grants: &GrantSet, name: &str) -> bool {
grants.is_unrestricted() || grants.env().allows(name)
}
fn denied(grants: &GrantSet, operation: &'static str, name: &str) -> Error {
let allowed: Vec<_> = grants.env().names().collect();
let detail = if allowed.is_empty() {
format!("`{name}` is not granted — no environment variables are")
} else {
format!(
"`{name}` is not granted — the allowed names are {}",
allowed.join(", ")
)
};
Error::Denied {
module: "env",
operation,
detail,
}
}
impl HostModule for Env {
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("env"),
reason: e.to_string(),
};
let grants = Arc::new(context.grants().clone());
let overlay = overlay();
let (g, o) = (Arc::clone(&grants), Arc::clone(&overlay));
let get = lua
.create_function(move |_, name: mlua::LuaString| {
let name = name.to_str()?;
if !allows(&g, &name) {
return Err(mlua::Error::from(denied(&g, "get", &name)));
}
Ok(o.get(&name))
})
.map_err(fail)?;
table.set("get", get).map_err(fail)?;
let (g, o) = (Arc::clone(&grants), Arc::clone(&overlay));
let all = lua
.create_function(move |lua, ()| {
let out = lua.create_table()?;
if g.is_unrestricted() {
let mut names: Vec<String> = std::env::vars().map(|(k, _)| k).collect();
names.extend(o.names());
names.sort();
names.dedup();
for name in names {
if let Some(value) = o.get(&name) {
out.set(name, value)?;
}
}
} else {
for name in g.env().names() {
if let Some(value) = o.get(name) {
out.set(name, value)?;
}
}
}
Ok(out)
})
.map_err(fail)?;
table.set("all", all).map_err(fail)?;
let (g, o) = (grants, overlay);
let set = lua
.create_function(
move |_, (name, value): (mlua::LuaString, Option<mlua::LuaString>)| {
let name = name.to_str()?;
if !allows(&g, &name) {
return Err(mlua::Error::from(denied(&g, "set", &name)));
}
let value = match value {
Some(text) => Some(text.to_str()?.to_owned()),
None => None,
};
o.set(&name, value);
Ok(())
},
)
.map_err(fail)?;
table.set("set", set).map_err(fail)?;
Ok(())
}
}
pub(crate) fn overlay() -> Arc<Overlay> {
static SHARED: std::sync::OnceLock<Arc<Overlay>> = std::sync::OnceLock::new();
Arc::clone(SHARED.get_or_init(|| Arc::new(Overlay::new())))
}
#[cfg(test)]
mod tests {
#![expect(
clippy::unwrap_used,
reason = "tests unwrap known-valid fixtures; a panic is the intended failure signal"
)]
use super::Env;
use crate::{Engine, GrantSet, HostModule as _, Policy, Script};
fn granted(names: &[&str]) -> Engine {
let names: Vec<String> = names.iter().map(|s| (*s).to_owned()).collect();
Engine::builder()
.policy(
Policy::confined()
.with_grants(GrantSet::declared().with_env(|env| env.read(names))),
)
.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_env() {
assert_eq!(Env::new().name().as_str(), "env");
}
#[test]
fn a_granted_name_can_be_read() {
let engine = granted(&["AIRSL_TEST_GRANTED"]);
eval::<()>(&engine, "airsstack.env.set('AIRSL_TEST_GRANTED', 'value')").unwrap();
assert_eq!(
eval::<String>(&engine, "return airsstack.env.get('AIRSL_TEST_GRANTED')").unwrap(),
"value"
);
}
#[test]
fn an_ungranted_name_is_refused_rather_than_reported_as_unset() {
let engine = granted(&["ALLOWED"]);
let err = eval::<Option<String>>(&engine, "return airsstack.env.get('PATH')").unwrap_err();
assert!(err.to_string().contains("env.get denied"), "{err}");
}
#[test]
fn a_granted_but_unset_name_reads_as_nil() {
let engine = granted(&["AIRSL_TEST_DEFINITELY_UNSET"]);
let kind: String = eval(
&engine,
"return type(airsstack.env.get('AIRSL_TEST_DEFINITELY_UNSET'))",
)
.unwrap();
assert_eq!(kind, "nil");
}
#[test]
fn all_returns_only_the_granted_names() {
let engine = granted(&["AIRSL_TEST_ALL"]);
eval::<()>(&engine, "airsstack.env.set('AIRSL_TEST_ALL', 'x')").unwrap();
let out: String = eval(
&engine,
"local names = {}
for name in pairs(airsstack.env.all()) do names[#names+1] = name end
table.sort(names)
return table.concat(names, ',')",
)
.unwrap();
assert_eq!(out, "AIRSL_TEST_ALL");
}
#[test]
fn all_does_not_leak_the_hosts_environment() {
let engine = granted(&["AIRSL_TEST_ALL_2"]);
let found: bool = eval(&engine, "return airsstack.env.all().PATH ~= nil").unwrap();
assert!(!found, "an ungranted variable reached the script");
}
#[test]
fn setting_an_ungranted_name_is_refused() {
let engine = granted(&["ALLOWED"]);
let err = eval::<()>(&engine, "airsstack.env.set('PATH', '/evil')").unwrap_err();
assert!(err.to_string().contains("env.set denied"), "{err}");
}
#[test]
fn set_does_not_change_the_host_process_environment() {
let engine = granted(&["AIRSL_TEST_HOST_UNTOUCHED"]);
eval::<()>(
&engine,
"airsstack.env.set('AIRSL_TEST_HOST_UNTOUCHED', 'from-lua')",
)
.unwrap();
assert!(std::env::var("AIRSL_TEST_HOST_UNTOUCHED").is_err());
}
#[test]
fn set_with_no_value_makes_the_name_read_as_unset() {
let engine = granted(&["AIRSL_TEST_CLEARED"]);
eval::<()>(&engine, "airsstack.env.set('AIRSL_TEST_CLEARED', 'x')").unwrap();
eval::<()>(&engine, "airsstack.env.set('AIRSL_TEST_CLEARED')").unwrap();
let kind: String = eval(
&engine,
"return type(airsstack.env.get('AIRSL_TEST_CLEARED'))",
)
.unwrap();
assert_eq!(kind, "nil");
}
#[test]
fn a_policy_granting_nothing_refuses_every_name() {
let engine = Engine::builder()
.policy(Policy::confined())
.build()
.unwrap();
let err = eval::<Option<String>>(&engine, "return airsstack.env.get('HOME')").unwrap_err();
assert!(
err.to_string().contains("no environment variables"),
"{err}"
);
}
#[test]
fn a_trusted_policy_reads_anything() {
let engine = Engine::builder().policy(Policy::trusted()).build().unwrap();
let kind: String = eval(&engine, "return type(airsstack.env.get('PATH'))").unwrap();
assert_eq!(kind, "string");
}
}