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(®istry_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(®istry_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}