Skip to main content

courier/transforms/script/
rhai.rs

1use anyhow::{Context, Result, anyhow, bail};
2use async_trait::async_trait;
3use rhai::serde::{from_dynamic, to_dynamic};
4use rhai::{AST, Dynamic, Engine, Scope};
5
6use crate::config::redact_secret;
7use crate::envelope::Envelope;
8
9use super::{ScriptEngine, ScriptTransformConfig};
10
11pub struct RhaiEngine {
12    engine: Engine,
13    ast: AST,
14    entrypoint: String,
15}
16
17#[async_trait]
18impl ScriptEngine for RhaiEngine {
19    async fn run(&self, env: Envelope) -> Result<Option<Envelope>> {
20        self.run_inner(env)
21    }
22}
23
24impl RhaiEngine {
25    pub(super) fn new(config: &ScriptTransformConfig) -> Result<Self> {
26        let limits = config
27            .rhai
28            .as_ref()
29            .expect("Rhai config missing for Rhai runtime");
30
31        let mut engine = Engine::new();
32        engine
33            .set_max_operations(limits.max_operations)
34            .set_max_call_levels(limits.max_call_levels)
35            .set_max_expr_depths(limits.max_expr_depth, limits.max_function_expr_depth)
36            .set_max_variables(limits.max_variables);
37
38        let ast = engine
39            .compile(&config.script)
40            .context("failed to compile Rhai script")?;
41
42        let mut entrypoint_ast = ast.clone();
43        entrypoint_ast
44            .retain_functions(|_, _, name, params| name == config.entrypoint && params == 1);
45        let has_entrypoint = entrypoint_ast.has_functions();
46        if !has_entrypoint {
47            bail!(
48                "missing Rhai entrypoint '{}' with exactly one parameter",
49                redact_secret(&config.entrypoint)
50            );
51        }
52
53        Ok(Self {
54            engine,
55            ast,
56            entrypoint: config.entrypoint.clone(),
57        })
58    }
59
60    fn run_inner(&self, env: Envelope) -> Result<Option<Envelope>> {
61        let arg = to_dynamic(env).context("failed to convert envelope into Rhai value")?;
62        let mut scope = Scope::new();
63        let out: Dynamic = self
64            .engine
65            .call_fn(&mut scope, &self.ast, &self.entrypoint, (arg,))
66            .with_context(|| {
67                format!(
68                    "Rhai entrypoint '{}' failed",
69                    redact_secret(&self.entrypoint)
70                )
71            })?;
72
73        if out.is_unit() {
74            return Ok(None);
75        }
76
77        from_dynamic(&out.flatten()).map(Some).map_err(|err| {
78            anyhow!(err).context("failed to convert Rhai return value into envelope")
79        })
80    }
81
82    #[cfg(test)]
83    fn run(&self, env: Envelope) -> Result<Option<Envelope>> {
84        self.run_inner(env)
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use serde_json::json;
91
92    use super::RhaiEngine;
93    use crate::envelope::Envelope;
94    use crate::transforms::script::ScriptTransformConfig;
95
96    fn config(script: &str) -> ScriptTransformConfig {
97        ScriptTransformConfig {
98            runtime: super::super::ScriptRuntime::Rhai,
99            script: script.into(),
100            entrypoint: "transform".into(),
101            python: None,
102            rhai: Some(super::super::RhaiConfig {
103                max_operations: 100_000,
104                max_call_levels: 32,
105                max_expr_depth: 64,
106                max_function_expr_depth: 32,
107                max_variables: 64,
108            }),
109        }
110    }
111
112    #[test]
113    fn mutates_payload() {
114        let engine = RhaiEngine::new(&config(
115            r#"
116                fn transform(env) {
117                    env.payload["processed"] = true;
118                    env
119                }
120            "#,
121        ))
122        .unwrap();
123
124        let out = engine
125            .run(Envelope::new("src", json!({ "value": 1 })))
126            .unwrap()
127            .unwrap();
128        assert_eq!(out.payload, json!({ "value": 1, "processed": true }));
129    }
130
131    #[test]
132    fn mutates_metadata() {
133        let engine = RhaiEngine::new(&config(
134            r#"
135                fn transform(env) {
136                    env.meta.headers["script_runtime"] = "rhai";
137                    env
138                }
139            "#,
140        ))
141        .unwrap();
142
143        let out = engine
144            .run(Envelope::new("src", json!({})))
145            .unwrap()
146            .unwrap();
147        assert_eq!(
148            out.meta.headers.get("script_runtime").map(String::as_str),
149            Some("rhai")
150        );
151    }
152
153    #[test]
154    fn unit_return_filters_envelope() {
155        let engine = RhaiEngine::new(&config("fn transform(env) { () }")).unwrap();
156
157        let out = engine
158            .run(Envelope::new("src", json!({ "skip": true })))
159            .unwrap();
160        assert!(out.is_none());
161    }
162
163    #[test]
164    fn compile_error_fails_build() {
165        let err = RhaiEngine::new(&config("fn transform(env) { let = }"))
166            .err()
167            .expect("expected compile error");
168        let msg = format!("{err:#}");
169        assert!(msg.contains("failed to compile Rhai script"), "{msg}");
170    }
171
172    #[test]
173    fn missing_entrypoint_fails_build() {
174        let err = RhaiEngine::new(&config("fn other(env) { env }"))
175            .err()
176            .expect("expected missing entrypoint error");
177        let msg = format!("{err:#}");
178        assert!(msg.contains("missing Rhai entrypoint 'transform'"), "{msg}");
179    }
180
181    #[test]
182    fn invalid_return_shape_fails_run() {
183        let engine = RhaiEngine::new(&config("fn transform(env) { 42 }")).unwrap();
184
185        let err = engine.run(Envelope::new("src", json!({}))).unwrap_err();
186        let msg = format!("{err:#}");
187        assert!(
188            msg.contains("failed to convert Rhai return value into envelope"),
189            "{msg}"
190        );
191    }
192
193    #[test]
194    fn runtime_exception_fails_run() {
195        let engine = RhaiEngine::new(&config("fn transform(env) { throw \"boom\"; }")).unwrap();
196
197        let err = engine.run(Envelope::new("src", json!({}))).unwrap_err();
198        let msg = format!("{err:#}");
199        assert!(msg.contains("Rhai entrypoint 'transform' failed"), "{msg}");
200    }
201}