1use 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
18pub(crate) const RESPOND_PORT: &str = "respond";
20
21pub struct LuaRuntime {
26 source: String,
28 modules_path: Option<PathBuf>,
31 timeout_ms: u64,
35}
36
37impl LuaRuntime {
38 pub fn new(
43 source: &str,
44 timeout_ms: u64,
45 modules_path: Option<PathBuf>,
46 ) -> Result<Self, String> {
47 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 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 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 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 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 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 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
301fn 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
311fn setup_module_loader(lua: &Lua, modules_path: &Option<PathBuf>) {
319 let Some(base_path) = modules_path.clone() else {
320 return;
321 };
322
323 let loader = lua
325 .create_function(move |lua, module_name: String| {
326 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 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 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
356fn context_to_lua(lua: &Lua, ctx: &Context) -> LuaResult<LuaTable> {
361 let table = lua.create_table()?;
362
363 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 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 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
420fn 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
439fn 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
453fn 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 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
509fn 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
526fn 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 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
607fn 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
638fn 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
676fn 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
712fn 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 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 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 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 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 #[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 #[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 #[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 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}