Skip to main content

featherbit/plugins/script/
lua_runtime.rs

1//! Lua scripting runtime for the `script` plugin, built on mlua's Luau VM.
2//!
3//! Marshals the gateway `Context` into a Lua table, calls the script's
4//! global `execute(ctx)` function, and marshals the returned table back into
5//! a `Context`. Also installs a sandboxed `require` restricted to a
6//! configured modules directory.
7
8use mlua::prelude::*;
9use std::collections::HashMap;
10use std::path::PathBuf;
11use std::sync::atomic::{AtomicBool, Ordering};
12use std::sync::Arc;
13use std::time::{Duration, Instant};
14
15use crate::context::{Context, GatewayError, GatewayRequest, GatewayResponse, Protocol};
16use crate::plugins::PluginExecutionError;
17
18/// The one outcome port a script may name: `return ctx, "respond"`.
19pub(crate) const RESPOND_PORT: &str = "respond";
20
21/// Holds a validated Lua script and executes it against a `Context`.
22///
23/// A fresh Lua VM is created for every execution, so scripts cannot leak
24/// state between requests; only the source text is retained between calls.
25pub struct LuaRuntime {
26    /// The full script source, re-loaded into a fresh VM per execution.
27    source: String,
28    /// Directory the sandboxed `require` resolves modules from; `None`
29    /// disables `require`.
30    modules_path: Option<PathBuf>,
31    /// Wall-clock budget for one execution, covering both loading the
32    /// source and the `execute(ctx)` call. Enforced by a Luau VM interrupt;
33    /// `0` disables enforcement.
34    timeout_ms: u64,
35}
36
37impl LuaRuntime {
38    /// Compiles and validates the script in a throwaway VM, failing early if
39    /// the source has syntax errors, its top level errors on load, or it does
40    /// not define a global `execute` function. This runs once at
41    /// policy-compile time, not per request.
42    pub fn new(
43        source: &str,
44        timeout_ms: u64,
45        modules_path: Option<PathBuf>,
46    ) -> Result<Self, String> {
47        // Validate the script compiles. This executes the source's top
48        // level, so it needs the same deadline the request path gets: a
49        // `while true do end` at the top level would otherwise hang the
50        // Admin API thread serving `PUT /api/policies`, with no request
51        // involved.
52        let lua = Lua::new();
53        let timed_out = install_deadline(&lua, timeout_ms);
54        setup_module_loader(&lua, &modules_path);
55        lua.load(source).exec().map_err(|e| {
56            if timed_out.load(Ordering::Relaxed) {
57                format!(
58                    "Lua script exceeded its {}ms timeout while loading",
59                    timeout_ms
60                )
61            } else {
62                format!("Lua compilation error: {}", e)
63            }
64        })?;
65
66        // Verify execute function exists
67        lua.globals()
68            .get::<LuaFunction>("execute")
69            .map_err(|_| "Lua script must define an 'execute(ctx)' function".to_string())?;
70
71        Ok(Self {
72            source: source.to_string(),
73            modules_path,
74            timeout_ms,
75        })
76    }
77
78    /// Runs the script's `execute(ctx)` against the given context in a fresh
79    /// VM and returns the context rebuilt from the table the script returned,
80    /// and the port the script named, if any (`"respond"` or the implicit
81    /// `success`).
82    ///
83    /// Every failure mode (load, marshalling either way, missing `execute`,
84    /// a runtime error raised by the script, or an unrecognized second
85    /// return value) returns a `PluginExecutionError` carrying the original
86    /// context, with a distinguishing error code (`LUA_LOAD_ERROR`,
87    /// `LUA_MARSHAL_ERROR`, `LUA_MISSING_EXECUTE`, `LUA_EXECUTION_ERROR`,
88    /// `LUA_UNMARSHAL_ERROR`, `LUA_BAD_PORT`, and `LUA_TIMEOUT` when the
89    /// script outran `timeout_ms`), so the graph engine routes through the
90    /// error port exactly like a native plugin failure.
91    ///
92    /// `timeout_ms` is a single budget covering both loading the source and
93    /// the `execute(ctx)` call.
94    ///
95    /// Shares the load/marshal/call core with [`execute_without_port`](Self::execute_without_port)
96    /// via [`call_execute`](Self::call_execute); only the judgment of the
97    /// second return value differs between the two.
98    pub fn execute(
99        &self,
100        ctx: Context,
101    ) -> Result<(Context, Option<&'static str>), PluginExecutionError> {
102        let lua = Lua::new();
103        let timed_out = install_deadline(&lua, self.timeout_ms);
104        setup_module_loader(&lua, &self.modules_path);
105        let (ctx, result_table, second) = self.call_execute(&lua, &timed_out, ctx)?;
106
107        // The optional second value names the port the node leaves on. It is
108        // a decision the script states, never something inferred from the
109        // response it left behind: a script that set status 403 and returned
110        // one value continues on success, exactly as before this existed.
111        let port = match second {
112            None | Some(LuaValue::Nil) => None,
113            Some(LuaValue::String(s)) if s.to_str().map(|s| s == RESPOND_PORT).unwrap_or(false) => {
114                Some(RESPOND_PORT)
115            }
116            Some(LuaValue::String(s)) if s.to_str().map(|s| s == "success").unwrap_or(false) => {
117                None
118            }
119            Some(other) => {
120                return Err(PluginExecutionError {
121                    context: ctx,
122                    error: GatewayError {
123                        node_id: String::new(),
124                        code: "LUA_BAD_PORT".to_string(),
125                        message: format!(
126                            "execute(ctx) returned {} as the port; expected \"respond\" or \"success\" (or no second value)",
127                            shown_lua_value(&other)
128                        ),
129                        metadata: HashMap::new(),
130                    },
131                });
132            }
133        };
134
135        self.finish_unmarshal(ctx, &result_table)
136            .map(|new_ctx| (new_ctx, port))
137    }
138
139    /// `execute` for node types that declare no outcome port: any named port
140    /// other than `"success"` is a `LUA_BAD_PORT` failure with the ORIGINAL
141    /// context, and the message says so without pointing at `respond`.
142    ///
143    /// Used by `serverless-pre-function`/`serverless-post-function`, which
144    /// share this Lua runtime with `script` but have no `respond` port (or
145    /// any outcome port) to leave on — a function that names one anyway is
146    /// rejected here, at the point the original `ctx` is still in hand,
147    /// rather than one layer up with a serverless-specific message.
148    pub fn execute_without_port(&self, ctx: Context) -> Result<Context, PluginExecutionError> {
149        let lua = Lua::new();
150        let timed_out = install_deadline(&lua, self.timeout_ms);
151        setup_module_loader(&lua, &self.modules_path);
152        let (ctx, result_table, second) = self.call_execute(&lua, &timed_out, ctx)?;
153
154        match second {
155            None | Some(LuaValue::Nil) => {}
156            Some(LuaValue::String(s)) if s.to_str().map(|s| s == "success").unwrap_or(false) => {}
157            Some(other) => {
158                return Err(PluginExecutionError {
159                    context: ctx,
160                    error: GatewayError {
161                        node_id: String::new(),
162                        code: "LUA_BAD_PORT".to_string(),
163                        message: format!(
164                            "execute(ctx) returned port {}; this node type declares no outcome port — only a `script` node can answer with \"respond\"",
165                            shown_lua_value(&other)
166                        ),
167                        metadata: HashMap::new(),
168                    },
169                });
170            }
171        }
172
173        self.finish_unmarshal(ctx, &result_table)
174    }
175
176    /// Shared core of [`execute`](Self::execute) and
177    /// [`execute_without_port`](Self::execute_without_port): loads the
178    /// script, marshals `ctx`, calls `execute(ctx)`, and returns the table it
179    /// returned together with the raw second return value, still unjudged,
180    /// and the original `ctx` — untouched, since it was only ever borrowed
181    /// to build the Lua table. This lets a caller that rejects the second
182    /// value still hand back the pristine original context, never the one
183    /// rebuilt from what the script returned.
184    ///
185    /// Takes `lua` and `timed_out` by reference rather than creating them
186    /// itself: the returned `LuaTable`/`LuaValue` are only valid as long as
187    /// the `Lua` instance that produced them is alive, so it must live in
188    /// the caller's stack frame, not be dropped when this function returns.
189    fn call_execute(
190        &self,
191        lua: &Lua,
192        timed_out: &Arc<AtomicBool>,
193        ctx: Context,
194    ) -> Result<(Context, LuaTable, Option<LuaValue>), PluginExecutionError> {
195        if let Err(e) = lua.load(&self.source).exec() {
196            return Err(PluginExecutionError {
197                context: ctx,
198                error: GatewayError {
199                    node_id: String::new(),
200                    code: failure_code(timed_out, "LUA_LOAD_ERROR"),
201                    message: format!("Failed to load Lua script: {}", e),
202                    metadata: HashMap::new(),
203                },
204            });
205        }
206
207        let ctx_table = match context_to_lua(lua, &ctx) {
208            Ok(t) => t,
209            Err(e) => {
210                return Err(PluginExecutionError {
211                    context: ctx,
212                    error: GatewayError {
213                        node_id: String::new(),
214                        code: "LUA_MARSHAL_ERROR".to_string(),
215                        message: format!("Failed to marshal context to Lua: {}", e),
216                        metadata: HashMap::new(),
217                    },
218                });
219            }
220        };
221
222        let execute_fn: LuaFunction = match lua.globals().get("execute") {
223            Ok(f) => f,
224            Err(e) => {
225                return Err(PluginExecutionError {
226                    context: ctx,
227                    error: GatewayError {
228                        node_id: String::new(),
229                        code: "LUA_MISSING_EXECUTE".to_string(),
230                        message: format!("Missing execute function: {}", e),
231                        metadata: HashMap::new(),
232                    },
233                });
234            }
235        };
236
237        let returned: LuaMultiValue = match execute_fn.call(ctx_table) {
238            Ok(v) => v,
239            Err(e) => {
240                return Err(PluginExecutionError {
241                    context: ctx,
242                    error: GatewayError {
243                        node_id: String::new(),
244                        code: failure_code(timed_out, "LUA_EXECUTION_ERROR"),
245                        message: format!("Lua execution error: {}", e),
246                        metadata: HashMap::new(),
247                    },
248                });
249            }
250        };
251        let mut returned = returned.into_iter();
252
253        let result_table: LuaTable = match returned.next() {
254            Some(LuaValue::Table(t)) => t,
255            other => {
256                return Err(PluginExecutionError {
257                    context: ctx,
258                    error: GatewayError {
259                        node_id: String::new(),
260                        code: "LUA_UNMARSHAL_ERROR".to_string(),
261                        message: format!(
262                            "execute(ctx) must return the ctx table; got {}",
263                            other.map(|v| v.type_name()).unwrap_or("nothing")
264                        ),
265                        metadata: HashMap::new(),
266                    },
267                });
268            }
269        };
270
271        let second = returned.next();
272        Ok((ctx, result_table, second))
273    }
274
275    /// Rebuilds the `Context` from `result_table`, carrying over the fields
276    /// scripts never see (the wire protocol and the errors accumulated by
277    /// earlier nodes) from `ctx`. On failure the ORIGINAL `ctx` travels with
278    /// the error, not a partially-rebuilt one.
279    fn finish_unmarshal(
280        &self,
281        ctx: Context,
282        result_table: &LuaTable,
283    ) -> Result<Context, PluginExecutionError> {
284        let protocol = ctx.request.protocol.clone();
285        let errors = ctx.errors.clone();
286        match lua_to_context(result_table, protocol, errors) {
287            Ok(new_ctx) => Ok(new_ctx),
288            Err(e) => Err(PluginExecutionError {
289                context: ctx,
290                error: GatewayError {
291                    node_id: String::new(),
292                    code: "LUA_UNMARSHAL_ERROR".to_string(),
293                    message: format!("Failed to unmarshal context from Lua: {}", e),
294                    metadata: HashMap::new(),
295                },
296            }),
297        }
298    }
299}
300
301/// Renders a Lua value for an error message: a string quoted (lossily, so a
302/// non-UTF-8 second return value renders instead of panicking), anything
303/// else by its Lua type name.
304fn shown_lua_value(value: &LuaValue) -> String {
305    match value {
306        LuaValue::String(s) => format!("\"{}\"", s.to_string_lossy()),
307        v => v.type_name().to_string(),
308    }
309}
310
311/// Registers a custom `require()` loader that reads `.lua` files from the modules directory.
312///
313/// The loader is sandboxed: module names containing `..`, `/`, or `\` are
314/// rejected, so only files directly inside `modules_path` can be loaded
315/// (resolved as `<modules_path>/<name>.lua`). When `modules_path` is `None`,
316/// no `require` is installed. Note: modules are re-evaluated on every
317/// `require`; results are not cached.
318fn setup_module_loader(lua: &Lua, modules_path: &Option<PathBuf>) {
319    let Some(base_path) = modules_path.clone() else {
320        return;
321    };
322
323    // In Luau, we use the require override approach
324    let loader = lua
325        .create_function(move |lua, module_name: String| {
326            // Sanitize: no path traversal
327            if module_name.contains("..") || module_name.contains('/') || module_name.contains('\\')
328            {
329                return Err(LuaError::runtime(format!(
330                    "Invalid module name '{}': path traversal not allowed",
331                    module_name
332                )));
333            }
334
335            // Try module_name.lua
336            let file_path = base_path.join(format!("{}.lua", module_name));
337            let source = std::fs::read_to_string(&file_path).map_err(|e| {
338                LuaError::runtime(format!(
339                    "Cannot load module '{}' from {:?}: {}",
340                    module_name, file_path, e
341                ))
342            })?;
343
344            // Execute the module and return its result
345            lua.load(&source).eval::<LuaValue>().map_err(|e| {
346                LuaError::runtime(format!("Error loading module '{}': {}", module_name, e))
347            })
348        })
349        .expect("Failed to create module loader");
350
351    lua.globals()
352        .set("require", loader)
353        .expect("Failed to set require");
354}
355
356/// Marshals a `Context` into a Lua table with `request`, `response`, and
357/// `message` sub-tables. Header and query-param values become 1-indexed
358/// arrays of strings; bodies become Lua strings; `message` values are
359/// converted from JSON. `errors` is not exposed to scripts.
360fn context_to_lua(lua: &Lua, ctx: &Context) -> LuaResult<LuaTable> {
361    let table = lua.create_table()?;
362
363    // request
364    let req = lua.create_table()?;
365    req.set("method", ctx.request.method.as_str())?;
366    req.set("path", ctx.request.path.as_str())?;
367    req.set("host", ctx.request.host.as_str())?;
368    req.set("scheme", ctx.request.scheme.as_str())?;
369    req.set("remote_addr", ctx.request.remote_addr.as_str())?;
370
371    let headers = lua.create_table()?;
372    for (k, v) in &ctx.request.headers {
373        let vals = lua.create_table()?;
374        for (i, val) in v.iter().enumerate() {
375            vals.set(i + 1, val.as_str())?;
376        }
377        headers.set(k.as_str(), vals)?;
378    }
379    req.set("headers", headers)?;
380
381    let query = lua.create_table()?;
382    for (k, v) in &ctx.request.query_params {
383        let vals = lua.create_table()?;
384        for (i, val) in v.iter().enumerate() {
385            vals.set(i + 1, val.as_str())?;
386        }
387        query.set(k.as_str(), vals)?;
388    }
389    req.set("query_params", query)?;
390
391    req.set("body", lua.create_string(&ctx.request.body)?)?;
392    table.set("request", req)?;
393
394    // response
395    let resp = lua.create_table()?;
396    resp.set("status_code", ctx.response.status_code)?;
397    let resp_headers = lua.create_table()?;
398    for (k, v) in &ctx.response.headers {
399        let vals = lua.create_table()?;
400        for (i, val) in v.iter().enumerate() {
401            vals.set(i + 1, val.as_str())?;
402        }
403        resp_headers.set(k.as_str(), vals)?;
404    }
405    resp.set("headers", resp_headers)?;
406    resp.set("body", lua.create_string(&ctx.response.body)?)?;
407    table.set("response", resp)?;
408
409    // message
410    let msg = lua.create_table()?;
411    for (k, v) in &ctx.message {
412        let lua_val = json_to_lua(lua, v)?;
413        msg.set(k.as_str(), lua_val)?;
414    }
415    table.set("message", msg)?;
416
417    Ok(table)
418}
419
420/// Rebuilds a `Context` from the table returned by the script's `execute`.
421///
422/// `request` and `response` (including their `headers` and bodies) are
423/// required and fail unmarshalling if malformed; `query_params` and
424/// `message` are optional. `protocol` and `errors` are not exposed to Lua,
425/// so the caller passes the original context's values through unchanged.
426/// Lua text for a scalar. Numbers and booleans are accepted because a script
427/// that writes `ctx.response.status_code = 200` or a numeric header value
428/// means the obvious thing.
429fn scalar_text(value: &LuaValue) -> Option<String> {
430    match value {
431        LuaValue::String(s) => Some(String::from_utf8_lossy(&s.as_bytes()).into_owned()),
432        LuaValue::Integer(i) => Some(i.to_string()),
433        LuaValue::Number(n) => Some(n.to_string()),
434        LuaValue::Boolean(b) => Some(b.to_string()),
435        _ => None,
436    }
437}
438
439/// A required string field, named in the error so the script author knows
440/// which one to fix.
441fn field_string(table: &LuaTable, field: &str, at: &str) -> Result<String, String> {
442    let value: LuaValue = table
443        .get(field)
444        .map_err(|e| format!("{at}.{field} could not be read: {e}"))?;
445    scalar_text(&value).ok_or_else(|| {
446        format!(
447            "{at}.{field} must be a string, got {} — keep the field the script received",
448            value.type_name()
449        )
450    })
451}
452
453/// A header or query-parameter map. The canonical shape is a list of strings
454/// per key (that is what the script is handed), but `headers["x-user"] =
455/// "alice"` is the natural thing to write, so a bare scalar is accepted as a
456/// one-element list. Anything else names the offending key.
457fn field_string_lists(
458    table: &LuaTable,
459    field: &str,
460    at: &str,
461) -> Result<HashMap<String, Vec<String>>, String> {
462    let value: LuaValue = table
463        .get(field)
464        .map_err(|e| format!("{at}.{field} could not be read: {e}"))?;
465    let map = match value {
466        LuaValue::Nil => return Ok(HashMap::new()),
467        LuaValue::Table(t) => t,
468        other => {
469            return Err(format!(
470                "{at}.{field} must be a table of name -> value, got {}",
471                other.type_name()
472            ))
473        }
474    };
475
476    let mut out = HashMap::new();
477    for pair in map.pairs::<LuaValue, LuaValue>() {
478        let (k, v) = pair.map_err(|e| format!("{at}.{field}: {e}"))?;
479        let key = scalar_text(&k)
480            .ok_or_else(|| format!("{at}.{field} has a non-string key ({})", k.type_name()))?;
481        let values = match v {
482            LuaValue::Table(list) => {
483                let mut vals = Vec::new();
484                for (i, item) in list.sequence_values::<LuaValue>().enumerate() {
485                    let item = item.map_err(|e| format!("{at}.{field}['{key}'][{}]: {e}", i + 1))?;
486                    vals.push(scalar_text(&item).ok_or_else(|| {
487                        format!(
488                            "{at}.{field}['{key}'][{}] must be a string, got {}",
489                            i + 1,
490                            item.type_name()
491                        )
492                    })?);
493                }
494                vals
495            }
496            // `headers["x-user"] = "alice"` — accept it as {"alice"}.
497            scalar => vec![scalar_text(&scalar).ok_or_else(|| {
498                format!(
499                    "{at}.{field}['{key}'] must be a string or a table of strings (e.g. {{\"a\", \"b\"}}), got {}",
500                    scalar.type_name()
501                )
502            })?],
503        };
504        out.insert(key, values);
505    }
506    Ok(out)
507}
508
509/// A body field: a string, a scalar, or nil for "no body".
510fn field_body(table: &LuaTable, at: &str) -> Result<bytes::Bytes, String> {
511    let value: LuaValue = table
512        .get("body")
513        .map_err(|e| format!("{at}.body could not be read: {e}"))?;
514    match value {
515        LuaValue::Nil => Ok(bytes::Bytes::new()),
516        LuaValue::String(s) => Ok(bytes::Bytes::from(s.as_bytes().to_vec())),
517        other => scalar_text(&other).map(bytes::Bytes::from).ok_or_else(|| {
518            format!(
519                "{at}.body must be a string, got {} — encode tables yourself (e.g. with a JSON string)",
520                other.type_name()
521            )
522        }),
523    }
524}
525
526/// The context table a script returned, field by field. Every failure names
527/// the field: an opaque "error converting Lua string to table" left script
528/// authors (and agents) guessing at the shape.
529fn lua_to_context(
530    table: &LuaTable,
531    protocol: Protocol,
532    errors: Vec<GatewayError>,
533) -> Result<Context, String> {
534    let sub_table = |field: &str| -> Result<LuaTable, String> {
535        let value: LuaValue = table
536            .get(field)
537            .map_err(|e| format!("ctx.{field} could not be read: {e}"))?;
538        match value {
539            LuaValue::Table(t) => Ok(t),
540            other => Err(format!(
541                "ctx.{field} must be a table, got {} — return the context you were given (`return ctx`), with your changes applied",
542                other.type_name()
543            )),
544        }
545    };
546    let req_table = sub_table("request")?;
547    let resp_table = sub_table("response")?;
548
549    let request = GatewayRequest {
550        method: field_string(&req_table, "method", "ctx.request")?,
551        path: field_string(&req_table, "path", "ctx.request")?,
552        host: field_string(&req_table, "host", "ctx.request")?,
553        scheme: field_string(&req_table, "scheme", "ctx.request")?,
554        headers: field_string_lists(&req_table, "headers", "ctx.request")?,
555        query_params: field_string_lists(&req_table, "query_params", "ctx.request")?,
556        body: field_body(&req_table, "ctx.request")?,
557        remote_addr: field_string(&req_table, "remote_addr", "ctx.request")?,
558        protocol,
559    };
560
561    let status_value: LuaValue = resp_table
562        .get("status_code")
563        .map_err(|e| format!("ctx.response.status_code could not be read: {e}"))?;
564    let status_code = match &status_value {
565        LuaValue::Integer(i) => u16::try_from(*i).ok(),
566        LuaValue::Number(n) => (n.fract() == 0.0)
567            .then_some(*n as i64)
568            .and_then(|i| u16::try_from(i).ok()),
569        LuaValue::String(s) => String::from_utf8_lossy(&s.as_bytes()).parse().ok(),
570        _ => None,
571    }
572    .ok_or_else(|| {
573        format!(
574            "ctx.response.status_code must be an HTTP status number (e.g. 200), got {}",
575            status_value.type_name()
576        )
577    })?;
578
579    let response = GatewayResponse {
580        status_code,
581        headers: field_string_lists(&resp_table, "headers", "ctx.response")?,
582        body: field_body(&resp_table, "ctx.response")?,
583        // Rebuilding from the Lua table always discards any stream: safe only
584        // because `script` does not opt out of `Plugin::reads_response_body()`
585        // (defaults to `true`), so the policy compiler never marks an
586        // upstream stream-capable when a script node sits downstream — a
587        // stream can never reach here.
588        stream: None,
589    };
590
591    let mut message = HashMap::new();
592    if let Ok(LuaValue::Table(msg_table)) = table.get::<LuaValue>("message") {
593        for pair in msg_table.pairs::<String, LuaValue>() {
594            let (k, v) = pair.map_err(|e| format!("ctx.message: {e}"))?;
595            message.insert(k, lua_to_json(&v));
596        }
597    }
598
599    Ok(Context {
600        request,
601        response,
602        message,
603        errors,
604    })
605}
606
607/// Converts a JSON value to the corresponding Lua value (arrays become
608/// 1-indexed tables, objects become string-keyed tables).
609fn json_to_lua(lua: &Lua, value: &serde_json::Value) -> LuaResult<LuaValue> {
610    match value {
611        serde_json::Value::Null => Ok(LuaValue::Nil),
612        serde_json::Value::Bool(b) => Ok(LuaValue::Boolean(*b)),
613        serde_json::Value::Number(n) => {
614            if let Some(i) = n.as_i64() {
615                Ok(LuaValue::Integer(i as _))
616            } else {
617                Ok(LuaValue::Number(n.as_f64().unwrap_or(0.0)))
618            }
619        }
620        serde_json::Value::String(s) => Ok(LuaValue::String(lua.create_string(s)?)),
621        serde_json::Value::Array(arr) => {
622            let table = lua.create_table()?;
623            for (i, v) in arr.iter().enumerate() {
624                table.set(i + 1, json_to_lua(lua, v)?)?;
625            }
626            Ok(LuaValue::Table(table))
627        }
628        serde_json::Value::Object(obj) => {
629            let table = lua.create_table()?;
630            for (k, v) in obj {
631                table.set(k.as_str(), json_to_lua(lua, v)?)?;
632            }
633            Ok(LuaValue::Table(table))
634        }
635    }
636}
637
638/// Converts a Lua value back to JSON. Tables with a non-zero sequence length
639/// become JSON arrays, other tables become objects with string keys;
640/// unconvertible values (functions, userdata, non-UTF-8 strings) degrade to
641/// null or empty strings rather than erroring.
642fn lua_to_json(value: &LuaValue) -> serde_json::Value {
643    match value {
644        LuaValue::Nil => serde_json::Value::Null,
645        LuaValue::Boolean(b) => serde_json::Value::Bool(*b),
646        LuaValue::Integer(i) => serde_json::json!(*i),
647        LuaValue::Number(n) => serde_json::json!(*n),
648        LuaValue::String(s) => {
649            serde_json::Value::String(std::str::from_utf8(&s.as_bytes()).unwrap_or("").to_string())
650        }
651        LuaValue::Table(t) => {
652            let len = t.raw_len();
653            if len > 0 {
654                let arr: Vec<serde_json::Value> = (1..=len)
655                    .filter_map(|i| t.get::<LuaValue>(i).ok().map(|v| lua_to_json(&v)))
656                    .collect();
657                serde_json::Value::Array(arr)
658            } else {
659                let mut map = serde_json::Map::new();
660                if let Ok(pairs) = t
661                    .clone()
662                    .pairs::<String, LuaValue>()
663                    .collect::<Result<Vec<_>, _>>()
664                {
665                    for (k, v) in pairs {
666                        map.insert(k, lua_to_json(&v));
667                    }
668                }
669                serde_json::Value::Object(map)
670            }
671        }
672        _ => serde_json::Value::Null,
673    }
674}
675
676/// Installs a wall-clock deadline on `lua`, returning the flag its interrupt
677/// sets when the budget runs out.
678///
679/// Luau calls the interrupt at VM instruction boundaries, so a tight loop is
680/// caught; time spent inside a Rust callback or `require`'s file IO is not.
681/// Returning an error from the interrupt propagates it through whatever the
682/// VM was executing, which is what stops the script.
683///
684/// The flag -- not the error message -- is what distinguishes a deadline
685/// abort from an ordinary script fault, so the two never blur together if
686/// the message is ever reworded.
687///
688/// `timeout_ms: 0` installs nothing: an explicit opt-out for a trusted
689/// long-running script.
690fn install_deadline(lua: &Lua, timeout_ms: u64) -> Arc<AtomicBool> {
691    let timed_out = Arc::new(AtomicBool::new(false));
692    if timeout_ms == 0 {
693        return timed_out;
694    }
695
696    let deadline = Instant::now() + Duration::from_millis(timeout_ms);
697    let flag = Arc::clone(&timed_out);
698    lua.set_interrupt(move |_| {
699        if Instant::now() >= deadline {
700            flag.store(true, Ordering::Relaxed);
701            Err(LuaError::runtime(format!(
702                "script exceeded its {}ms timeout",
703                timeout_ms
704            )))
705        } else {
706            Ok(LuaVmState::Continue)
707        }
708    });
709    timed_out
710}
711
712/// The error code for a failed Lua call: `LUA_TIMEOUT` when the deadline
713/// tripped, otherwise the caller's code for that failure site.
714fn failure_code(timed_out: &Arc<AtomicBool>, default_code: &str) -> String {
715    if timed_out.load(Ordering::Relaxed) {
716        "LUA_TIMEOUT".to_string()
717    } else {
718        default_code.to_string()
719    }
720}
721
722#[cfg(test)]
723mod tests {
724    use super::*;
725    use crate::context::Protocol;
726
727    fn test_context() -> Context {
728        Context {
729            request: GatewayRequest {
730                method: "GET".to_string(),
731                path: "/test".to_string(),
732                host: "localhost".to_string(),
733                scheme: "http".to_string(),
734                headers: HashMap::new(),
735                query_params: HashMap::new(),
736                body: bytes::Bytes::new(),
737                remote_addr: "127.0.0.1:1234".to_string(),
738                protocol: Protocol::Http1,
739            },
740            response: GatewayResponse {
741                status_code: 0,
742                headers: HashMap::new(),
743                body: bytes::Bytes::new(),
744                stream: None,
745            },
746            message: HashMap::new(),
747            errors: Vec::new(),
748        }
749    }
750
751    #[test]
752    fn test_lua_modify_path() {
753        let rt = LuaRuntime::new(
754            r#"
755            function execute(ctx)
756                ctx.request.path = "/modified"
757                return ctx
758            end
759            "#,
760            5000,
761            None,
762        )
763        .unwrap();
764
765        let ctx = test_context();
766        let (result, _) = rt.execute(ctx).unwrap();
767        assert_eq!(result.request.path, "/modified");
768    }
769
770    #[test]
771    fn test_lua_set_message() {
772        let rt = LuaRuntime::new(
773            r#"
774            function execute(ctx)
775                ctx.message.enriched = true
776                ctx.message.user_id = "abc123"
777                return ctx
778            end
779            "#,
780            5000,
781            None,
782        )
783        .unwrap();
784
785        let ctx = test_context();
786        let (result, _) = rt.execute(ctx).unwrap();
787        assert_eq!(
788            result.message.get("enriched"),
789            Some(&serde_json::json!(true))
790        );
791        assert_eq!(
792            result.message.get("user_id"),
793            Some(&serde_json::json!("abc123"))
794        );
795    }
796
797    #[test]
798    fn test_lua_add_header() {
799        let rt = LuaRuntime::new(
800            r#"
801            function execute(ctx)
802                ctx.request.headers["x-custom"] = {"hello"}
803                return ctx
804            end
805            "#,
806            5000,
807            None,
808        )
809        .unwrap();
810
811        let ctx = test_context();
812        let (result, _) = rt.execute(ctx).unwrap();
813        assert_eq!(
814            result.request.headers.get("x-custom"),
815            Some(&vec!["hello".to_string()])
816        );
817    }
818
819    #[test]
820    fn test_lua_preserves_errors_and_protocol() {
821        let rt = LuaRuntime::new(
822            r#"
823            function execute(ctx)
824                return ctx
825            end
826            "#,
827            5000,
828            None,
829        )
830        .unwrap();
831
832        let mut ctx = test_context();
833        ctx.request.protocol = Protocol::Http2;
834        ctx.errors.push(GatewayError {
835            node_id: "upstream".to_string(),
836            code: "UPSTREAM_CONNECTION_ERROR".to_string(),
837            message: "boom".to_string(),
838            metadata: HashMap::new(),
839        });
840
841        let (result, _) = rt.execute(ctx).unwrap();
842        assert_eq!(result.request.protocol, Protocol::Http2);
843        assert_eq!(result.errors.len(), 1);
844        assert_eq!(result.errors[0].code, "UPSTREAM_CONNECTION_ERROR");
845    }
846
847    /// A bare `return ctx` is the whole existing contract: no port named,
848    /// the node continues on success. Every script written so far relies on it.
849    #[test]
850    fn test_lua_single_return_takes_success() {
851        let rt = LuaRuntime::new("function execute(ctx) return ctx end", 5000, None).unwrap();
852        let (_, port) = rt.execute(test_context()).unwrap();
853        assert_eq!(port, None);
854    }
855
856    /// The feature: a script names the port it wants to leave on.
857    #[test]
858    fn test_lua_second_return_respond_takes_the_respond_port() {
859        let rt = LuaRuntime::new(
860            r#"
861            function execute(ctx)
862                ctx.response.status_code = 403
863                ctx.response.body = "blocked"
864                return ctx, "respond"
865            end
866            "#,
867            5000,
868            None,
869        )
870        .unwrap();
871        let (ctx, port) = rt.execute(test_context()).unwrap();
872        assert_eq!(port, Some(RESPOND_PORT));
873        assert_eq!(
874            ctx.response.status_code, 403,
875            "the prepared response travels with the port"
876        );
877    }
878
879    /// Naming success explicitly is allowed, so a script can be spelled out.
880    #[test]
881    fn test_lua_second_return_success_is_plain_success() {
882        let rt = LuaRuntime::new(
883            "function execute(ctx) return ctx, \"success\" end",
884            5000,
885            None,
886        )
887        .unwrap();
888        let (_, port) = rt.execute(test_context()).unwrap();
889        assert_eq!(port, None);
890    }
891
892    /// `nil` is "no second value", not a bad port: `return ctx, maybe_port`
893    /// with an unset local must keep working.
894    #[test]
895    fn test_lua_second_return_nil_is_absent() {
896        let rt = LuaRuntime::new("function execute(ctx) return ctx, nil end", 5000, None).unwrap();
897        let (_, port) = rt.execute(test_context()).unwrap();
898        assert_eq!(port, None);
899    }
900
901    /// An unknown port name is a failure, and the ORIGINAL context goes down
902    /// error: the script did not finish making a decision, so nothing it
903    /// wrote is kept. The header it added must be absent from the error path.
904    #[test]
905    fn test_lua_unknown_port_is_lua_bad_port_with_the_original_context() {
906        let rt = LuaRuntime::new(
907            r#"
908            function execute(ctx)
909                ctx.request.headers["x-mutated"] = { "yes" }
910                return ctx, "client"
911            end
912            "#,
913            5000,
914            None,
915        )
916        .unwrap();
917        let err = rt.execute(test_context()).unwrap_err();
918        assert_eq!(err.error.code, "LUA_BAD_PORT");
919        assert!(
920            err.error.message.contains("client"),
921            "{}",
922            err.error.message
923        );
924        assert!(
925            err.error.message.contains("respond"),
926            "the message names the accepted values: {}",
927            err.error.message
928        );
929        assert!(
930            !err.context.request.headers.contains_key("x-mutated"),
931            "the mutated table must be discarded on a bad port"
932        );
933    }
934
935    /// A non-string second value is the same failure, not a coercion.
936    #[test]
937    fn test_lua_non_string_port_is_lua_bad_port() {
938        let rt = LuaRuntime::new("function execute(ctx) return ctx, 42 end", 5000, None).unwrap();
939        let err = rt.execute(test_context()).unwrap_err();
940        assert_eq!(err.error.code, "LUA_BAD_PORT");
941    }
942
943    /// Port names are matched exactly; a script that gets the case wrong
944    /// does not get coerced into the port it probably meant.
945    #[test]
946    fn test_lua_port_name_is_case_sensitive() {
947        let rt = LuaRuntime::new(
948            "function execute(ctx) return ctx, \"Respond\" end",
949            5000,
950            None,
951        )
952        .unwrap();
953        let err = rt.execute(test_context()).unwrap_err();
954        assert_eq!(err.error.code, "LUA_BAD_PORT");
955    }
956
957    /// A non-UTF-8 second value must be reported as a bad port, not panic
958    /// while rendering it into the error message.
959    #[test]
960    fn test_lua_non_utf8_port_is_lua_bad_port_without_panicking() {
961        let rt = LuaRuntime::new(
962            "function execute(ctx) return ctx, \"\\255\\254\" end",
963            5000,
964            None,
965        )
966        .unwrap();
967        let err = rt.execute(test_context()).unwrap_err();
968        assert_eq!(err.error.code, "LUA_BAD_PORT");
969    }
970
971    /// A first return value that is not the ctx table at all is an
972    /// unmarshal failure, not an execution failure -- pinning a behavior
973    /// change from reading `execute`'s return as a `LuaMultiValue`: before,
974    /// `execute_fn.call::<LuaTable>(..)` made mlua itself reject a
975    /// non-table return as `LUA_EXECUTION_ERROR` (a `FromLua` conversion
976    /// error surfaced through the call); now the call always succeeds (it
977    /// no longer asks mlua to convert anything), and the first returned
978    /// value is inspected by hand, so a non-table first value is reported
979    /// as `LUA_UNMARSHAL_ERROR` instead.
980    #[test]
981    fn test_lua_non_table_return_is_unmarshal_error() {
982        let rt = LuaRuntime::new("function execute(ctx) return 5 end", 5000, None).unwrap();
983        let err = rt.execute(test_context()).unwrap_err();
984        assert_eq!(err.error.code, "LUA_UNMARSHAL_ERROR");
985        assert!(
986            err.error.message.contains("must return the ctx table"),
987            "{}",
988            err.error.message
989        );
990
991        let rt = LuaRuntime::new("function execute(ctx) return end", 5000, None).unwrap();
992        let err = rt.execute(test_context()).unwrap_err();
993        assert_eq!(err.error.code, "LUA_UNMARSHAL_ERROR");
994        assert!(
995            err.error.message.contains("nothing"),
996            "{}",
997            err.error.message
998        );
999    }
1000
1001    /// Runs `body` as the whole of `execute`, returning the result context
1002    /// (the port is not interesting to these tests, so it is discarded here).
1003    fn run(body: &str) -> Result<Context, PluginExecutionError> {
1004        let rt =
1005            LuaRuntime::new(&format!("function execute(ctx)\n{body}\nend"), 5000, None).unwrap();
1006        rt.execute(test_context()).map(|(ctx, _)| ctx)
1007    }
1008
1009    /// The shape a script author naturally writes — a bare string header
1010    /// value — used to fail with an opaque conversion error.
1011    #[test]
1012    fn test_lua_scalar_header_and_query_values_are_accepted() {
1013        let ctx = run(r#"
1014            ctx.request.headers["x-user"] = "alice"
1015            ctx.request.headers["x-retry"] = 3
1016            ctx.request.query_params["page"] = "2"
1017            ctx.response.headers["x-served"] = "yes"
1018            return ctx
1019        "#)
1020        .unwrap();
1021        assert_eq!(ctx.request.headers["x-user"], vec!["alice"]);
1022        assert_eq!(ctx.request.headers["x-retry"], vec!["3"]);
1023        assert_eq!(ctx.request.query_params["page"], vec!["2"]);
1024        assert_eq!(ctx.response.headers["x-served"], vec!["yes"]);
1025    }
1026
1027    #[test]
1028    fn test_lua_list_header_values_still_work() {
1029        let ctx = run(r#"
1030            ctx.request.headers["accept"] = {"text/plain", "application/json"}
1031            return ctx
1032        "#)
1033        .unwrap();
1034        assert_eq!(
1035            ctx.request.headers["accept"],
1036            vec!["text/plain", "application/json"]
1037        );
1038    }
1039
1040    #[test]
1041    fn test_lua_nil_body_and_numeric_status_are_accepted() {
1042        let ctx = run(r#"
1043            ctx.request.body = nil
1044            ctx.response.status_code = 201
1045            ctx.response.body = "ok"
1046            return ctx
1047        "#)
1048        .unwrap();
1049        assert!(ctx.request.body.is_empty());
1050        assert_eq!(ctx.response.status_code, 201);
1051        assert_eq!(ctx.response.body.as_ref(), b"ok");
1052    }
1053
1054    #[test]
1055    fn test_lua_unmarshal_errors_name_the_offending_field() {
1056        // A table where a string belongs.
1057        let err = run(r#"
1058            ctx.request.path = {"/oops"}
1059            return ctx
1060        "#)
1061        .unwrap_err();
1062        assert_eq!(err.error.code, "LUA_UNMARSHAL_ERROR");
1063        assert!(
1064            err.error
1065                .message
1066                .contains("ctx.request.path must be a string"),
1067            "{}",
1068            err.error.message
1069        );
1070
1071        // A table where a body belongs: the message says to encode it.
1072        let err = run(r#"
1073            ctx.response.body = { ok = true }
1074            return ctx
1075        "#)
1076        .unwrap_err();
1077        assert!(
1078            err.error
1079                .message
1080                .contains("ctx.response.body must be a string"),
1081            "{}",
1082            err.error.message
1083        );
1084
1085        // A nested table inside a header list.
1086        let err = run(r#"
1087            ctx.request.headers["x"] = {{"nested"}}
1088            return ctx
1089        "#)
1090        .unwrap_err();
1091        assert!(
1092            err.error.message.contains("ctx.request.headers['x'][1]"),
1093            "{}",
1094            err.error.message
1095        );
1096
1097        // A fresh table instead of the context that was handed in.
1098        let err = run(r#"
1099            return { message = { a = 1 } }
1100        "#)
1101        .unwrap_err();
1102        assert!(
1103            err.error.message.contains("ctx.request must be a table"),
1104            "{}",
1105            err.error.message
1106        );
1107        assert!(
1108            err.error.message.contains("return ctx"),
1109            "{}",
1110            err.error.message
1111        );
1112
1113        // A status code that is not a status code.
1114        let err = run(r#"
1115            ctx.response.status_code = "fine"
1116            return ctx
1117        "#)
1118        .unwrap_err();
1119        assert!(
1120            err.error
1121                .message
1122                .contains("status_code must be an HTTP status number"),
1123            "{}",
1124            err.error.message
1125        );
1126    }
1127
1128    #[test]
1129    fn test_lua_error_handling() {
1130        let rt = LuaRuntime::new(
1131            r#"
1132            function execute(ctx)
1133                error("something went wrong")
1134                return ctx
1135            end
1136            "#,
1137            5000,
1138            None,
1139        )
1140        .unwrap();
1141
1142        let ctx = test_context();
1143        let result = rt.execute(ctx);
1144        assert!(result.is_err());
1145        let err = result.unwrap_err();
1146        assert_eq!(err.error.code, "LUA_EXECUTION_ERROR");
1147    }
1148
1149    /// A script that never returns must be stopped by `timeout_ms`. Before
1150    /// enforcement landed this call did not fail -- it hung, pinning the
1151    /// tokio worker thread that was polling it, so N runaway scripts wedged
1152    /// N of the runtime's workers.
1153    #[test]
1154    fn test_lua_runaway_script_is_stopped_by_timeout() {
1155        let rt = LuaRuntime::new(
1156            r#"
1157            function execute(ctx)
1158                while true do end
1159                return ctx
1160            end
1161            "#,
1162            50,
1163            None,
1164        )
1165        .unwrap();
1166
1167        let started = std::time::Instant::now();
1168        let result = rt.execute(test_context());
1169        let elapsed = started.elapsed();
1170
1171        let err = result.expect_err("a script that never returns must not succeed");
1172        assert_eq!(
1173            err.error.code, "LUA_TIMEOUT",
1174            "a timeout must be distinguishable from an ordinary script fault"
1175        );
1176        assert!(
1177            elapsed < std::time::Duration::from_secs(5),
1178            "the interrupt should fire near its 50ms budget, took {:?}",
1179            elapsed
1180        );
1181    }
1182
1183    /// The interrupt must not trip on a script that finishes inside its
1184    /// budget: a false positive here would break every working script.
1185    #[test]
1186    fn test_lua_script_within_budget_is_not_timed_out() {
1187        let rt = LuaRuntime::new(
1188            r#"
1189            function execute(ctx)
1190                local total = 0
1191                for i = 1, 100000 do total = total + i end
1192                ctx.message.total = total
1193                return ctx
1194            end
1195            "#,
1196            5000,
1197            None,
1198        )
1199        .unwrap();
1200
1201        let (result, _) = rt.execute(test_context()).unwrap();
1202        assert_eq!(
1203            result.message.get("total").and_then(|v| v.as_f64()),
1204            Some(5000050000.0)
1205        );
1206    }
1207
1208    /// The same hang exists at policy-compile time: `new` validates a script
1209    /// by executing its top level, so a loop there blocks the Admin API
1210    /// thread serving `PUT /api/policies` -- no request needed to trigger it.
1211    #[test]
1212    fn test_lua_compile_time_validation_is_bounded() {
1213        let started = std::time::Instant::now();
1214        let result = LuaRuntime::new("while true do end", 50, None);
1215        let elapsed = started.elapsed();
1216
1217        assert!(
1218            result.is_err(),
1219            "a top-level infinite loop must fail to compile, not hang"
1220        );
1221        assert!(
1222            elapsed < std::time::Duration::from_secs(5),
1223            "compile-time validation should be bounded, took {:?}",
1224            elapsed
1225        );
1226    }
1227
1228    #[test]
1229    fn test_lua_require_module() {
1230        // Create a temp directory with a module
1231        let tmp = std::env::temp_dir().join("gw_lua_test");
1232        let _ = std::fs::create_dir_all(&tmp);
1233        std::fs::write(
1234            tmp.join("helpers.lua"),
1235            r#"
1236            local M = {}
1237            function M.greet(name)
1238                return "hello " .. name
1239            end
1240            return M
1241            "#,
1242        )
1243        .unwrap();
1244
1245        let rt = LuaRuntime::new(
1246            r#"
1247            local helpers = require("helpers")
1248
1249            function execute(ctx)
1250                ctx.message.greeting = helpers.greet("world")
1251                return ctx
1252            end
1253            "#,
1254            5000,
1255            Some(tmp.clone()),
1256        )
1257        .unwrap();
1258
1259        let ctx = test_context();
1260        let (result, _) = rt.execute(ctx).unwrap();
1261        assert_eq!(
1262            result.message.get("greeting"),
1263            Some(&serde_json::json!("hello world"))
1264        );
1265
1266        let _ = std::fs::remove_dir_all(&tmp);
1267    }
1268}