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
11/// Tables whose entire function surface is blocked.
12const BLOCKED_TABLES: &[&str] = &["shell", "process", "machinectl"];
13
14/// Individual functions blocked inside otherwise-usable tables.
15const BLOCKED_FUNCTIONS: &[&str] = &[
16    "http.post",
17    "http.put",
18    "http.patch",
19    "http.delete",
20    "http.serve",
21    "http.serve_with_extra",
22    "http.download",
23    "ws.connect",
24    "fs.write",
25    "fs.write_bytes",
26    "fs.remove",
27    "fs.rename",
28    "fs.copy",
29    "fs.chmod",
30    "fs.mkdir",
31    "fs.tempdir",
32    "fs.sub_in_file",
33    "env.set",
34    "db.execute",
35    "oci.copy",
36    "oci.tag",
37    "oci.mutate",
38    "systemd.start",
39    "systemd.stop",
40    "systemd.restart",
41    "systemd.reload",
42    "systemd.unit_action",
43    "systemd.machine_start",
44    "systemd.machine_poweroff",
45    "systemd.machine_reboot",
46    "systemd.machine_terminate",
47    "systemd.machine_exec",
48    "apt.update",
49    "apt.install",
50    "apt.remove",
51    "apt.add_source",
52    "compress.untar",
53    "tar.create",
54    "tar.extract",
55    "io.popen",
56];
57
58pub fn apply(lua: &Lua) -> mlua::Result<()> {
59    for path in BLOCKED_FUNCTIONS {
60        block_function(lua, path)?;
61    }
62    for name in BLOCKED_TABLES {
63        block_table(lua, name)?;
64    }
65    guard_http_client_request(lua)?;
66    guard_io_open(lua)?;
67    guard_io_output(lua)?;
68    Ok(())
69}
70
71fn blocked(name: &str) -> mlua::Error {
72    mlua::Error::runtime(format!(
73        "readonly: {name} blocked (write operations are disabled in read-only mode)"
74    ))
75}
76
77fn blocked_stub(lua: &Lua, name: &str) -> mlua::Result<mlua::Function> {
78    let name = name.to_string();
79    lua.create_function(move |_, _args: MultiValue| -> mlua::Result<Value> { Err(blocked(&name)) })
80}
81
82/// Replace a single `table.fn` global with a blocking stub. Missing
83/// tables or functions (feature-gated builds) are skipped so the guard
84/// never changes the surface shape of the VM.
85fn block_function(lua: &Lua, path: &str) -> mlua::Result<()> {
86    let Some((table_name, fn_name)) = path.split_once('.') else {
87        return Ok(());
88    };
89    let Some(table) = lua.globals().get::<Option<Table>>(table_name)? else {
90        return Ok(());
91    };
92    if table.get::<Value>(fn_name)?.is_function() {
93        table.set(fn_name, blocked_stub(lua, path)?)?;
94    }
95    Ok(())
96}
97
98/// Replace every function in a global table with a blocking stub.
99fn block_table(lua: &Lua, name: &str) -> mlua::Result<()> {
100    let Some(table) = lua.globals().get::<Option<Table>>(name)? else {
101        return Ok(());
102    };
103    for pair in table.clone().pairs::<Value, Value>() {
104        let (key, value) = pair?;
105        if let (Value::String(key_str), true) = (&key, value.is_function()) {
106            let label = format!("{name}.{}", key_str.to_str()?);
107            table.set(key, blocked_stub(lua, &label)?)?;
108        }
109    }
110    Ok(())
111}
112
113/// `http.client(...)` wrappers route every verb through
114/// `http._client_request(ud, method, ...)`; blocking only the
115/// top-level `http.post` would leave that path open. The guard allows
116/// `get` through and raises for every other verb.
117fn guard_http_client_request(lua: &Lua) -> mlua::Result<()> {
118    let Some(http) = lua.globals().get::<Option<Table>>("http")? else {
119        return Ok(());
120    };
121    let Some(inner) = http.get::<Option<mlua::Function>>("_client_request")? else {
122        return Ok(());
123    };
124    let wrapper = lua.create_async_function(move |_, args: MultiValue| {
125        let inner = inner.clone();
126        async move {
127            let method = match args.iter().nth(1) {
128                Some(Value::String(s)) => Some(s.to_str()?.to_string()),
129                _ => None,
130            };
131            if let Some(method) = method
132                && method != "get"
133            {
134                return Err(blocked(&format!("http.{method}")));
135            }
136            inner.call_async::<MultiValue>(args).await
137        }
138    })?;
139    http.set("_client_request", wrapper)?;
140    Ok(())
141}
142
143/// `io.open` stays available for reading; write and append modes raise.
144fn guard_io_open(lua: &Lua) -> mlua::Result<()> {
145    let Some(io_table) = lua.globals().get::<Option<Table>>("io")? else {
146        return Ok(());
147    };
148    let Some(inner) = io_table.get::<Option<mlua::Function>>("open")? else {
149        return Ok(());
150    };
151    let wrapper = lua.create_function(move |_, args: MultiValue| {
152        let mode = match args.iter().nth(1) {
153            Some(Value::String(s)) => s.to_str()?.to_string(),
154            _ => "r".to_string(),
155        };
156        if mode.contains('w') || mode.contains('a') || mode.contains('+') {
157            return Err(blocked("io.open"));
158        }
159        inner.call::<MultiValue>(args)
160    })?;
161    io_table.set("open", wrapper)?;
162    Ok(())
163}
164
165/// `io.output(target)` opens its target for writing; only the
166/// zero-argument read of the current output stays available.
167fn guard_io_output(lua: &Lua) -> mlua::Result<()> {
168    let Some(io_table) = lua.globals().get::<Option<Table>>("io")? else {
169        return Ok(());
170    };
171    let Some(inner) = io_table.get::<Option<mlua::Function>>("output")? else {
172        return Ok(());
173    };
174    let wrapper = lua.create_function(move |_, args: MultiValue| {
175        if !args.is_empty() {
176            return Err(blocked("io.output"));
177        }
178        inner.call::<MultiValue>(args)
179    })?;
180    io_table.set("output", wrapper)?;
181    Ok(())
182}