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};