Skip to main content

gizmo_scripting/
api_table.rs

1//! API tables a script can read and cannot rewrite.
2//!
3//! # Why a proxy
4//!
5//! The engine hands Lua a table per subsystem — `input`, `entity`, `physics` — and every script
6//! shares those objects. Sandboxing `_G` fixed one half of that: a script's globals are its own
7//! now. It did not fix this half, because `input` is not a global write, it is a *field* write on
8//! a shared object. Measured: `input.is_pressed = function() return true end` in one script, and
9//! every other script sees the replacement for the rest of the session.
10//!
11//! A `__newindex` metamethod alone does not close it. `__newindex` fires only for keys the table
12//! does **not** already have, and every key worth clobbering — `is_pressed`, `spawn`, `apply_force`
13//! — is a key the table already has. Assigning to those writes straight through the metatable.
14//!
15//! So the global a script sees is an empty proxy. Empty means every read misses and goes through
16//! `__index` to the real table, and every write — new key or not — reaches `__newindex`, which
17//! raises. The real table lives in the Lua **registry**, which is reachable from Rust and not from
18//! Lua at all: no global points at it, so a script cannot walk to it, and `__metatable` blocks
19//! `getmetatable` from lifting it out of the proxy.
20//!
21//! Rust still writes the real table every frame with `raw_set`, which bypasses metamethods by
22//! definition. That is the asymmetry the whole arrangement exists to create: the engine writes,
23//! the scripts read.
24//!
25//! # What it does not stop
26//!
27//! A script can still shadow the name in its own environment (`input = something_else`) — that is
28//! what `_G` isolation makes safe, since the shadow is private to that script. And a table the
29//! engine hands *out* by value, rather than exposing as a global, is unaffected; this covers the
30//! long-lived API surface, not every table that crosses the boundary.
31
32use mlua::prelude::*;
33
34/// Where the real table hides. Not a global: Lua has no way to name the registry.
35fn registry_key(name: &str) -> String {
36    format!("gizmo_api_raw_{name}")
37}
38
39/// Publish `name` as a read-only API table.
40///
41/// `build` fills the real table — Rust fields and, via `lua.load`, any Lua helper functions, which
42/// is why it runs while `name` is still bound to the real table. The proxy replaces it afterwards;
43/// from then on the helpers resolve `name` to the proxy and read through it, which is exactly what
44/// a script does.
45pub fn register_protected(
46    lua: &Lua,
47    name: &str,
48    build: impl FnOnce(&LuaTable) -> Result<(), LuaError>,
49) -> Result<(), LuaError> {
50    let real = lua.create_table()?;
51
52    // Visible under its real name while `build` runs: the Lua half of an API is written as
53    // `function input.is_pressed(...)`, which is a field write and would hit the proxy's guard.
54    lua.globals().set(name, real.clone())?;
55    build(&real)?;
56
57    lua.set_named_registry_value(&registry_key(name), real.clone())?;
58
59    let proxy = lua.create_table()?;
60    let meta = lua.create_table()?;
61    meta.set("__index", real)?;
62    let owner = name.to_string();
63    meta.set(
64        "__newindex",
65        lua.create_function(move |_, (_, key, _): (LuaTable, LuaValue, LuaValue)| {
66            let key = match &key {
67                LuaValue::String(s) => s.to_str().unwrap_or("?").to_string(),
68                other => format!("{other:?}"),
69            };
70            Err::<(), _>(LuaError::RuntimeError(format!(
71                "`{owner}` is a read-only engine API: assigning to `{owner}.{key}` would change it \
72                 for every script in the scene, not just this one"
73            )))
74        })?,
75    )?;
76    // Stops `getmetatable(input).__index` from handing the real table back, and `setmetatable`
77    // from replacing the guard.
78    meta.set("__metatable", false)?;
79    proxy.set_metatable(Some(meta));
80
81    lua.globals().set(name, proxy)?;
82    Ok(())
83}
84
85/// The real table behind a protected API, for the engine's own per-frame writes.
86///
87/// Callers must use [`LuaTable::raw_set`] on it. A plain `set` would work today — the real table
88/// has no metatable — but the point of fetching it here rather than reading the global is that the
89/// write path and the read path are deliberately different, and `raw_set` says so at the call site.
90pub fn raw<'lua>(lua: &'lua Lua, name: &str) -> Result<LuaTable<'lua>, LuaError> {
91    lua.named_registry_value(&registry_key(name))
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    fn engine_api(lua: &Lua) {
99        register_protected(lua, "demo", |t| {
100            t.set("value", 1)?;
101            lua.load("function demo.helper() return demo.value end").exec()
102        })
103        .unwrap();
104    }
105
106    #[test]
107    fn a_script_cannot_replace_an_api_function() {
108        let lua = Lua::new();
109        engine_api(&lua);
110        let err = lua
111            .load("demo.helper = function() return 99 end")
112            .exec()
113            .expect_err("overwriting an API function must fail");
114        assert!(format!("{err}").contains("read-only"), "unexpected error: {err}");
115        // …and the original still answers.
116        let got: i64 = lua.load("return demo.helper()").eval().unwrap();
117        assert_eq!(got, 1);
118    }
119
120    /// The case a bare `__newindex` misses: assigning to a key the table already has.
121    #[test]
122    fn an_existing_key_is_protected_too() {
123        let lua = Lua::new();
124        engine_api(&lua);
125        assert!(lua.load("demo.value = 42").exec().is_err(), "existing keys must be protected");
126        let got: i64 = lua.load("return demo.value").eval().unwrap();
127        assert_eq!(got, 1, "the write must not have landed");
128    }
129
130    #[test]
131    fn the_real_table_is_not_reachable_from_lua() {
132        let lua = Lua::new();
133        engine_api(&lua);
134        // `getmetatable` is blocked, so the proxy cannot be unwrapped…
135        let meta: LuaValue = lua.load("return getmetatable(demo)").eval().unwrap();
136        assert_eq!(meta, LuaValue::Boolean(false), "the metatable must not be readable");
137        // …and no global points at the real table.
138        let found: bool = lua
139            .load("for k, v in pairs(_G) do if v ~= demo and type(v) == 'table' and rawget(v, 'value') == 1 then return true end end return false")
140            .eval()
141            .unwrap();
142        assert!(!found, "the real table is reachable through some global");
143    }
144
145    #[test]
146    fn the_engine_still_writes_through_the_registry() {
147        let lua = Lua::new();
148        engine_api(&lua);
149        raw(&lua, "demo").unwrap().raw_set("value", 7).unwrap();
150        let got: i64 = lua.load("return demo.value").eval().unwrap();
151        assert_eq!(got, 7, "a Rust-side raw_set must be visible through the proxy");
152    }
153}