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