context_harness/
lua_runtime.rs

1//! Shared Lua 5.4 VM runtime for connectors and tools.
2//!
3//! Provides a sandboxed Lua environment with host APIs that both
4//! [`crate::connector_script`] and [`crate::tool_script`] use. The Lua VM
5//! runs on a blocking thread (via [`tokio::task::spawn_blocking`]), so all
6//! host functions use synchronous I/O (`reqwest::blocking`, `std::thread::sleep`).
7//!
8//! # Host APIs
9//!
10//! | Module | Functions |
11//! |--------|-----------|
12//! | `http` | `get`, `post`, `put` |
13//! | `json` | `parse`, `encode` |
14//! | `env` | `get` |
15//! | `log` | `info`, `warn`, `error`, `debug` |
16//! | `fs` | `read`, `list` (sandboxed to script directory) |
17//! | `base64` | `encode`, `decode` |
18//! | `crypto` | `sha256`, `hmac_sha256` |
19//! | `sleep` | `sleep(seconds)` |
20//!
21//! # Sandboxing
22//!
23//! Dangerous Lua standard libraries (`os`, `io`, `debug`, `loadfile`, `dofile`)
24//! are removed. Filesystem access is restricted to a configurable sandbox root
25//! directory.
26
27use globset::Glob;
28use hmac::{Hmac, Mac};
29use mlua::prelude::*;
30use sha2::{Digest, Sha256};
31use std::path::Path;
32use std::time::Duration;
33
34// ═══════════════════════════════════════════════════════════════════════
35// Public helpers
36// ═══════════════════════════════════════════════════════════════════════
37
38/// Register all standard host APIs on a Lua VM instance.
39///
40/// This is the single entry-point used by both connector and tool runtimes.
41/// It sandboxes the globals and registers every host module.
42///
43/// # Arguments
44///
45/// * `lua` — the Lua VM instance to configure.
46/// * `script_name` — logical name used for log prefixes (e.g. `"script:jira"`).
47/// * `sandbox_root` — directory that `fs.read` / `fs.list` are confined to.
48pub(crate) fn register_all_host_apis(
49    lua: &Lua,
50    script_name: &str,
51    sandbox_root: &Path,
52) -> LuaResult<()> {
53    sandbox_globals(lua)?;
54    register_http_api(lua)?;
55    register_json_api(lua)?;
56    register_env_api(lua)?;
57    register_log_api(lua, script_name)?;
58    register_fs_api(lua, sandbox_root)?;
59    register_base64_api(lua)?;
60    register_crypto_api(lua)?;
61    register_sleep(lua)?;
62    Ok(())
63}
64
65// ═══════════════════════════════════════════════════════════════════════
66// Sandboxing
67// ═══════════════════════════════════════════════════════════════════════
68
69/// Remove dangerous standard library functions from the Lua globals.
70pub(crate) fn sandbox_globals(lua: &Lua) -> LuaResult<()> {
71    let globals = lua.globals();
72    globals.set("os", LuaValue::Nil)?;
73    globals.set("io", LuaValue::Nil)?;
74    globals.set("loadfile", LuaValue::Nil)?;
75    globals.set("dofile", LuaValue::Nil)?;
76    globals.set("debug", LuaValue::Nil)?;
77    Ok(())
78}
79
80// ═══════════════════════════════════════════════════════════════════════
81// Host API: http
82// ═══════════════════════════════════════════════════════════════════════
83
84fn register_http_api(lua: &Lua) -> LuaResult<()> {
85    let client = reqwest::blocking::Client::builder()
86        .timeout(Duration::from_secs(30))
87        .redirect(reqwest::redirect::Policy::limited(10))
88        .build()
89        .map_err(mlua::Error::external)?;
90
91    let http = lua.create_table()?;
92
93    // http.get(url, opts?) → response
94    let c = client.clone();
95    http.set(
96        "get",
97        lua.create_function(move |lua, (url, opts): (String, Option<LuaTable>)| {
98            do_http_request(lua, &c, "GET", &url, None, opts)
99        })?,
100    )?;
101
102    // http.post(url, body, opts?) → response
103    let c = client.clone();
104    http.set(
105        "post",
106        lua.create_function(
107            move |lua, (url, body, opts): (String, String, Option<LuaTable>)| {
108                do_http_request(lua, &c, "POST", &url, Some(&body), opts)
109            },
110        )?,
111    )?;
112
113    // http.put(url, body, opts?) → response
114    let c = client.clone();
115    http.set(
116        "put",
117        lua.create_function(
118            move |lua, (url, body, opts): (String, String, Option<LuaTable>)| {
119                do_http_request(lua, &c, "PUT", &url, Some(&body), opts)
120            },
121        )?,
122    )?;
123
124    lua.globals().set("http", http)?;
125    Ok(())
126}
127
128/// Execute an HTTP request and return a Lua table with the response.
129fn do_http_request(
130    lua: &Lua,
131    client: &reqwest::blocking::Client,
132    method: &str,
133    url: &str,
134    body: Option<&str>,
135    opts: Option<LuaTable>,
136) -> LuaResult<LuaTable> {
137    let mut builder = match method {
138        "GET" => client.get(url),
139        "POST" => client.post(url),
140        "PUT" => client.put(url),
141        "DELETE" => client.delete(url),
142        "PATCH" => client.patch(url),
143        _ => {
144            return Err(mlua::Error::external(anyhow::anyhow!(
145                "unsupported HTTP method: {}",
146                method
147            )))
148        }
149    };
150
151    if let Some(ref opts) = opts {
152        // Headers
153        if let Ok(headers) = opts.get::<LuaTable>("headers") {
154            for pair in headers.pairs::<String, String>() {
155                let (k, v) = pair?;
156                builder = builder.header(k, v);
157            }
158        }
159
160        // Query parameters
161        if let Ok(params) = opts.get::<LuaTable>("params") {
162            let mut param_vec: Vec<(String, String)> = Vec::new();
163            for pair in params.pairs::<String, String>() {
164                let (k, v) = pair?;
165                param_vec.push((k, v));
166            }
167            builder = builder.query(&param_vec);
168        }
169
170        // Custom timeout
171        if let Ok(timeout) = opts.get::<f64>("timeout") {
172            builder = builder.timeout(Duration::from_secs_f64(timeout));
173        }
174    }
175
176    // Request body
177    if let Some(body) = body {
178        builder = builder.body(body.to_string());
179    }
180
181    // Execute the request
182    let response = builder.send().map_err(|e| {
183        mlua::Error::external(anyhow::anyhow!("HTTP {} {} failed: {}", method, url, e))
184    })?;
185
186    let status = response.status().as_u16();
187    let ok = response.status().is_success();
188
189    // Collect response headers
190    let headers_table = lua.create_table()?;
191    for (name, value) in response.headers() {
192        if let Ok(v) = value.to_str() {
193            headers_table.set(name.as_str(), v.to_string())?;
194        }
195    }
196
197    // Read body
198    let body_text = response.text().map_err(|e| {
199        mlua::Error::external(anyhow::anyhow!("Failed to read response body: {}", e))
200    })?;
201
202    // Try to parse as JSON
203    let json_value = serde_json::from_str::<serde_json::Value>(&body_text).ok();
204
205    // Build result table
206    let result = lua.create_table()?;
207    result.set("status", status)?;
208    result.set("headers", headers_table)?;
209    result.set("body", body_text)?;
210    result.set("ok", ok)?;
211    if let Some(json) = json_value {
212        result.set("json", json_value_to_lua(lua, &json)?)?;
213    }
214
215    Ok(result)
216}
217
218// ═══════════════════════════════════════════════════════════════════════
219// Host API: json
220// ═══════════════════════════════════════════════════════════════════════
221
222fn register_json_api(lua: &Lua) -> LuaResult<()> {
223    let json_table = lua.create_table()?;
224
225    json_table.set(
226        "parse",
227        lua.create_function(|lua, s: String| {
228            let value: serde_json::Value = serde_json::from_str(&s)
229                .map_err(|e| mlua::Error::external(anyhow::anyhow!("json.parse: {}", e)))?;
230            json_value_to_lua(lua, &value)
231        })?,
232    )?;
233
234    json_table.set(
235        "encode",
236        lua.create_function(|_lua, value: LuaValue| {
237            let json = lua_value_to_json(value)?;
238            serde_json::to_string(&json)
239                .map_err(|e| mlua::Error::external(anyhow::anyhow!("json.encode: {}", e)))
240        })?,
241    )?;
242
243    lua.globals().set("json", json_table)?;
244    Ok(())
245}
246
247// ═══════════════════════════════════════════════════════════════════════
248// Host API: env
249// ═══════════════════════════════════════════════════════════════════════
250
251fn register_env_api(lua: &Lua) -> LuaResult<()> {
252    let env = lua.create_table()?;
253
254    env.set(
255        "get",
256        lua.create_function(|_lua, name: String| Ok(std::env::var(&name).ok()))?,
257    )?;
258
259    lua.globals().set("env", env)?;
260    Ok(())
261}
262
263// ═══════════════════════════════════════════════════════════════════════
264// Host API: log
265// ═══════════════════════════════════════════════════════════════════════
266
267fn register_log_api(lua: &Lua, script_name: &str) -> LuaResult<()> {
268    let log = lua.create_table()?;
269
270    let n = script_name.to_string();
271    log.set(
272        "info",
273        lua.create_function(move |_lua, msg: String| {
274            eprintln!("[{}] INFO: {}", n, msg);
275            Ok(())
276        })?,
277    )?;
278
279    let n = script_name.to_string();
280    log.set(
281        "warn",
282        lua.create_function(move |_lua, msg: String| {
283            eprintln!("[{}] WARN: {}", n, msg);
284            Ok(())
285        })?,
286    )?;
287
288    let n = script_name.to_string();
289    log.set(
290        "error",
291        lua.create_function(move |_lua, msg: String| {
292            eprintln!("[{}] ERROR: {}", n, msg);
293            Ok(())
294        })?,
295    )?;
296
297    let n = script_name.to_string();
298    log.set(
299        "debug",
300        lua.create_function(move |_lua, msg: String| {
301            eprintln!("[{}] DEBUG: {}", n, msg);
302            Ok(())
303        })?,
304    )?;
305
306    lua.globals().set("log", log)?;
307    Ok(())
308}
309
310// ═══════════════════════════════════════════════════════════════════════
311// Host API: fs (sandboxed)
312// ═══════════════════════════════════════════════════════════════════════
313
314fn register_fs_api(lua: &Lua, sandbox_root: &Path) -> LuaResult<()> {
315    let fs = lua.create_table()?;
316    let root = sandbox_root
317        .canonicalize()
318        .unwrap_or_else(|_| sandbox_root.to_path_buf());
319
320    // fs.read(path) → string
321    let r = root.clone();
322    fs.set(
323        "read",
324        lua.create_function(move |_lua, path: String| {
325            let target = r.join(&path);
326            let canonical = target
327                .canonicalize()
328                .map_err(|e| mlua::Error::external(anyhow::anyhow!("fs.read: {}: {}", path, e)))?;
329            if !canonical.starts_with(&r) {
330                return Err(mlua::Error::external(anyhow::anyhow!(
331                    "fs.read: path escapes sandbox: {}",
332                    path
333                )));
334            }
335            std::fs::read_to_string(&canonical)
336                .map_err(|e| mlua::Error::external(anyhow::anyhow!("fs.read: {}: {}", path, e)))
337        })?,
338    )?;
339
340    // fs.list(dir, glob?) → [{path, size, modified}]
341    let r = root.clone();
342    fs.set(
343        "list",
344        lua.create_function(move |lua, (dir, glob_pattern): (String, Option<String>)| {
345            let target = r.join(&dir);
346            let canonical = target
347                .canonicalize()
348                .map_err(|e| mlua::Error::external(anyhow::anyhow!("fs.list: {}: {}", dir, e)))?;
349            if !canonical.starts_with(&r) {
350                return Err(mlua::Error::external(anyhow::anyhow!(
351                    "fs.list: path escapes sandbox: {}",
352                    dir
353                )));
354            }
355
356            let matcher = glob_pattern
357                .as_deref()
358                .map(|p| {
359                    Glob::new(p)
360                        .map_err(|e| {
361                            mlua::Error::external(anyhow::anyhow!("fs.list: bad glob: {}", e))
362                        })
363                        .map(|g| g.compile_matcher())
364                })
365                .transpose()?;
366
367            let entries = lua.create_table()?;
368            let mut idx = 1i64;
369
370            let dir_entries = std::fs::read_dir(&canonical)
371                .map_err(|e| mlua::Error::external(anyhow::anyhow!("fs.list: {}: {}", dir, e)))?;
372
373            for entry in dir_entries {
374                let entry = entry.map_err(mlua::Error::external)?;
375                let path = entry.path();
376
377                if let Some(ref m) = matcher {
378                    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
379                    if !m.is_match(name) {
380                        continue;
381                    }
382                }
383
384                let metadata = entry.metadata().map_err(mlua::Error::external)?;
385                let item = lua.create_table()?;
386                item.set("path", path.to_string_lossy().to_string())?;
387                item.set("size", metadata.len())?;
388                item.set(
389                    "modified",
390                    metadata
391                        .modified()
392                        .ok()
393                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
394                        .map(|d| d.as_secs())
395                        .unwrap_or(0),
396                )?;
397                entries.set(idx, item)?;
398                idx += 1;
399            }
400
401            Ok(entries)
402        })?,
403    )?;
404
405    lua.globals().set("fs", fs)?;
406    Ok(())
407}
408
409// ═══════════════════════════════════════════════════════════════════════
410// Host API: base64
411// ═══════════════════════════════════════════════════════════════════════
412
413fn register_base64_api(lua: &Lua) -> LuaResult<()> {
414    use base64::{engine::general_purpose::STANDARD, Engine as _};
415
416    let b64 = lua.create_table()?;
417
418    b64.set(
419        "encode",
420        lua.create_function(|_lua, data: String| Ok(STANDARD.encode(data.as_bytes())))?,
421    )?;
422
423    b64.set(
424        "decode",
425        lua.create_function(|_lua, data: String| {
426            let bytes = STANDARD
427                .decode(data.as_bytes())
428                .map_err(|e| mlua::Error::external(anyhow::anyhow!("base64.decode: {}", e)))?;
429            String::from_utf8(bytes)
430                .map_err(|e| mlua::Error::external(anyhow::anyhow!("base64.decode: {}", e)))
431        })?,
432    )?;
433
434    lua.globals().set("base64", b64)?;
435    Ok(())
436}
437
438// ═══════════════════════════════════════════════════════════════════════
439// Host API: crypto
440// ═══════════════════════════════════════════════════════════════════════
441
442fn register_crypto_api(lua: &Lua) -> LuaResult<()> {
443    let crypto = lua.create_table()?;
444
445    crypto.set(
446        "sha256",
447        lua.create_function(|_lua, data: String| {
448            let mut hasher = Sha256::new();
449            hasher.update(data.as_bytes());
450            Ok(format!("{:x}", hasher.finalize()))
451        })?,
452    )?;
453
454    crypto.set(
455        "hmac_sha256",
456        lua.create_function(|_lua, (key, data): (String, String)| {
457            type HmacSha256 = Hmac<Sha256>;
458            let mut mac = HmacSha256::new_from_slice(key.as_bytes())
459                .map_err(|e| mlua::Error::external(anyhow::anyhow!("crypto.hmac_sha256: {}", e)))?;
460            mac.update(data.as_bytes());
461            Ok(hex::encode(mac.finalize().into_bytes()))
462        })?,
463    )?;
464
465    lua.globals().set("crypto", crypto)?;
466    Ok(())
467}
468
469// ═══════════════════════════════════════════════════════════════════════
470// Host API: sleep
471// ═══════════════════════════════════════════════════════════════════════
472
473fn register_sleep(lua: &Lua) -> LuaResult<()> {
474    lua.globals().set(
475        "sleep",
476        lua.create_function(|_lua, seconds: f64| {
477            std::thread::sleep(Duration::from_secs_f64(seconds));
478            Ok(())
479        })?,
480    )?;
481    Ok(())
482}
483
484// ═══════════════════════════════════════════════════════════════════════
485// Value Conversions: TOML → Lua
486// ═══════════════════════════════════════════════════════════════════════
487
488/// Convert a TOML value to a Lua value, expanding `${VAR}` in strings.
489pub(crate) fn toml_value_to_lua(lua: &Lua, value: &toml::Value) -> LuaResult<LuaValue> {
490    match value {
491        toml::Value::String(s) => {
492            let expanded = expand_env_vars(s);
493            lua.create_string(&expanded).map(LuaValue::String)
494        }
495        toml::Value::Integer(i) => Ok(LuaValue::Integer(*i)),
496        toml::Value::Float(f) => Ok(LuaValue::Number(*f)),
497        toml::Value::Boolean(b) => Ok(LuaValue::Boolean(*b)),
498        toml::Value::Array(arr) => {
499            let table = lua.create_table()?;
500            for (i, v) in arr.iter().enumerate() {
501                table.set(i as i64 + 1, toml_value_to_lua(lua, v)?)?;
502            }
503            Ok(LuaValue::Table(table))
504        }
505        toml::Value::Table(map) => {
506            let t = toml_table_to_lua(lua, map)?;
507            Ok(LuaValue::Table(t))
508        }
509        _ => Ok(LuaValue::Nil),
510    }
511}
512
513/// Convert a TOML table to a Lua table.
514pub(crate) fn toml_table_to_lua(lua: &Lua, table: &toml::Table) -> LuaResult<LuaTable> {
515    let lua_table = lua.create_table()?;
516    for (k, v) in table {
517        lua_table.set(k.as_str(), toml_value_to_lua(lua, v)?)?;
518    }
519    Ok(lua_table)
520}
521
522/// Expand `${VAR_NAME}` patterns in a string from the process environment.
523pub(crate) fn expand_env_vars(s: &str) -> String {
524    let mut result = s.to_string();
525    while let Some(start) = result.find("${") {
526        let end = match result[start..].find('}') {
527            Some(pos) => start + pos,
528            None => break,
529        };
530        let var_name = &result[start + 2..end];
531        let value = std::env::var(var_name).unwrap_or_default();
532        result = format!("{}{}{}", &result[..start], value, &result[end + 1..]);
533    }
534    result
535}
536
537// ═══════════════════════════════════════════════════════════════════════
538// Value Conversions: JSON ↔ Lua
539// ═══════════════════════════════════════════════════════════════════════
540
541/// Convert a JSON value to a Lua value.
542pub(crate) fn json_value_to_lua(lua: &Lua, value: &serde_json::Value) -> LuaResult<LuaValue> {
543    match value {
544        serde_json::Value::Null => Ok(LuaValue::Nil),
545        serde_json::Value::Bool(b) => Ok(LuaValue::Boolean(*b)),
546        serde_json::Value::Number(n) => {
547            if let Some(i) = n.as_i64() {
548                Ok(LuaValue::Integer(i))
549            } else {
550                Ok(LuaValue::Number(n.as_f64().unwrap_or(0.0)))
551            }
552        }
553        serde_json::Value::String(s) => lua.create_string(s).map(LuaValue::String),
554        serde_json::Value::Array(arr) => {
555            let table = lua.create_table()?;
556            for (i, v) in arr.iter().enumerate() {
557                table.set(i as i64 + 1, json_value_to_lua(lua, v)?)?;
558            }
559            Ok(LuaValue::Table(table))
560        }
561        serde_json::Value::Object(map) => {
562            let table = lua.create_table()?;
563            for (k, v) in map {
564                table.set(k.as_str(), json_value_to_lua(lua, v)?)?;
565            }
566            Ok(LuaValue::Table(table))
567        }
568    }
569}
570
571/// Convert a Lua value to a JSON value.
572pub(crate) fn lua_value_to_json(value: LuaValue) -> LuaResult<serde_json::Value> {
573    match value {
574        LuaValue::Nil => Ok(serde_json::Value::Null),
575        LuaValue::Boolean(b) => Ok(serde_json::Value::Bool(b)),
576        LuaValue::Integer(i) => Ok(serde_json::Value::Number(i.into())),
577        LuaValue::Number(n) => Ok(serde_json::Number::from_f64(n)
578            .map(serde_json::Value::Number)
579            .unwrap_or(serde_json::Value::Null)),
580        LuaValue::String(s) => Ok(serde_json::Value::String(s.to_str()?.to_string())),
581        LuaValue::Table(t) => {
582            // Heuristic: if raw_len > 0, treat as array; otherwise as object
583            let len = t.raw_len();
584            if len > 0 {
585                let mut arr = Vec::new();
586                for i in 1..=len {
587                    let v: LuaValue = t.raw_get(i)?;
588                    arr.push(lua_value_to_json(v)?);
589                }
590                Ok(serde_json::Value::Array(arr))
591            } else {
592                let mut map = serde_json::Map::new();
593                for pair in t.pairs::<String, LuaValue>() {
594                    let (k, v) = pair?;
595                    map.insert(k, lua_value_to_json(v)?);
596                }
597                Ok(serde_json::Value::Object(map))
598            }
599        }
600        _ => Ok(serde_json::Value::Null),
601    }
602}