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