//! Rhai script engine with safety configuration. //! //! Provides a sandboxed Rhai engine for executing plugin scripts. use rhai::{Dynamic, Engine, AST}; use std::sync::Arc; use crate::api::create_goingson_module; use crate::error::{PluginError, Result}; /// Safety limits for script execution. #[derive(Debug, Clone)] pub struct SafetyLimits { /// Maximum number of operations before termination. pub max_operations: u64, /// Maximum call stack depth (recursion limit). pub max_call_levels: usize, /// Maximum string size in bytes. pub max_string_size: usize, /// Maximum array elements. pub max_array_size: usize, /// Maximum map entries. pub max_map_size: usize, } impl Default for SafetyLimits { fn default() -> Self { Self { max_operations: 100_000, max_call_levels: 32, max_string_size: 1_048_576, // 1MB max_array_size: 10_000, max_map_size: 10_000, } } } /// Plugin script engine with sandboxing. pub struct PluginEngine { engine: Engine, limits: SafetyLimits, } impl PluginEngine { /// Creates a new plugin engine with default safety limits. #[tracing::instrument(skip_all)] pub fn new() -> Self { Self::with_limits(SafetyLimits::default()) } /// Creates a new plugin engine with custom safety limits. #[tracing::instrument(skip_all)] pub fn with_limits(limits: SafetyLimits) -> Self { let mut engine = Engine::new(); // Apply safety limits engine.set_max_operations(limits.max_operations); engine.set_max_call_levels(limits.max_call_levels); engine.set_max_string_size(limits.max_string_size); engine.set_max_array_size(limits.max_array_size); engine.set_max_map_size(limits.max_map_size); // Disable dangerous operations engine.disable_symbol("eval"); // Register the goingson:: API module let goingson_module = create_goingson_module(); engine.register_static_module("goingson", Arc::new(goingson_module)); Self { engine, limits } } /// Compiles a script into an AST for caching. #[tracing::instrument(skip_all)] pub fn compile(&self, script: &str) -> Result { self.engine .compile(script) .map_err(|e| PluginError::ScriptError { plugin: "unknown".to_string(), message: e.to_string(), }) } /// Compiles a script with a plugin name for error messages. #[tracing::instrument(skip_all)] pub fn compile_plugin(&self, plugin_id: &str, script: &str) -> Result { self.engine .compile(script) .map_err(|e| PluginError::script(plugin_id, e.to_string())) } /// Checks if a function exists in the compiled AST. #[tracing::instrument(skip_all)] pub fn has_function(&self, ast: &AST, name: &str, arity: usize) -> bool { ast.iter_functions() .any(|f| f.name == name && f.params.len() == arity) } /// Calls a function in the script with no arguments. #[tracing::instrument(skip_all)] pub fn call_fn( &self, ast: &AST, plugin_id: &str, fn_name: &str, ) -> Result { self.engine .call_fn::(&mut rhai::Scope::new(), ast, fn_name, ()) .map_err(|e| self.map_rhai_error(plugin_id, e)) } /// Calls a function with one argument. #[tracing::instrument(skip_all)] pub fn call_fn_1(&self, ast: &AST, plugin_id: &str, fn_name: &str, arg: A) -> Result where A: Clone + Send + Sync + 'static, T: Clone + Send + Sync + 'static, { self.engine .call_fn::(&mut rhai::Scope::new(), ast, fn_name, (arg,)) .map_err(|e| self.map_rhai_error(plugin_id, e)) } /// Calls a function with two arguments. #[tracing::instrument(skip_all)] pub fn call_fn_2( &self, ast: &AST, plugin_id: &str, fn_name: &str, arg1: A, arg2: B, ) -> Result where A: Clone + Send + Sync + 'static, B: Clone + Send + Sync + 'static, T: Clone + Send + Sync + 'static, { self.engine .call_fn::(&mut rhai::Scope::new(), ast, fn_name, (arg1, arg2)) .map_err(|e| self.map_rhai_error(plugin_id, e)) } /// Calls a function and returns a Dynamic result. #[tracing::instrument(skip_all)] pub fn call_fn_dynamic( &self, ast: &AST, plugin_id: &str, fn_name: &str, args: impl rhai::FuncArgs, ) -> Result { self.engine .call_fn::(&mut rhai::Scope::new(), ast, fn_name, args) .map_err(|e| self.map_rhai_error(plugin_id, e)) } /// Maps a Rhai error to a PluginError. fn map_rhai_error(&self, plugin_id: &str, err: Box) -> PluginError { let message = err.to_string(); // Check for specific error types if message.contains("Too many operations") { PluginError::safety_limit(plugin_id, "Maximum operations exceeded") } else if message.contains("Stack overflow") { PluginError::safety_limit(plugin_id, "Maximum call depth exceeded") } else { PluginError::script(plugin_id, message) } } /// Returns the current safety limits. #[tracing::instrument(skip_all)] pub fn limits(&self) -> &SafetyLimits { &self.limits } /// Returns a reference to the underlying engine. #[tracing::instrument(skip_all)] pub fn inner(&self) -> &Engine { &self.engine } } impl Default for PluginEngine { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_compile_valid_script() { let engine = PluginEngine::new(); let result = engine.compile("fn hello() { 42 }"); assert!(result.is_ok()); } #[test] fn test_compile_invalid_script() { let engine = PluginEngine::new(); let result = engine.compile("fn hello( { }"); assert!(result.is_err()); } #[test] fn test_has_function() { let engine = PluginEngine::new(); let ast = engine.compile("fn describe() { } fn parse(x, y) { }").unwrap(); assert!(engine.has_function(&ast, "describe", 0)); assert!(engine.has_function(&ast, "parse", 2)); assert!(!engine.has_function(&ast, "describe", 1)); assert!(!engine.has_function(&ast, "missing", 0)); } #[test] fn test_call_function() { let engine = PluginEngine::new(); let ast = engine.compile("fn answer() { 42 }").unwrap(); let result: i64 = engine.call_fn(&ast, "test", "answer").unwrap(); assert_eq!(result, 42); } #[test] fn test_operation_limit() { let limits = SafetyLimits { max_operations: 100, ..Default::default() }; let engine = PluginEngine::with_limits(limits); let ast = engine .compile("fn infinite() { let x = 0; loop { x += 1; } }") .unwrap(); let result: Result = engine.call_fn(&ast, "test", "infinite"); assert!(result.is_err()); if let Err(PluginError::SafetyLimitExceeded { .. }) = result { // Expected } else { panic!("Expected SafetyLimitExceeded error"); } } // ============ Plugin Lifecycle: Error and Recovery ============ #[test] fn script_error_returns_plugin_error_not_panic() { let engine = PluginEngine::new(); let ast = engine .compile( r#" fn hook_on_task_created(task) { throw "something went wrong in hook"; } "#, ) .unwrap(); let result: Result = engine.call_fn_1(&ast, "my-hook-plugin", "hook_on_task_created", "task-1"); assert!(result.is_err()); match result.unwrap_err() { PluginError::ScriptError { plugin, message } => { assert_eq!(plugin, "my-hook-plugin"); assert!( message.contains("something went wrong in hook"), "Unexpected message: {}", message ); } other => panic!("Expected ScriptError, got {:?}", other), } } #[test] fn plugin_recoverable_after_hook_error() { let engine = PluginEngine::new(); let ast = engine .compile( r#" fn on_task_created(task_id) { if task_id == "bad" { throw "invalid task"; } 42 } "#, ) .unwrap(); // First call errors let err_result: Result = engine.call_fn_1(&ast, "recoverable", "on_task_created", "bad".to_string()); assert!(err_result.is_err()); match err_result.unwrap_err() { PluginError::ScriptError { .. } => {} other => panic!("Expected ScriptError, got {:?}", other), } // Second call with valid input succeeds -- engine did not poison itself let ok_result: i64 = engine.call_fn_1(&ast, "recoverable", "on_task_created", "good".to_string()).unwrap(); assert_eq!(ok_result, 42); } #[test] fn operation_limit_returns_safety_error_not_panic() { let limits = SafetyLimits { max_operations: 50, ..Default::default() }; let engine = PluginEngine::with_limits(limits); let ast = engine .compile( r#" fn expensive() { let x = 0; while x < 999999 { x += 1; } x } "#, ) .unwrap(); let result: Result = engine.call_fn(&ast, "expensive-plugin", "expensive"); assert!(result.is_err()); match result.unwrap_err() { PluginError::SafetyLimitExceeded { plugin, message } => { assert_eq!(plugin, "expensive-plugin"); assert!( message.contains("operations"), "Unexpected message: {}", message ); } other => panic!("Expected SafetyLimitExceeded, got {:?}", other), } } #[test] fn recoverable_after_operation_limit() { let limits = SafetyLimits { max_operations: 50, ..Default::default() }; let engine = PluginEngine::with_limits(limits); let ast = engine .compile( r#" fn expensive() { let x = 0; while x < 999999 { x += 1; } x } fn cheap() { 1 } "#, ) .unwrap(); // expensive() blows the ops limit let err_result: Result = engine.call_fn(&ast, "ops-test", "expensive"); assert!(matches!( err_result, Err(PluginError::SafetyLimitExceeded { .. }) )); // cheap() should still work -- the engine resets its operation counter per call let ok_result: i64 = engine.call_fn(&ast, "ops-test", "cheap").unwrap(); assert_eq!(ok_result, 1); } #[test] fn compile_execute_multiple_functions_lifecycle() { let engine = PluginEngine::new(); // Compile a plugin-like script with describe + parse let ast = engine .compile( r#" fn describe() { #{ name: "lifecycle-test", file_extensions: ["csv"] } } fn parse(file_path, options) { let items = []; items.push(#{ description: "parsed from " + file_path }); goingson::task_result(items) } "#, ) .unwrap(); // Validate function signatures exist assert!(engine.has_function(&ast, "describe", 0)); assert!(engine.has_function(&ast, "parse", 2)); assert!(!engine.has_function(&ast, "execute", 1)); // Execute describe() let desc: Dynamic = engine.call_fn(&ast, "lifecycle", "describe").unwrap(); let map = desc.try_cast::().unwrap(); assert_eq!( map.get("name").unwrap().clone().into_string().unwrap(), "lifecycle-test" ); // Execute parse() let options = rhai::Map::new(); let result: Dynamic = engine .call_fn_2(&ast, "lifecycle", "parse", "/tmp/test.csv".to_string(), options) .unwrap(); let result_map = result.try_cast::().unwrap(); assert_eq!( result_map .get("entity_type") .unwrap() .clone() .into_string() .unwrap(), "task" ); } #[test] fn eval_is_disabled() { let engine = PluginEngine::new(); let result = engine.compile(r#"fn sneaky() { eval("1 + 1") }"#); // eval is disabled at the symbol level, so compilation should fail assert!(result.is_err()); } #[test] fn call_fn_with_runtime_error_returns_script_error() { let engine = PluginEngine::new(); let ast = engine .compile("fn divide(a, b) { a / b }") .unwrap(); // Division by zero should produce a ScriptError let result: Result = engine.call_fn_2( &ast, "type-test", "divide", 42_i64, 0_i64, ); assert!(result.is_err()); match result.unwrap_err() { PluginError::ScriptError { plugin, .. } => { assert_eq!(plugin, "type-test"); } other => panic!("Expected ScriptError, got {:?}", other), } } }