Skip to main content

featherbit/plugins/script/
mod.rs

1//! Scripted plugin host (`script`).
2//!
3//! Runs user-provided scripts as graph nodes behind the same `Plugin` trait
4//! as native plugins. Scripts are parsed and validated once at policy-compile
5//! time (in `from_config`), not per request; script failures surface as
6//! `PluginExecutionError` exactly like native failures, routing through the
7//! node's error port.
8
9pub mod lua_runtime;
10
11use async_trait::async_trait;
12use std::collections::HashMap;
13use std::path::PathBuf;
14
15use crate::context::Context;
16use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
17
18/// Executes a scripted plugin as a graph node.
19///
20/// The script receives the full `Context` (request, response, message) and
21/// returns a possibly modified copy; anything it writes into `ctx.message`
22/// is visible to downstream nodes. Currently only the Lua (Luau) runtime is
23/// supported.
24pub struct ScriptPlugin {
25    runtime: ScriptRuntime,
26}
27
28/// Dispatch over the available scripting runtimes (only Lua today; Python is
29/// planned but not implemented).
30enum ScriptRuntime {
31    Lua(lua_runtime::LuaRuntime),
32}
33
34impl ScriptPlugin {
35    /// Builds the plugin from node config, loading and validating the script
36    /// immediately so bad scripts fail at policy-compile time.
37    ///
38    /// Accepted keys:
39    /// - `runtime` (string, default `"lua"`): scripting runtime; any other
40    ///   value is an error.
41    /// - `source` (string): path to a script file, read at compile time.
42    /// - `inline` (string): script text embedded in the config. One of
43    ///   `source` or `inline` is required (`source` wins if both are set);
44    ///   omitting both is an error, as is an unreadable `source` file.
45    /// - `timeout_ms` (integer, default `5000`): wall-clock budget for one
46    ///   execution, covering both loading the source and the `execute(ctx)`
47    ///   call. Enforced by a Luau VM interrupt, which fires at instruction
48    ///   boundaries -- a runaway loop is stopped and the node fails with
49    ///   `LUA_TIMEOUT`, but time inside a Rust callback or `require`'s file
50    ///   IO is not interrupted. The same budget bounds the validation run at
51    ///   policy-compile time. `0` disables enforcement.
52    /// - `modules_path` (string, default: the `source` script's parent
53    ///   directory; none for `inline`): directory the sandboxed `require`
54    ///   resolves modules from.
55    ///
56    /// ```yaml
57    /// type: script
58    /// config:
59    ///   runtime: lua
60    ///   source: scripts/enrich.lua
61    ///   timeout_ms: 2000
62    /// ```
63    ///
64    /// The script must define a global `execute(ctx)` function that returns
65    /// the (possibly modified) context table:
66    ///
67    /// ```lua
68    /// function execute(ctx)
69    ///     ctx.request.headers["x-enriched"] = {"true"}
70    ///     ctx.message.user_tier = "gold"
71    ///     return ctx
72    /// end
73    /// ```
74    pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
75        let runtime_name = config
76            .get("runtime")
77            .and_then(|v| v.as_str())
78            .unwrap_or("lua");
79
80        let source_path = config
81            .get("source")
82            .and_then(|v| v.as_str())
83            .map(String::from);
84
85        let inline_source = config
86            .get("inline")
87            .and_then(|v| v.as_str())
88            .map(String::from);
89
90        if source_path.is_none() && inline_source.is_none() {
91            return Err("script plugin requires 'source' or 'inline'".to_string());
92        }
93
94        let source = if let Some(ref path) = source_path {
95            std::fs::read_to_string(path)
96                .map_err(|e| format!("Failed to read script '{}': {}", path, e))?
97        } else {
98            inline_source.unwrap()
99        };
100
101        let timeout_ms = config
102            .get("timeout_ms")
103            .and_then(|v| v.as_u64())
104            .unwrap_or(5000);
105
106        // modules_path: explicit config, or derive from the script's parent directory
107        let modules_path = config
108            .get("modules_path")
109            .and_then(|v| v.as_str())
110            .map(PathBuf::from)
111            .or_else(|| {
112                source_path
113                    .as_ref()
114                    .and_then(|p| PathBuf::from(p).parent().map(|p| p.to_path_buf()))
115            });
116
117        let runtime = match runtime_name {
118            "lua" => {
119                let rt = lua_runtime::LuaRuntime::new(&source, timeout_ms, modules_path)?;
120                ScriptRuntime::Lua(rt)
121            }
122            other => {
123                return Err(format!("Unknown runtime: '{}' — supported: lua", other));
124            }
125        };
126
127        Ok(Self { runtime })
128    }
129}
130
131#[async_trait]
132impl Plugin for ScriptPlugin {
133    fn plugin_type(&self) -> &str {
134        "script"
135    }
136
137    async fn execute(&self, ctx: Context) -> PluginResult {
138        match &self.runtime {
139            ScriptRuntime::Lua(rt) => match rt.execute(ctx) {
140                Ok((new_ctx, Some(port))) => Ok(PluginOutput::on_port(new_ctx, port)),
141                Ok((new_ctx, None)) => Ok(PluginOutput::success(new_ctx)),
142                Err(e) => Err(PluginExecutionError {
143                    context: e.context,
144                    error: e.error,
145                }),
146            },
147        }
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::context::{Context, GatewayRequest, GatewayResponse, Protocol};
155
156    fn plugin(inline: &str) -> ScriptPlugin {
157        let mut cfg = HashMap::new();
158        cfg.insert("runtime".to_string(), serde_json::json!("lua"));
159        cfg.insert("inline".to_string(), serde_json::json!(inline));
160        ScriptPlugin::from_config(&cfg).unwrap()
161    }
162
163    fn ctx() -> Context {
164        Context {
165            request: GatewayRequest {
166                method: "GET".to_string(),
167                path: "/".to_string(),
168                host: "localhost".to_string(),
169                scheme: "http".to_string(),
170                headers: HashMap::new(),
171                query_params: HashMap::new(),
172                body: bytes::Bytes::new(),
173                remote_addr: "127.0.0.1:1".to_string(),
174                protocol: Protocol::Http1,
175            },
176            response: GatewayResponse {
177                status_code: 0,
178                headers: HashMap::new(),
179                body: bytes::Bytes::new(),
180                stream: None,
181            },
182            message: HashMap::new(),
183            errors: Vec::new(),
184        }
185    }
186
187    /// The plugin is where the runtime's answer becomes a graph port.
188    #[tokio::test]
189    async fn test_respond_becomes_the_respond_port() {
190        let out = plugin("function execute(ctx) return ctx, \"respond\" end")
191            .execute(ctx())
192            .await
193            .unwrap();
194        assert_eq!(out.port, Some("respond"));
195    }
196
197    #[tokio::test]
198    async fn test_plain_return_is_success() {
199        let out = plugin("function execute(ctx) return ctx end")
200            .execute(ctx())
201            .await
202            .unwrap();
203        assert_eq!(out.port, None);
204    }
205}