courier/transforms/script/
rhai.rs1use 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}