leviath_scripting/
engine.rs1use crate::{Error, Result};
4use rhai::{Engine, Scope};
5
6pub struct ScriptEngine {
8 engine: Engine,
9}
10
11impl ScriptEngine {
12 pub fn new() -> Self {
14 let mut engine = Engine::new();
15 crate::harden(&mut engine, 100_000);
16
17 crate::functions::register_functions(&mut engine);
19 crate::types::register_types(&mut engine);
20
21 Self { engine }
22 }
23
24 pub fn validate(&self, script: &str, content: &str) -> Result<bool> {
26 let mut scope = Scope::new();
27 scope.push("content", content.to_string());
28
29 self.engine
30 .eval_with_scope::<bool>(&mut scope, script)
31 .map_err(|e| Error::ExecutionFailed(e.to_string()))
32 }
33
34 pub fn transform(&self, script: &str, input: rhai::Map) -> Result<String> {
36 let mut scope = Scope::new();
37 scope.push("input", input);
38
39 self.engine
40 .eval_with_scope::<String>(&mut scope, script)
41 .map_err(|e| Error::ExecutionFailed(e.to_string()))
42 }
43
44 pub fn execute(&self, script: &str, scope: &mut Scope) -> Result<rhai::Dynamic> {
46 self.engine
47 .eval_with_scope(scope, script)
48 .map_err(|e| Error::ExecutionFailed(e.to_string()))
49 }
50
51 pub fn check_gate_rule(
56 &self,
57 script: &str,
58 tool: &str,
59 target: Option<&str>,
60 taint_level: &str,
61 ) -> Result<bool> {
62 let mut context = rhai::Map::new();
63 context.insert("tool".into(), rhai::Dynamic::from(tool.to_string()));
64 context.insert(
65 "target".into(),
66 rhai::Dynamic::from(target.unwrap_or("").to_string()),
67 );
68 context.insert(
69 "taint_level".into(),
70 rhai::Dynamic::from(taint_level.to_string()),
71 );
72
73 let mut scope = Scope::new();
74 scope.push("context", context);
75
76 self.engine
79 .eval_with_scope::<bool>(&mut scope, script)
80 .map_err(|e| Error::ExecutionFailed(e.to_string()))
81 }
82}
83
84impl Default for ScriptEngine {
85 fn default() -> Self {
86 Self::new()
87 }
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93
94 #[test]
95 fn test_engine_creation() {
96 let engine = ScriptEngine::new();
97 assert!(engine.engine.max_operations() > 0);
99 }
100
101 #[test]
102 fn test_simple_validation() {
103 let engine = ScriptEngine::new();
104 let script = r#"
105 content.contains("test")
106 "#;
107 let result = engine.validate(script, "this is a test");
108 assert!(result.is_ok());
109 assert!(result.unwrap());
110 }
111
112 #[test]
113 fn test_string_operations() {
114 let engine = ScriptEngine::new();
115
116 let script = r#"content.starts_with("Hello")"#;
118 assert!(engine.validate(script, "Hello, world!").unwrap());
119
120 let script = r#"content.ends_with("!")"#;
122 assert!(engine.validate(script, "Hello, world!").unwrap());
123
124 let script = r#"content.trim() == "hello""#;
126 assert!(engine.validate(script, " hello ").unwrap());
127 }
128
129 #[test]
130 fn test_json_validation() {
131 let engine = ScriptEngine::new();
132 let script = r#"is_json(content)"#;
133
134 assert!(engine.validate(script, r#"{"key": "value"}"#).unwrap());
135 assert!(!engine.validate(script, "not json").unwrap());
136 }
137
138 #[test]
139 fn test_mermaid_validation() {
140 let engine = ScriptEngine::new();
141 let script = r#"is_mermaid(content)"#;
142
143 assert!(engine.validate(script, "graph TD\n A --> B").unwrap());
144 assert!(
145 engine
146 .validate(script, "sequenceDiagram\n Alice->>Bob: Hello")
147 .unwrap()
148 );
149 assert!(!engine.validate(script, "just text").unwrap());
150 }
151
152 #[test]
153 fn test_token_counting() {
154 let engine = ScriptEngine::new();
155 let script = r#"count_tokens(content) > 10"#;
156
157 let long_text = "a".repeat(50);
158 assert!(engine.validate(script, &long_text).unwrap());
159 assert!(!engine.validate(script, "short").unwrap());
160 }
161
162 #[test]
163 fn test_split_join() {
164 let engine = ScriptEngine::new();
165 let script = r#"
166 let parts = content.split(",");
167 parts.len() == 3 && join(parts, "|") != ""
168 "#;
169 assert!(engine.validate(script, "a,b,c").unwrap());
170 }
171
172 #[test]
173 fn test_sandbox_limits() {
174 let engine = ScriptEngine::new();
175 let script = r#"
177 let x = 0;
178 loop {
179 x = x + 1;
180 if x > 200000 { break; }
181 }
182 true
183 "#;
184 let result = engine.validate(script, "test");
186 assert!(result.is_err());
187 }
188
189 #[test]
192 fn test_transform_success() {
193 let engine = ScriptEngine::new();
194 let mut input = rhai::Map::new();
195 input.insert("name".into(), rhai::Dynamic::from("world".to_string()));
196
197 let script = r#"("hello " + input["name"])"#;
198 let result = engine.transform(script, input);
199 assert_eq!(result.unwrap(), "hello world");
200 }
201
202 #[test]
203 fn test_transform_script_error_returns_execution_failed() {
204 let engine = ScriptEngine::new();
205 let input = rhai::Map::new();
206
207 let script = "this is not valid rhai {{{";
209 let result = engine.transform(script, input);
210 assert!(result.is_err());
211 assert!(
212 result
213 .unwrap_err()
214 .to_string()
215 .starts_with("Script execution failed:")
216 );
217 }
218
219 #[test]
220 fn test_transform_wrong_return_type_returns_execution_failed() {
221 let engine = ScriptEngine::new();
222 let input = rhai::Map::new();
223
224 let script = "42";
226 let result = engine.transform(script, input);
227 assert!(result.is_err());
228 }
229
230 #[test]
233 fn test_execute_returns_dynamic_value() {
234 let engine = ScriptEngine::new();
235 let mut scope = Scope::new();
236 scope.push("x", 10_i64);
237
238 let result = engine.execute("x * 2", &mut scope);
239 let value = result.unwrap();
240 assert_eq!(value.as_int().unwrap(), 20);
241 }
242
243 #[test]
244 fn test_execute_script_error_returns_execution_failed() {
245 let engine = ScriptEngine::new();
246 let mut scope = Scope::new();
247
248 let result = engine.execute("undefined_function_call()", &mut scope);
249 assert!(result.is_err());
250 assert!(
251 result
252 .unwrap_err()
253 .to_string()
254 .starts_with("Script execution failed:")
255 );
256 }
257
258 #[test]
259 fn test_print_and_debug_statements_invoke_noop_callbacks() {
260 let engine = ScriptEngine::new();
266 let mut scope = Scope::new();
267 let result = engine.execute(r#"print("hello"); debug("world"); true"#, &mut scope);
268 assert!(result.unwrap().as_bool().unwrap());
269 }
270
271 #[test]
274 fn test_default_creates_working_engine() {
275 let engine = ScriptEngine::default();
276 let result = engine.validate("content.len() > 0", "hi");
277 assert!(result.unwrap());
278 }
279
280 #[test]
283 fn test_gate_rule_allows_matching_tool() {
284 let engine = ScriptEngine::new();
285 let script = r#"
286 context["tool"] == "send_email"
287 && context["target"].ends_with("@mycompany.com")
288 && (context["taint_level"] == "public" || context["taint_level"] == "internal")
289 "#;
290 let result = engine
291 .check_gate_rule(
292 script,
293 "send_email",
294 Some("alice@mycompany.com"),
295 "internal",
296 )
297 .unwrap();
298 assert!(result);
299 }
300
301 #[test]
302 fn test_gate_rule_blocks_external_email() {
303 let engine = ScriptEngine::new();
304 let script = r#"
305 context["tool"] == "send_email"
306 && context["target"].ends_with("@mycompany.com")
307 "#;
308 let result = engine
309 .check_gate_rule(script, "send_email", Some("bob@external.com"), "internal")
310 .unwrap();
311 assert!(!result);
312 }
313
314 #[test]
315 fn test_gate_rule_blocks_wrong_tool() {
316 let engine = ScriptEngine::new();
317 let script = r#"context["tool"] == "send_email""#;
318 let result = engine
319 .check_gate_rule(script, "post_to_slack", None, "public")
320 .unwrap();
321 assert!(!result);
322 }
323
324 #[test]
325 fn test_gate_rule_no_target_uses_empty_string() {
326 let engine = ScriptEngine::new();
327 let script = r#"context["target"] == """#;
328 let result = engine
329 .check_gate_rule(script, "shell", None, "public")
330 .unwrap();
331 assert!(result);
332 }
333
334 #[test]
335 fn test_gate_rule_script_error() {
336 let engine = ScriptEngine::new();
337 let result = engine.check_gate_rule("invalid {{ syntax", "shell", None, "public");
338 assert!(result.is_err());
339 }
340}