Spaces:
Sleeping
Sleeping
File size: 7,259 Bytes
3afc977 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 | //! Deterministic Lua sandbox.
//!
//! The sandbox is the verifier (DATA.md): a real Lua interpreter, stripped to a
//! whitelist of the language core plus always-safe builtins. No `io`, `os`,
//! `require`, `package`, no non-deterministic sources. The "timeout" is an
//! instruction budget enforced by a VM hook — deterministic, unlike wall-clock,
//! so verification is reproducible.
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::Arc;
use mlua::{HookTriggers, Lua, LuaOptions, MultiValue, StdLib, Table, Value as LuaValue, VmState};
use crate::value::LValue;
/// How many VM instructions elapse between hook fires. Coarser = faster.
const HOOK_GRANULARITY: u32 = 256;
#[derive(Debug, Clone, PartialEq)]
pub enum RunError {
/// Program/function raised a Lua error.
Lua(String),
/// Instruction budget exhausted (our deterministic timeout).
Budget,
/// Returned value lay outside the observable value space.
NonObservable,
}
pub struct Sandbox {
lua: Lua,
env: Table,
budget: Arc<AtomicI64>,
}
impl Sandbox {
pub fn new() -> mlua::Result<Self> {
// Load only deterministic, side-effect-free standard libraries. No IO,
// OS, PACKAGE, DEBUG, COROUTINE.
let lua = Lua::new_with(
StdLib::MATH | StdLib::STRING | StdLib::TABLE,
LuaOptions::default(),
)?;
let env = build_env(&lua)?;
// Instruction-budget hook. Shared counter, reset before each operation.
let budget = Arc::new(AtomicI64::new(0));
let hook_budget = budget.clone();
lua.set_hook(
HookTriggers::new().every_nth_instruction(HOOK_GRANULARITY),
move |_lua, _debug| {
let left = hook_budget.fetch_sub(HOOK_GRANULARITY as i64, Ordering::Relaxed);
if left <= 0 {
Err(mlua::Error::RuntimeError("instruction budget exceeded".into()))
} else {
Ok(VmState::Continue)
}
},
);
Ok(Sandbox { lua, env, budget })
}
fn arm(&self, instructions: i64) {
self.budget.store(instructions, Ordering::Relaxed);
}
fn is_budget_error(e: &mlua::Error) -> bool {
// The hook error is wrapped (CallbackError) as it propagates through the
// VM, so match on the message rather than the variant.
e.to_string().contains("instruction budget")
}
/// Load a program (defining a global function `f` in the sandbox env) and
/// execute its top level. Returns Ok if it loads and runs cleanly.
pub fn load_program(&self, source: &str, budget: i64) -> Result<(), RunError> {
self.arm(budget);
let chunk = self
.lua
.load(source)
.set_name("program")
.set_environment(self.env.clone());
chunk.exec().map_err(|e| {
if Self::is_budget_error(&e) {
RunError::Budget
} else {
RunError::Lua(e.to_string())
}
})
}
/// Call `f(args...)` and return its single observable result.
pub fn call_f(&self, args: &[LValue], budget: i64) -> Result<LValue, RunError> {
let f: mlua::Function = self
.env
.get("f")
.map_err(|e| RunError::Lua(format!("no function f: {e}")))?;
let mut lua_args = MultiValue::new();
for a in args {
let v = a
.to_lua(&self.lua)
.map_err(|e| RunError::Lua(e.to_string()))?;
lua_args.push_back(v);
}
self.arm(budget);
let ret: LuaValue = f.call(lua_args).map_err(|e| {
if Self::is_budget_error(&e) {
RunError::Budget
} else {
RunError::Lua(e.to_string())
}
})?;
LValue::from_lua(&ret).ok_or(RunError::NonObservable)
}
}
/// Build the restricted global environment: a fresh table holding only
/// whitelisted names copied from the real globals, with non-deterministic
/// entries pruned.
fn build_env(lua: &Lua) -> mlua::Result<Table> {
let g = lua.globals();
let env = lua.create_table()?;
// Safe base functions. Deliberately excluded: print (side effect),
// load/loadstring/dofile/loadfile (code injection), collectgarbage,
// require, rawset (mutation escape hatches kept minimal), _G access.
const BASE: &[&str] = &[
"assert", "error", "ipairs", "pairs", "next", "select", "tonumber",
"tostring", "type", "pcall", "xpcall", "rawequal", "rawget", "rawlen",
"setmetatable", "getmetatable", "unpack",
];
for name in BASE {
let v: LuaValue = g.get(*name)?;
if !v.is_nil() {
env.set(*name, v)?;
}
}
// math, minus non-deterministic random sources.
if let Ok(math) = g.get::<Table>("math") {
let m = clone_table_shallow(lua, &math)?;
m.set("random", LuaValue::Nil)?;
m.set("randomseed", LuaValue::Nil)?;
env.set("math", m)?;
}
// string and table are fully deterministic.
if let Ok(string) = g.get::<Table>("string") {
env.set("string", clone_table_shallow(lua, &string)?)?;
}
if let Ok(table) = g.get::<Table>("table") {
env.set("table", clone_table_shallow(lua, &table)?)?;
}
Ok(env)
}
fn clone_table_shallow(lua: &Lua, src: &Table) -> mlua::Result<Table> {
let dst = lua.create_table()?;
for pair in src.clone().pairs::<LuaValue, LuaValue>() {
let (k, v) = pair?;
dst.set(k, v)?;
}
Ok(dst)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::value::LValue;
const B: i64 = 100_000;
#[test]
fn forbidden_globals_absent() {
let sb = Sandbox::new().unwrap();
for name in ["os", "io", "require", "package", "print", "load", "dofile"] {
let src = format!("function f(x) return {name} == nil end");
sb.load_program(&src, B).unwrap();
assert_eq!(
sb.call_f(&[LValue::Int(0)], B).unwrap(),
LValue::Bool(true),
"{name} should be nil in sandbox"
);
}
}
#[test]
fn nondeterministic_sources_removed() {
let sb = Sandbox::new().unwrap();
sb.load_program("function f(x) return math.random == nil end", B).unwrap();
assert_eq!(sb.call_f(&[LValue::Int(0)], B).unwrap(), LValue::Bool(true));
}
#[test]
fn infinite_loop_hits_budget() {
let sb = Sandbox::new().unwrap();
sb.load_program("function f(x) while true do end end", B).unwrap();
assert_eq!(sb.call_f(&[LValue::Int(0)], B), Err(RunError::Budget));
}
#[test]
fn deterministic_arithmetic() {
let sb = Sandbox::new().unwrap();
sb.load_program("function f(x) return (x * 2) + 1 end", B).unwrap();
assert_eq!(sb.call_f(&[LValue::Int(20)], B).unwrap(), LValue::Int(41));
}
#[test]
fn nonobservable_function_return() {
let sb = Sandbox::new().unwrap();
sb.load_program("function f(x) return function() end end", B).unwrap();
assert_eq!(sb.call_f(&[LValue::Int(0)], B), Err(RunError::NonObservable));
}
}
|