Skip to main content

kevy_lua/
lib.rs

1//! kevy-lua — Redis EVAL / EVALSHA / SCRIPT surface backed by luna-core.
2//!
3//! kevy's script-host layer. Thin "cement" crate (per the
4//! stone-cement-stone model) — it carries no algorithmic content, only
5//! the bridge between kevy-rt's command dispatch path, kevy-resp's
6//! wire codec, and luna-core's sandboxed `Vm`.
7//!
8//! Design lock-in:
9//!
10//! - **Default Lua 5.1** — preserves the Redis Lua ecosystem (BullMQ,
11//!   Redlock, rate limiters, anything copied from Redis docs).
12//! - **Per-script dialect opt-in via `#!lua version=N`** — scripts
13//!   opt into 5.2 / 5.3 / 5.4 / 5.5 with a single shebang line.
14//!   SHA1 cache key is the raw script bytes, so EVALSHA is
15//!   version-aware for free.
16//! - **VM per-shard, per-dialect, lazily spawned** — first EVAL
17//!   hitting a dialect on a shard constructs the VM; reused
18//!   afterwards. Idle RSS scales with dialects actually used.
19//! - **Atomic execution** — entering EVAL pauses other dispatch on
20//!   that shard until the script returns. Matches Redis semantics.
21//!
22//! `Bridge` holds a per-dialect Vm pool (lazy-spawned); `eval()` runs
23//! the script under the sandbox and marshals the first returned
24//! `Value` into a RESP reply. Shebang parsing, SHA1 cache, EVALSHA,
25//! SCRIPT LOAD/EXISTS/FLUSH, and the `redis.call` host plumbing all
26//! live here.
27//!
28//! ```
29//! let mut bridge = kevy_lua::Bridge::with_no_dispatch();
30//! // KEYS and ARGV are bound per call; the reply is RESP bytes.
31//! let reply = bridge.eval(b"return #KEYS + tonumber(ARGV[1])", &[b"k"], &[b"41"]);
32//! assert_eq!(reply, b":42\r\n");
33//! // EVAL caches the script, so EVALSHA finds it by digest.
34//! let sha = kevy_lua::sha1::sha1(b"return 'hi'");
35//! bridge.eval(b"return 'hi'", &[], &[]);
36//! assert_eq!(bridge.script_exists(&[sha]), vec![true]);
37//! ```
38
39// Seeding a sandbox global. A VM that refuses one fails the script
40// anyway, with its own message — "attempt to index a nil value" says
41// more than "set_global returned Err".
42#![expect(
43    clippy::let_underscore_must_use,
44    reason = "a VM that refuses a global fails the script with a better message"
45)]
46#![forbid(unsafe_code)]
47#![warn(missing_docs)]
48
49use luna_core::runtime::value::Value;
50use luna_core::vm::exec::Vm;
51use std::cell::Cell;
52use std::rc::Rc;
53
54mod dispatch;
55mod host;
56mod marshal;
57mod pool;
58mod resp;
59mod shebang;
60
61mod cjson;
62mod cmsgpack;
63/// SHA-1 digest helpers. Exposed because the operator-side wire
64/// layer (kevy-rt's SCRIPT LOAD / EVALSHA codec) needs to convert
65/// between the 20-byte digest used as a cache key and the 40-char
66/// ASCII hex Redis uses on the wire.
67pub mod sha1;
68
69/// Re-export so callers can name the dialect without depending on
70/// luna-core directly.
71pub use luna_core::version::LuaVersion;
72
73pub(crate) use dispatch::{DISPATCH_KEY, DispatchHandle, DispatchSlot};
74use pool::{dialect_slot, format_lua_error, version_tag};
75
76/// Lua 5.1 / 5.2 / 5.3 / 5.4 / MacroLua / 5.5 — six fixed slots.
77/// `MacroLua` sits between `Lua54` and `Lua55` (it's a 5.4-superset
78/// compile-time-macro dialect). luna-core does not promise
79/// append-only variant ordering, so we explicitly map every variant
80/// to a stable slot via [`dialect_slot`] — an upstream variant
81/// insert can't silently re-index the VM pool.
82const N_DIALECTS: usize = 6;
83
84/// 200 M ≈ 5 s on modern hardware; matches Redis's default
85/// `lua-time-limit`. Overridable via [`Bridge::set_instr_budget`].
86/// `0` = unlimited (no budget cap).
87const DEFAULT_INSTR_BUDGET: i64 = 200_000_000;
88
89/// A wire-level reply: just the encoded RESP bytes.
90///
91/// ```
92/// let reply: kevy_lua::Reply = kevy_lua::Bridge::with_no_dispatch().eval(b"return 'ok'", &[], &[]);
93/// assert_eq!(reply, b"$2\r\nok\r\n");
94/// ```
95pub type Reply = Vec<u8>;
96
97/// SCRIPT FLUSH mode (Redis 6.2+ semantics). The default is
98/// [`FlushMode::Sync`], what `SCRIPT FLUSH` without an argument means.
99///
100/// ```
101/// assert_eq!(kevy_lua::FlushMode::default(), kevy_lua::FlushMode::Sync);
102/// ```
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
104#[non_exhaustive]
105pub enum FlushMode {
106    /// Synchronous — drop the cache before returning.
107    ///
108    /// ```
109    /// let mut b = kevy_lua::Bridge::with_no_dispatch();
110    /// let sha = b.script_load(b"return 1");
111    /// b.script_flush(kevy_lua::FlushMode::Sync);
112    /// assert_eq!(b.script_exists(&[sha]), [false]);
113    /// ```
114    #[default]
115    Sync,
116    /// Asynchronous — schedule the cache drop. Currently both
117    /// modes run as Sync; we keep the tag for future
118    /// differentiation (and Redis-compat replies).
119    ///
120    /// ```
121    /// let mut b = kevy_lua::Bridge::with_no_dispatch();
122    /// let sha = b.script_load(b"return 1");
123    /// b.script_flush(kevy_lua::FlushMode::Async);
124    /// // the cache is already empty when the call returns
125    /// assert_eq!(b.script_exists(&[sha]), [false]);
126    /// ```
127    Async,
128}
129
130/// A SHA1 hash of a script's source bytes. Used as the EVALSHA cache
131/// key. Includes any `#!lua version=N` shebang in the input, so a
132/// 5.1 script and the same script with a 5.3 shebang have distinct
133/// SHA1s and never collide in the cache.
134///
135/// ```
136/// use kevy_lua::{Bridge, ScriptSha1};
137/// let mut b = Bridge::with_no_dispatch();
138/// let plain: ScriptSha1 = b.script_load(b"return 1");
139/// let pinned: ScriptSha1 = b.script_load(b"#!lua version=5.3\nreturn 1");
140/// assert_eq!(plain, kevy_lua::sha1::sha1(b"return 1"));
141/// assert_ne!(plain, pinned);
142/// ```
143pub type ScriptSha1 = [u8; 20];
144
145/// kevy-lua per-shard bridge. One `Bridge` lives in each shard's
146/// runtime; it owns the per-dialect VM pool, the SHA1 cache, and
147/// the kevy-side dispatch callback that `redis.call` invokes.
148///
149/// The bridge is intentionally NOT `Send` / `Sync` — same constraint
150/// as luna-core's `Vm`, which is `!Send + !Sync` by design. kevy's
151/// thread-per-core model means every shard owns its bridge
152/// exclusively.
153///
154/// ```
155/// let mut bridge = kevy_lua::Bridge::with_no_dispatch();
156/// // no shebang runs under Lua 5.1; `#!lua version=5.3` opts into 5.3
157/// assert_eq!(bridge.eval(b"return 7 / 2", &[], &[]), b"$3\r\n3.5\r\n");
158/// assert_eq!(bridge.eval(b"#!lua version=5.3\nreturn 7 // 2", &[], &[]), b":3\r\n");
159/// ```
160pub struct Bridge {
161    /// Lazily-spawned VM per dialect. First EVAL hitting a dialect
162    /// creates the VM; reused for every subsequent script on that
163    /// dialect. Six fixed slots indexed by [`dialect_slot`].
164    vms: [Option<Vm>; N_DIALECTS],
165    /// Host dispatch closure invoked by `redis.call` / `redis.pcall`.
166    /// `Rc` so cheaply cloned into per-Vm userdata at construction
167    /// time without consuming the original.
168    dispatch: DispatchHandle,
169    /// Read-only mode flag set by [`Bridge::eval_ro`] /
170    /// [`Bridge::evalsha_ro`] before running the script and cleared
171    /// right after. `Rc<Cell<...>>` so every per-dialect Vm's
172    /// dispatch userdata sees the same bit without us having to
173    /// walk the pool. Shared with each `DispatchSlot`.
174    read_only: Rc<Cell<bool>>,
175    /// Per-Vm instruction budget applied at construction time
176    /// (`Vm::sandbox(...).with_instr_budget(N)`). Default 200 M
177    /// (the original hard-coded value). The kevy
178    /// operator wires `[lua] time_limit_ms` through here via
179    /// [`Bridge::set_instr_budget`].
180    ///
181    /// Changes only affect VMs spawned **after** the setter call;
182    /// the kevy-side wiring sets it before any EVAL so this is fine
183    /// in practice. If a config reload needs to take effect on
184    /// in-flight VMs, call `script_flush` afterwards.
185    instr_budget: i64,
186    /// Allow-mask, one bit per [`dialect_slot`]. `true` at slot `i`
187    /// means dialect `i` is permitted; an EVAL whose shebang asks
188    /// for a denied dialect gets a wire `-ERR` reply. All-true by
189    /// default.
190    allow: [bool; N_DIALECTS],
191    /// SHA1 → raw script bytes (including shebang). Populated by
192    /// `script_load` and by every successful `eval`. EVALSHA reads
193    /// from here; SCRIPT FLUSH empties it; SCRIPT EXISTS probes it.
194    ///
195    /// Per-shard cache: kevy runs thread-per-core and each shard
196    /// owns its own Bridge, so we don't share a global cache. The
197    /// trade-off (cache miss on first hit per shard) is dwarfed by
198    /// the locking we'd otherwise need.
199    script_cache: std::collections::HashMap<ScriptSha1, Vec<u8>>,
200}
201
202impl Bridge {
203    /// Create a fresh bridge with `dispatch` as the host callback
204    /// behind `redis.call`. No Vms are spawned until the first
205    /// EVAL.
206    ///
207    /// The dispatch closure receives the script's argv (`&[&[u8]]`,
208    /// command name at index 0) plus a `read_only` flag and must
209    /// return RESP reply bytes. When `read_only` is true the
210    /// dispatcher MUST reject write commands with
211    /// `-READONLY can't write against a read-only script\r\n` so
212    /// `EVAL_RO` / `EVALSHA_RO` deliver Redis semantics. kevy-rt
213    /// owns the canonical command-flag table and does this check
214    /// natively in production; tests provide a stub dispatcher
215    /// hard-coding a few write commands (see `tests/integration.rs`).
216    ///
217    /// For embedders that don't need real keyspace access (e.g.
218    /// pure-computation EVAL), [`Bridge::with_no_dispatch`] installs
219    /// a default that returns `-ERR redis.call: no dispatch wired`
220    /// for every call.
221    ///
222    /// ```
223    /// // a host that answers every command with its own name
224    /// let mut b = kevy_lua::Bridge::new(|argv: &[&[u8]], _read_only: bool| {
225    ///     let name = argv[0];
226    ///     [format!("${}\r\n", name.len()).as_bytes(), name, b"\r\n"].concat()
227    /// });
228    /// assert_eq!(b.eval(b"return redis.call('ECHO')", &[], &[]), b"$4\r\nECHO\r\n");
229    /// ```
230    pub fn new<F>(dispatch: F) -> Self
231    where
232        F: Fn(&[&[u8]], bool) -> Vec<u8> + 'static,
233    {
234        Self {
235            vms: [const { None }; N_DIALECTS],
236            dispatch: Rc::new(dispatch),
237            read_only: Rc::new(Cell::new(false)),
238            allow: [true; N_DIALECTS],
239            script_cache: std::collections::HashMap::new(),
240            instr_budget: DEFAULT_INSTR_BUDGET,
241        }
242    }
243
244    /// Override the per-Vm instruction budget (~5 s ≈ 200 M instr by
245    /// default). `0` disables the cap (unlimited execution).
246    ///
247    /// Setting it does NOT affect already-spawned VMs in the pool —
248    /// you can pair the call with [`Bridge::script_flush`] to force
249    /// a respawn under the new budget, or leave existing VMs as-is
250    /// and only catch new dialects.
251    ///
252    /// ```
253    /// let mut b = kevy_lua::Bridge::with_no_dispatch();
254    /// b.set_instr_budget(10_000);
255    /// // a runaway script is stopped and reported, not left spinning
256    /// assert!(b.eval(b"while true do end", &[], &[]).starts_with(b"-"));
257    /// ```
258    pub fn set_instr_budget(&mut self, n: i64) {
259        self.instr_budget = n;
260    }
261
262    /// Bridge with a no-op dispatcher: every `redis.call` returns a
263    /// RESP error. Convenience for embedders that want EVAL but
264    /// don't have the host dispatch wired yet (e.g. pure-computation
265    /// scripts during early development).
266    ///
267    /// ```
268    /// let mut b = kevy_lua::Bridge::with_no_dispatch();
269    /// assert_eq!(b.eval(b"return 6 * 7", &[], &[]), b":42\r\n");
270    /// assert!(b.eval(b"return redis.call('GET', 'k')", &[], &[]).starts_with(b"-"));
271    /// ```
272    #[must_use]
273    pub fn with_no_dispatch() -> Self {
274        Self::new(|_argv: &[&[u8]], _ro: bool| {
275            b"-ERR redis.call: no host dispatch wired\r\n".to_vec()
276        })
277    }
278
279    /// Restrict which Lua dialects this bridge will spawn VMs for.
280    /// An EVAL with `#!lua version=N` for a non-allowed dialect is
281    /// rejected with a `-ERR` reply. The 5.1 default is always
282    /// accessible via scripts with no shebang regardless of this
283    /// setting (you can't disable the ecosystem-default dialect
284    /// without taking a different `with_allowed_dialects` API).
285    ///
286    /// Passing an empty slice = no restriction = all five dialects
287    /// permitted (the constructor default).
288    ///
289    /// ```
290    /// use kevy_lua::{Bridge, LuaVersion};
291    /// let mut b = Bridge::with_no_dispatch();
292    /// b.set_allowed_dialects(&[LuaVersion::Lua51]);
293    /// assert!(b.eval(b"#!lua version=5.4\nreturn 1", &[], &[]).starts_with(b"-ERR"));
294    /// assert_eq!(b.eval(b"return 1", &[], &[]), b":1\r\n");
295    /// ```
296    pub fn set_allowed_dialects(&mut self, versions: &[LuaVersion]) {
297        if versions.is_empty() {
298            self.allow = [true; N_DIALECTS];
299            return;
300        }
301        self.allow = [false; N_DIALECTS];
302        for v in versions {
303            self.allow[dialect_slot(*v)] = true;
304        }
305    }
306
307    /// Run a script and marshal its first return value into a RESP
308    /// reply: the `#!lua version=N` shebang picks the dialect (5.1 when
309    /// absent), `KEYS` and `ARGV` are bound for this call, and the script
310    /// enters the SHA1 cache before it runs, as Redis does. Every failure
311    /// — a bad shebang, a disabled dialect, a Lua error — is a RESP error
312    /// reply, never a panic.
313    ///
314    /// ```
315    /// let mut b = kevy_lua::Bridge::with_no_dispatch();
316    /// assert_eq!(b.eval(b"return 1", &[], &[]), b":1\r\n");
317    /// assert!(b.eval(b"error('boom')", &[], &[]).starts_with(b"-"));
318    /// ```
319    pub fn eval(&mut self, script: &[u8], keys: &[&[u8]], args: &[&[u8]]) -> Reply {
320        // P4: peel off the `#!lua version=N` shebang first so we know
321        // which dialect Vm to route to before parsing the body.
322        let (sh, body) = match shebang::parse(script) {
323            Ok(t) => t,
324            Err(e) => return resp::err(format!("{e}").as_bytes()),
325        };
326        if !self.allow[dialect_slot(sh.version)] {
327            return resp::err(
328                format!("dialect {} disabled by [lua] allow_dialects", version_tag(sh.version))
329                    .as_bytes(),
330            );
331        }
332        let src = match std::str::from_utf8(body) {
333            Ok(s) => s,
334            Err(_) => return resp::err(b"script body is not valid UTF-8"),
335        };
336        // Redis EVAL semantics: every script that successfully runs
337        // (or even compiles) is added to the SCRIPT cache so a later
338        // EVALSHA can find it. We insert before running so a script
339        // that runs forever still gets a SCRIPT EXISTS hit (matches
340        // Redis behaviour).
341        let digest = sha1::sha1(script);
342        self.script_cache.entry(digest).or_insert_with(|| script.to_vec());
343        let vm = self.vm_for(sh.version);
344        // Bind KEYS / ARGV freshly per invocation. The `redis` host
345        // table was installed once when the Vm was constructed.
346        host::bind_keys_argv(vm, keys, args);
347        match vm.eval(src) {
348            Ok(results) => {
349                let first = results.first().copied().unwrap_or(Value::Nil);
350                marshal::value(vm, first)
351            }
352            Err(e) => resp::err(format_lua_error(&e).as_bytes()),
353        }
354    }
355
356    /// Read-only variant of [`Bridge::eval`]. The dispatcher receives
357    /// `read_only = true` for every `redis.call` from this script;
358    /// kevy-rt rejects write commands with
359    /// `-READONLY can't write against a read-only script\r\n`.
360    /// Redis 7.0+ `EVAL_RO`.
361    ///
362    /// All other semantics (KEYS / ARGV / SHA1 cache fill /
363    /// dialect routing) are identical to `eval`.
364    ///
365    /// ```
366    /// // the host sees the read-only flag and refuses writes under it
367    /// let mut b = kevy_lua::Bridge::new(|argv: &[&[u8]], read_only: bool| {
368    ///     if read_only && argv[0] == b"SET" {
369    ///         b"-READONLY can't write against a read-only script\r\n".to_vec()
370    ///     } else {
371    ///         b"+OK\r\n".to_vec()
372    ///     }
373    /// });
374    /// let script = b"return redis.pcall('SET', KEYS[1], 'v')";
375    /// assert_eq!(b.eval(script, &[b"k"], &[]), b"+OK\r\n");
376    /// assert!(b.eval_ro(script, &[b"k"], &[]).starts_with(b"-READONLY"));
377    /// ```
378    pub fn eval_ro(&mut self, script: &[u8], keys: &[&[u8]], args: &[&[u8]]) -> Reply {
379        self.read_only.set(true);
380        let r = self.eval(script, keys, args);
381        self.read_only.set(false);
382        r
383    }
384
385    /// Read-only variant of [`Bridge::evalsha`]. Redis 7.0+ `EVALSHA_RO`.
386    ///
387    /// ```
388    /// let mut b = kevy_lua::Bridge::new(|_argv: &[&[u8]], read_only: bool| {
389    ///     if read_only { b":1\r\n".to_vec() } else { b":0\r\n".to_vec() }
390    /// });
391    /// let sha = b.script_load(b"return redis.call('PING')");
392    /// assert_eq!(b.evalsha_ro(sha, &[], &[]), b":1\r\n");
393    /// assert_eq!(b.evalsha(sha, &[], &[]), b":0\r\n");
394    /// ```
395    pub fn evalsha_ro(&mut self, sha1: ScriptSha1, keys: &[&[u8]], args: &[&[u8]]) -> Reply {
396        self.read_only.set(true);
397        let r = self.evalsha(sha1, keys, args);
398        self.read_only.set(false);
399        r
400    }
401
402    /// Run a previously-cached script by SHA1 hex.
403    ///
404    /// Returns `-NOSCRIPT ...` if the script isn't in the cache.
405    /// Identical to running `eval` with the cached bytes — the same
406    /// shebang routing, KEYS/ARGV binding, and redis.* host plumbing
407    /// apply.
408    ///
409    /// ```
410    /// let mut b = kevy_lua::Bridge::with_no_dispatch();
411    /// let sha = b.script_load(b"return ARGV[1]");
412    /// assert_eq!(b.evalsha(sha, &[], &[b"hi"]), b"$2\r\nhi\r\n");
413    /// assert!(b.evalsha([0; 20], &[], &[]).starts_with(b"-NOSCRIPT"));
414    /// ```
415    pub fn evalsha(&mut self, sha1: ScriptSha1, keys: &[&[u8]], args: &[&[u8]]) -> Reply {
416        let Some(script) = self.script_cache.get(&sha1).cloned() else {
417            return resp::err(b"NOSCRIPT No matching script. Please use EVAL.");
418        };
419        self.eval(&script, keys, args)
420    }
421
422    /// Cache a script without running it. Returns the SHA1 digest;
423    /// the operator-side wire layer hex-encodes it for the Redis
424    /// SCRIPT LOAD reply.
425    ///
426    /// ```
427    /// let mut b = kevy_lua::Bridge::with_no_dispatch();
428    /// let sha = b.script_load(b"return 1");
429    /// assert_eq!(&kevy_lua::sha1::hex(&sha), b"e0e1f9fabfc9d4800c877a703b823ac0578ff8db");
430    /// ```
431    pub fn script_load(&mut self, script: &[u8]) -> ScriptSha1 {
432        let digest = sha1::sha1(script);
433        self.script_cache.insert(digest, script.to_vec());
434        digest
435    }
436
437    /// Test which of the given SHA1s are in the cache. Returns a
438    /// vector with `true`/`false` for each input SHA1 in order.
439    ///
440    /// ```
441    /// let mut b = kevy_lua::Bridge::with_no_dispatch();
442    /// let loaded = b.script_load(b"return 1");
443    /// assert_eq!(b.script_exists(&[loaded, [0; 20]]), [true, false]);
444    /// ```
445    #[must_use]
446    pub fn script_exists(&self, sha1s: &[ScriptSha1]) -> Vec<bool> {
447        sha1s.iter().map(|s| self.script_cache.contains_key(s)).collect()
448    }
449
450    /// Drop the SHA1 cache + all per-dialect VMs. `ASYNC` and `SYNC`
451    /// are currently both implemented as synchronous; the tag is
452    /// preserved for future differentiation.
453    ///
454    /// ```
455    /// let mut b = kevy_lua::Bridge::with_no_dispatch();
456    /// let sha = b.script_load(b"return 1");
457    /// b.script_flush(kevy_lua::FlushMode::default());
458    /// assert!(b.evalsha(sha, &[], &[]).starts_with(b"-NOSCRIPT"));
459    /// ```
460    pub fn script_flush(&mut self, _mode: FlushMode) {
461        for slot in &mut self.vms {
462            *slot = None;
463        }
464        self.script_cache.clear();
465    }
466}
467
468impl Default for Bridge {
469    /// Equivalent to [`Bridge::with_no_dispatch`] — the safe default
470    /// for embedders that don't have a host dispatch wired yet.
471    fn default() -> Self {
472        Self::with_no_dispatch()
473    }
474}
475
476// Send and Sync are part of the public contract: a change that loses
477// either fails to compile here rather than in a caller.
478const _: () = {
479    const fn send_sync<T: Send + Sync>() {}
480    send_sync::<FlushMode>();
481};