Skip to main content

assay/lua/builtins/
readonly.rs

1//! Read-only mode guards, applied after `register_all` when the VM is
2//! created with `readonly = true`. Mutating builtins stay registered
3//! but are replaced with stubs that raise
4//! `readonly: <name> blocked (write operations are disabled in read-only mode)`,
5//! so semi-trusted scripts fail with a clear error instead of a
6//! nil-index. Read paths (`http.get`, `fs.read`, `env.get`, `db.query`,
7//! `systemd.list_units`, …) are untouched.
8
9use mlua::{Lua, MultiValue, Table, Value};
10
11use super::gated::{BLOCKED_FUNCTIONS, BLOCKED_TABLES, is_gated_http_verb};
12
13pub fn apply(lua: &Lua) -> mlua::Result<()> {
14    for path in BLOCKED_FUNCTIONS {
15        block_function(lua, path)?;
16    }
17    for name in BLOCKED_TABLES {
18        block_table(lua, name)?;
19    }
20    guard_http_client_request(lua)?;
21    guard_io_open(lua)?;
22    guard_io_output(lua)?;
23    Ok(())
24}
25
26fn blocked(name: &str) -> mlua::Error {
27    mlua::Error::runtime(format!(
28        "readonly: {name} blocked (write operations are disabled in read-only mode)"
29    ))
30}
31
32fn blocked_stub(lua: &Lua, name: &str) -> mlua::Result<mlua::Function> {
33    let name = name.to_string();
34    lua.create_function(move |_, _args: MultiValue| -> mlua::Result<Value> { Err(blocked(&name)) })
35}
36
37/// Replace a single `table.fn` global with a blocking stub. Missing
38/// tables or functions (feature-gated builds) are skipped so the guard
39/// never changes the surface shape of the VM.
40fn block_function(lua: &Lua, path: &str) -> mlua::Result<()> {
41    let Some((table_name, fn_name)) = path.split_once('.') else {
42        return Ok(());
43    };
44    let Some(table) = lua.globals().get::<Option<Table>>(table_name)? else {
45        return Ok(());
46    };
47    if table.get::<Value>(fn_name)?.is_function() {
48        table.set(fn_name, blocked_stub(lua, path)?)?;
49    }
50    Ok(())
51}
52
53/// Replace every function in a global table with a blocking stub.
54fn block_table(lua: &Lua, name: &str) -> mlua::Result<()> {
55    let Some(table) = lua.globals().get::<Option<Table>>(name)? else {
56        return Ok(());
57    };
58    for pair in table.clone().pairs::<Value, Value>() {
59        let (key, value) = pair?;
60        if let (Value::String(key_str), true) = (&key, value.is_function()) {
61            let label = format!("{name}.{}", key_str.to_str()?);
62            table.set(key, blocked_stub(lua, &label)?)?;
63        }
64    }
65    Ok(())
66}
67
68/// `http.client(...)` wrappers route every verb through
69/// `http._client_request(ud, method, ...)`; blocking only the
70/// top-level `http.post` would leave that path open. The guard allows
71/// `get` through and raises for every other verb.
72fn guard_http_client_request(lua: &Lua) -> mlua::Result<()> {
73    let Some(http) = lua.globals().get::<Option<Table>>("http")? else {
74        return Ok(());
75    };
76    let Some(inner) = http.get::<Option<mlua::Function>>("_client_request")? else {
77        return Ok(());
78    };
79    let wrapper = lua.create_async_function(move |_, args: MultiValue| {
80        let inner = inner.clone();
81        async move {
82            let method = match args.iter().nth(1) {
83                Some(Value::String(s)) => Some(s.to_str()?.to_string()),
84                _ => None,
85            };
86            if let Some(method) = method
87                && is_gated_http_verb(&method)
88            {
89                return Err(blocked(&format!("http.{method}")));
90            }
91            inner.call_async::<MultiValue>(args).await
92        }
93    })?;
94    http.set("_client_request", wrapper)?;
95    Ok(())
96}
97
98/// `io.open` stays available for reading; write and append modes raise.
99fn guard_io_open(lua: &Lua) -> mlua::Result<()> {
100    let Some(io_table) = lua.globals().get::<Option<Table>>("io")? else {
101        return Ok(());
102    };
103    let Some(inner) = io_table.get::<Option<mlua::Function>>("open")? else {
104        return Ok(());
105    };
106    let wrapper = lua.create_function(move |_, args: MultiValue| {
107        let mode = match args.iter().nth(1) {
108            Some(Value::String(s)) => s.to_str()?.to_string(),
109            _ => "r".to_string(),
110        };
111        if mode.contains('w') || mode.contains('a') || mode.contains('+') {
112            return Err(blocked("io.open"));
113        }
114        inner.call::<MultiValue>(args)
115    })?;
116    io_table.set("open", wrapper)?;
117    Ok(())
118}
119
120/// `io.output(target)` opens its target for writing; only the
121/// zero-argument read of the current output stays available.
122fn guard_io_output(lua: &Lua) -> mlua::Result<()> {
123    let Some(io_table) = lua.globals().get::<Option<Table>>("io")? else {
124        return Ok(());
125    };
126    let Some(inner) = io_table.get::<Option<mlua::Function>>("output")? else {
127        return Ok(());
128    };
129    let wrapper = lua.create_function(move |_, args: MultiValue| {
130        if !args.is_empty() {
131            return Err(blocked("io.output"));
132        }
133        inner.call::<MultiValue>(args)
134    })?;
135    io_table.set("output", wrapper)?;
136    Ok(())
137}