From 55c97eb64927953a2ef80dcab6c3facc4895c4d9 Mon Sep 17 00:00:00 2001 From: Stephen Chung Date: Fri, 15 May 2020 11:43:32 +0800 Subject: [PATCH] Add progress tracking and operations limit. --- README.md | 150 +++++++++++++++++++++++++++----- src/api.rs | 110 ++++++++++++++++++++---- src/engine.rs | 204 +++++++++++++++++++++++++++++++++----------- src/fn_native.rs | 5 ++ src/result.rs | 14 ++- tests/operations.rs | 93 ++++++++++++++++++++ tests/stack.rs | 1 + 7 files changed, 489 insertions(+), 88 deletions(-) create mode 100644 tests/operations.rs diff --git a/README.md b/README.md index 0b359294..c602fa92 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,15 @@ Rhai's current features set: * Fairly efficient (1 million iterations in 0.75 sec on my 5 year old laptop) * Low compile-time overhead (~0.6 sec debug/~3 sec release for script runner app) * Relatively little `unsafe` code (yes there are some for performance reasons) +* Sand-boxed (the scripting [`Engine`] can be declared immutable which cannot mutate the containing environment + unless explicitly allowed via `RefCell` etc.) +* Rugged (protection against [stack-overflow](#maximum-stack-depth) and [runaway scripts](#maximum-number-of-operations)) +* Able to set limits on script resource usage (e.g. see [tracking progress](#tracking-progress)) * [`no-std`](#optional-features) support * [Function overloading](#function-overloading) * [Operator overloading](#operator-overloading) * [Modules] -* Compiled script is [optimized](#script-optimization) for repeat evaluations +* Compiled script is [optimized](#script-optimization) for repeated evaluations * Support for [minimal builds](#minimal-builds) by excluding unneeded language [features](#optional-features) * Very few additional dependencies (right now only [`num-traits`](https://crates.io/crates/num-traits/) to do checked arithmetic operations); for [`no-std`](#optional-features) builds, a number of additional dependencies are @@ -63,19 +67,19 @@ Beware that in order to use pre-releases (e.g. alpha and beta), the exact versio Optional features ----------------- -| Feature | Description | -| ------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| `unchecked` | Exclude arithmetic checking (such as overflows and division by zero). Beware that a bad script may panic the entire system! | -| `no_function` | Disable script-defined functions if not needed. | -| `no_index` | Disable [arrays] and indexing features if not needed. | -| `no_object` | Disable support for custom types and objects. | -| `no_float` | Disable floating-point numbers and math if not needed. | -| `no_optimize` | Disable the script optimizer. | -| `no_module` | Disable modules. | -| `only_i32` | Set the system integer type to `i32` and disable all other integer types. `INT` is set to `i32`. | -| `only_i64` | Set the system integer type to `i64` and disable all other integer types. `INT` is set to `i64`. | -| `no_std` | Build for `no-std`. Notice that additional dependencies will be pulled in to replace `std` features. | -| `sync` | Restrict all values types to those that are `Send + Sync`. Under this feature, [`Engine`], [`Scope`] and `AST` are all `Send + Sync`. | +| Feature | Description | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `unchecked` | Exclude arithmetic checking (such as over-flows and division by zero), stack depth limit and operations count limit. Beware that a bad script may panic the entire system! | +| `no_function` | Disable script-defined functions if not needed. | +| `no_index` | Disable [arrays] and indexing features if not needed. | +| `no_object` | Disable support for custom types and objects. | +| `no_float` | Disable floating-point numbers and math if not needed. | +| `no_optimize` | Disable the script optimizer. | +| `no_module` | Disable modules. | +| `only_i32` | Set the system integer type to `i32` and disable all other integer types. `INT` is set to `i32`. | +| `only_i64` | Set the system integer type to `i64` and disable all other integer types. `INT` is set to `i64`. | +| `no_std` | Build for `no-std`. Notice that additional dependencies will be pulled in to replace `std` features. | +| `sync` | Restrict all values types to those that are `Send + Sync`. Under this feature, [`Engine`], [`Scope`] and `AST` are all `Send + Sync`. | By default, Rhai includes all the standard functionalities in a small, tight package. Most features are here to opt-**out** of certain functionalities that are not needed. @@ -269,7 +273,7 @@ let ast = engine.compile_file("hello_world.rhai".into())?; ### Calling Rhai functions from Rust -Rhai also allows working _backwards_ from the other direction - i.e. calling a Rhai-scripted function from Rust via `call_fn`. +Rhai also allows working _backwards_ from the other direction - i.e. calling a Rhai-scripted function from Rust via `Engine::call_fn`. Functions declared with `private` are hidden and cannot be called from Rust (see also [modules]). ```rust @@ -372,7 +376,7 @@ Use `Engine::new_raw` to create a _raw_ `Engine`, in which _nothing_ is added, n ### Packages -Rhai functional features are provided in different _packages_ that can be loaded via a call to `load_package`. +Rhai functional features are provided in different _packages_ that can be loaded via a call to `Engine::load_package`. Packages reside under `rhai::packages::*` and the trait `rhai::packages::Package` must be loaded in order for packages to be used. @@ -759,7 +763,7 @@ Because they [_short-circuit_](#boolean-operators), `&&` and `||` are handled sp overriding them has no effect at all. Operator functions cannot be defined as a script function (because operators syntax are not valid function names). -However, operator functions _can_ be registered to the [`Engine`] via `register_fn`, `register_result_fn` etc. +However, operator functions _can_ be registered to the [`Engine`] via the methods `Engine::register_fn`, `Engine::register_result_fn` etc. When a custom operator function is registered with the same name as an operator, it _overloads_ (or overrides) the built-in version. ```rust @@ -2043,7 +2047,7 @@ debug("world!"); // prints "world!" to stdout using debug formatting ### Overriding `print` and `debug` with callback functions When embedding Rhai into an application, it is usually necessary to trap `print` and `debug` output -(for logging into a tracking log, for example). +(for logging into a tracking log, for example) with the `Engine::on_print` and `Engine::on_debug` methods: ```rust // Any function or closure that takes an '&str' argument can be used to override @@ -2237,7 +2241,7 @@ Built-in module resolvers are grouped under the `rhai::module_resolvers` module | `FileModuleResolver` | The default module resolution service, not available under the [`no_std`] feature. Loads a script file (based off the current directory) with `.rhai` extension.
The base directory can be changed via the `FileModuleResolver::new_with_path()` constructor function.
`FileModuleResolver::create_module()` loads a script file and returns a module. | | `StaticModuleResolver` | Loads modules that are statically added. This can be used when the [`no_std`] feature is turned on. | -An [`Engine`]'s module resolver is set via a call to `set_module_resolver`: +An [`Engine`]'s module resolver is set via a call to `Engine::set_module_resolver`: ```rust // Use the 'StaticModuleResolver' @@ -2248,6 +2252,112 @@ engine.set_module_resolver(Some(resolver)); engine.set_module_resolver(None); ``` +Ruggedization - protect against DoS attacks +------------------------------------------ + +For scripting systems open to user-land scripts, it is always best to limit the amount of resources used by a script +so that it does not crash the system by consuming all resources. + +The most important resources to watch out for are: + +* **Memory**: A malignant script may continuously grow an [array] or [object map] until all memory is consumed. +* **CPU**: A malignant script may run an infinite tight loop that consumes all CPU cycles. +* **Time**: A malignant script may run indefinitely, thereby blocking the calling system waiting for a result. +* **Stack**: A malignant script may consume attempt an infinite recursive call that exhausts the call stack. +* **Overflows**: A malignant script may deliberately cause numeric over-flows and/or under-flows, and/or bad + floating-point representations, in order to crash the system. +* **Files**: A malignant script may continuously `import` an external module within an infinite loop, + thereby putting heavy load on the file-system (or even the network if the file is not local). +* **Data**: A malignant script may attempt to read from and/or write to data that it does not own. If this happens, + it is a severe security breach and may put the entire system at risk. + +### Maximum number of operations + +Rhai by default does not limit how much time or CPU a script consumes. +This can be changed via the `Engine::set_max_operations` method, with zero being unlimited (the default). + +```rust +let mut engine = Engine::new(); + +engine.set_max_operations(500); // allow only up to 500 operations for this script + +engine.set_max_operations(0); // allow unlimited operations +``` + +The concept of one single _operation_ in Rhai is volatile - it roughly equals one expression node, +one statement, one iteration of a loop, or one function call etc. with sub-expressions and statement +blocks executed inside these contexts accumulated on top. + +One _operation_ can take an unspecified amount of time and CPU cycles, depending on the particular operation +involved. For example, loading a constant consumes very few CPU cycles, while calling an external Rust function, +though also counted as only one operation, may consume much more resources. + +The _operation count_ is intended to be a very course-grained measurement of the amount of CPU that a script +is consuming, and allows the system to impose a hard upper limit. + +A script exceeding the maximum operations count will terminate with an error result. +This check can be disabled via the [`unchecked`] feature for higher performance +(but higher risks as well). + +### Tracking progress + +To track script evaluation progress and to force-terminate a script prematurely (for any reason), +provide a closure to the `Engine::on_progress` method: + +```rust +let mut engine = Engine::new(); + +engine.on_progress(|count| { // 'count' is the number of operations performed + if count % 1000 == 0 { + println!("{}", count); // print out a progress log every 1,000 operations + } + true // return 'true' to continue the script + // returning 'false' will terminate the script +}); +``` + +The closure passed to `Engine::on_progress` will be called once every operation. +Return `false` to terminate the script immediately. + +### Maximum stack depth + +Rhai by default limits function calls to a maximum depth of 256 levels (28 levels in debug build). +This limit may be changed via the `Engine::set_max_call_levels` method. +The limit can be disabled via the [`unchecked`] feature for higher performance +(but higher risks as well). + +```rust +let mut engine = Engine::new(); + +engine.set_max_call_levels(10); // allow only up to 10 levels of function calls + +engine.set_max_call_levels(0); // allow no function calls at all (max depth = zero) +``` + +A script exceeding the maximum call stack depth will terminate with an error result. + +### Checked arithmetic + +All arithmetic calculations in Rhai are _checked_, meaning that the script terminates with an error whenever +it detects a numeric over-flow/under-flow condition or an invalid floating-point operation, instead of +crashing the entire system. This checking can be turned off via the [`unchecked`] feature for higher performance +(but higher risks as well). + +### Access to external data + +Rhai is _sand-boxed_ so a script can never read from outside its own environment. +Furthermore, an [`Engine`] created non-`mut` cannot mutate any state outside of itself; +so it is highly recommended that [`Engine`]'s are created immutable as much as possible. + +```rust +let mut engine = Engine::new(); // create mutable 'Engine' + +engine.register_get("add", add); // configure 'engine' + +let engine = engine; // shadow the variable so that 'engine' is now immutable +``` + + Script optimization =================== @@ -2358,7 +2468,7 @@ There are actually three levels of optimizations: `None`, `Simple` and `Full`. * `Full` is _much_ more aggressive, _including_ running functions on constant arguments to determine their result. One benefit to this is that many more optimization opportunities arise, especially with regards to comparison operators. -An [`Engine`]'s optimization level is set via a call to `set_optimization_level`: +An [`Engine`]'s optimization level is set via a call to `Engine::set_optimization_level`: ```rust // Turn on aggressive optimizations diff --git a/src/api.rs b/src/api.rs index 24b28474..78ceaf72 100644 --- a/src/api.rs +++ b/src/api.rs @@ -865,7 +865,7 @@ impl Engine { scope: &mut Scope, ast: &AST, ) -> Result> { - let result = self.eval_ast_with_scope_raw(scope, ast)?; + let (result, _) = self.eval_ast_with_scope_raw(scope, ast)?; let return_type = self.map_type_name(result.type_name()); @@ -881,7 +881,7 @@ impl Engine { &self, scope: &mut Scope, ast: &AST, - ) -> Result> { + ) -> Result<(Dynamic, u64), Box> { let mut state = State::new(ast.fn_lib()); ast.statements() @@ -893,6 +893,7 @@ impl Engine { EvalAltResult::Return(out, _) => Ok(out), _ => Err(err), }) + .map(|v| (v, state.operations)) } /// Evaluate a file, but throw away the result and only return error (if any). @@ -1015,10 +1016,11 @@ impl Engine { .get_function_by_signature(name, args.len(), true) .ok_or_else(|| Box::new(EvalAltResult::ErrorFunctionNotFound(name.into(), pos)))?; - let state = State::new(fn_lib); + let mut state = State::new(fn_lib); + let args = args.as_mut(); - let result = - self.call_script_fn(Some(scope), &state, name, fn_def, args.as_mut(), pos, 0)?; + let (result, _) = + self.call_script_fn(Some(scope), &mut state, name, fn_def, args, pos, 0, 0)?; let return_type = self.map_type_name(result.type_name()); @@ -1058,6 +1060,84 @@ impl Engine { optimize_into_ast(self, scope, stmt, fn_lib, optimization_level) } + /// Register a callback for script evaluation progress. + /// + /// # Example + /// + /// ``` + /// # fn main() -> Result<(), Box> { + /// # use std::sync::RwLock; + /// # use std::sync::Arc; + /// use rhai::Engine; + /// + /// let result = Arc::new(RwLock::new(0_u64)); + /// let logger = result.clone(); + /// + /// let mut engine = Engine::new(); + /// + /// engine.on_progress(move |ops| { + /// if ops > 10000 { + /// false + /// } else if ops % 800 == 0 { + /// *logger.write().unwrap() = ops; + /// true + /// } else { + /// true + /// } + /// }); + /// + /// engine.consume("for x in range(0, 50000) {}") + /// .expect_err("should error"); + /// + /// assert_eq!(*result.read().unwrap(), 9600); + /// + /// # Ok(()) + /// # } + /// ``` + #[cfg(feature = "sync")] + pub fn on_progress(&mut self, callback: impl Fn(u64) -> bool + Send + Sync + 'static) { + self.progress = Some(Box::new(callback)); + } + + /// Register a callback for script evaluation progress. + /// + /// # Example + /// + /// ``` + /// # fn main() -> Result<(), Box> { + /// # use std::cell::Cell; + /// # use std::rc::Rc; + /// use rhai::Engine; + /// + /// let result = Rc::new(Cell::new(0_u64)); + /// let logger = result.clone(); + /// + /// let mut engine = Engine::new(); + /// + /// engine.on_progress(move |ops| { + /// if ops > 10000 { + /// false + /// } else if ops % 800 == 0 { + /// logger.set(ops); + /// true + /// } else { + /// true + /// } + /// }); + /// + /// engine.consume("for x in range(0, 50000) {}") + /// .expect_err("should error"); + /// + /// assert_eq!(result.get(), 9600); + /// + /// # Ok(()) + /// # } + /// ``` + #[cfg(not(feature = "sync"))] + pub fn on_progress(&mut self, callback: impl Fn(u64) -> bool + 'static) { + self.progress = Some(Box::new(callback)); + } + /// Override default action of `print` (print to stdout using `println!`) /// /// # Example @@ -1092,21 +1172,21 @@ impl Engine { /// /// ``` /// # fn main() -> Result<(), Box> { - /// # use std::sync::RwLock; - /// # use std::sync::Arc; + /// # use std::cell::RefCell; + /// # use std::rc::Rc; /// use rhai::Engine; /// - /// let result = Arc::new(RwLock::new(String::from(""))); + /// let result = Rc::new(RefCell::new(String::from(""))); /// /// let mut engine = Engine::new(); /// /// // Override action of 'print' function /// let logger = result.clone(); - /// engine.on_print(move |s| logger.write().unwrap().push_str(s)); + /// engine.on_print(move |s| logger.borrow_mut().push_str(s)); /// /// engine.consume("print(40 + 2);")?; /// - /// assert_eq!(*result.read().unwrap(), "42"); + /// assert_eq!(*result.borrow(), "42"); /// # Ok(()) /// # } /// ``` @@ -1149,21 +1229,21 @@ impl Engine { /// /// ``` /// # fn main() -> Result<(), Box> { - /// # use std::sync::RwLock; - /// # use std::sync::Arc; + /// # use std::cell::RefCell; + /// # use std::rc::Rc; /// use rhai::Engine; /// - /// let result = Arc::new(RwLock::new(String::from(""))); + /// let result = Rc::new(RefCell::new(String::from(""))); /// /// let mut engine = Engine::new(); /// /// // Override action of 'print' function /// let logger = result.clone(); - /// engine.on_debug(move |s| logger.write().unwrap().push_str(s)); + /// engine.on_debug(move |s| logger.borrow_mut().push_str(s)); /// /// engine.consume(r#"debug("hello");"#)?; /// - /// assert_eq!(*result.read().unwrap(), r#""hello""#); + /// assert_eq!(*result.borrow(), r#""hello""#); /// # Ok(()) /// # } /// ``` diff --git a/src/engine.rs b/src/engine.rs index 7ef00d45..6f159487 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -3,7 +3,7 @@ use crate::any::{Dynamic, Union}; use crate::calc_fn_hash; use crate::error::ParseErrorType; -use crate::fn_native::{FnCallArgs, NativeFunctionABI, PrintCallback}; +use crate::fn_native::{FnCallArgs, NativeFunctionABI, PrintCallback, ProgressCallback}; use crate::module::Module; use crate::optimize::OptimizationLevel; use crate::packages::{CorePackage, Package, PackageLibrary, PackagesCollection, StandardPackage}; @@ -28,7 +28,7 @@ use crate::stdlib::{ format, iter::{empty, once, repeat}, mem, - num::NonZeroUsize, + num::{NonZeroU64, NonZeroUsize}, ops::{Deref, DerefMut}, rc::Rc, string::{String, ToString}, @@ -48,12 +48,17 @@ pub type Array = Vec; #[cfg(not(feature = "no_object"))] pub type Map = HashMap; +#[cfg(not(feature = "unchecked"))] #[cfg(debug_assertions)] pub const MAX_CALL_STACK_DEPTH: usize = 28; +#[cfg(not(feature = "unchecked"))] #[cfg(not(debug_assertions))] pub const MAX_CALL_STACK_DEPTH: usize = 256; +#[cfg(feature = "unchecked")] +pub const MAX_CALL_STACK_DEPTH: usize = usize::MAX; + pub const KEYWORD_PRINT: &str = "print"; pub const KEYWORD_DEBUG: &str = "debug"; pub const KEYWORD_TYPE_OF: &str = "type_of"; @@ -146,6 +151,9 @@ pub struct State<'a> { /// Level of the current scope. The global (root) level is zero, a new block (or function call) /// is one level higher, and so on. pub scope_level: usize, + + /// Number of operations performed. + pub operations: u64, } impl<'a> State<'a> { @@ -155,6 +163,7 @@ impl<'a> State<'a> { always_search: false, fn_lib, scope_level: 0, + operations: 0, } } /// Does a certain script-defined function exist in the `State`? @@ -304,6 +313,8 @@ pub struct Engine { pub(crate) print: Box, /// Closure for implementing the `debug` command. pub(crate) debug: Box, + /// Closure for progress reporting. + pub(crate) progress: Option>, /// Optimize the AST after compilation. pub(crate) optimization_level: OptimizationLevel, @@ -311,6 +322,8 @@ pub struct Engine { /// /// Defaults to 28 for debug builds and 256 for non-debug builds. pub(crate) max_call_stack_depth: usize, + /// Maximum number of operations to run. + pub(crate) max_operations: Option, } impl Default for Engine { @@ -333,6 +346,9 @@ impl Default for Engine { print: Box::new(default_print), debug: Box::new(default_print), + // progress callback + progress: None, + // optimization level #[cfg(feature = "no_optimize")] optimization_level: OptimizationLevel::None, @@ -346,6 +362,7 @@ impl Default for Engine { optimization_level: OptimizationLevel::Full, max_call_stack_depth: MAX_CALL_STACK_DEPTH, + max_operations: None, }; #[cfg(feature = "no_stdlib")] @@ -471,6 +488,7 @@ impl Engine { type_names: Default::default(), print: Box::new(|_| {}), debug: Box::new(|_| {}), + progress: None, #[cfg(feature = "no_optimize")] optimization_level: OptimizationLevel::None, @@ -484,6 +502,7 @@ impl Engine { optimization_level: OptimizationLevel::Full, max_call_stack_depth: MAX_CALL_STACK_DEPTH, + max_operations: None, } } @@ -515,10 +534,17 @@ impl Engine { /// Set the maximum levels of function calls allowed for a script in order to avoid /// infinite recursion and stack overflows. + #[cfg(not(feature = "unchecked"))] pub fn set_max_call_levels(&mut self, levels: usize) { self.max_call_stack_depth = levels } + /// Set the maximum number of operations allowed for a script to run to avoid + /// consuming too much resources (0 for unlimited). + #[cfg(not(feature = "unchecked"))] + pub fn set_max_operations(&mut self, operations: u64) { + self.max_operations = NonZeroU64::new(operations); + } /// Set the module resolution service used by the `Engine`. /// /// Not available under the `no_module` feature. @@ -537,7 +563,7 @@ impl Engine { pub(crate) fn call_fn_raw( &self, scope: Option<&mut Scope>, - state: &State, + state: &mut State, fn_name: &str, hashes: (u64, u64), args: &mut FnCallArgs, @@ -546,6 +572,8 @@ impl Engine { pos: Position, level: usize, ) -> Result<(Dynamic, bool), Box> { + self.inc_operations(state, pos)?; + // Check for stack overflow if level > self.max_call_stack_depth { return Err(Box::new(EvalAltResult::ErrorStackOverflow(pos))); @@ -554,9 +582,12 @@ impl Engine { // First search in script-defined functions (can override built-in) if hashes.1 > 0 { if let Some(fn_def) = state.get_function(hashes.1) { - return self - .call_script_fn(scope, state, fn_name, fn_def, args, pos, level) - .map(|v| (v, false)); + let ops = state.operations; + let (result, operations) = + self.call_script_fn(scope, &state, fn_name, fn_def, args, pos, level, ops)?; + state.operations = operations; + self.inc_operations(state, pos)?; + return Ok((result, false)); } } @@ -677,14 +708,17 @@ impl Engine { args: &mut FnCallArgs, pos: Position, level: usize, - ) -> Result> { + operations: u64, + ) -> Result<(Dynamic, u64), Box> { match scope { // Extern scope passed in which is not empty Some(scope) if scope.len() > 0 => { let scope_len = scope.len(); - let mut state = State::new(state.fn_lib); + let mut local_state = State::new(state.fn_lib); - state.scope_level += 1; + local_state.operations = operations; + self.inc_operations(&mut local_state, pos)?; + local_state.scope_level += 1; // Put arguments into scope as variables scope.extend( @@ -696,14 +730,14 @@ impl Engine { args.into_iter().map(|v| mem::take(*v)), ) .map(|(name, value)| { - let var_name = unsafe_cast_var_name(name.as_str(), &state); + let var_name = unsafe_cast_var_name(name.as_str(), &local_state); (var_name, ScopeEntryType::Normal, value) }), ); // Evaluate the function at one higher level of call depth let result = self - .eval_stmt(scope, &mut state, &fn_def.body, level + 1) + .eval_stmt(scope, &mut local_state, &fn_def.body, level + 1) .or_else(|err| match *err { // Convert return statement to return value EvalAltResult::Return(x, _) => Ok(x), @@ -725,13 +759,16 @@ impl Engine { // No need to reset `state.scope_level` because it is thrown away scope.rewind(scope_len); - return result; + return result.map(|v| (v, local_state.operations)); } // No new scope - create internal scope _ => { let mut scope = Scope::new(); - let mut state = State::new(state.fn_lib); - state.scope_level += 1; + let mut local_state = State::new(state.fn_lib); + + local_state.operations = operations; + self.inc_operations(&mut local_state, pos)?; + local_state.scope_level += 1; // Put arguments into scope as variables scope.extend( @@ -748,7 +785,7 @@ impl Engine { // Evaluate the function at one higher level of call depth // No need to reset `state.scope_level` because it is thrown away return self - .eval_stmt(&mut scope, &mut state, &fn_def.body, level + 1) + .eval_stmt(&mut scope, &mut local_state, &fn_def.body, level + 1) .or_else(|err| match *err { // Convert return statement to return value EvalAltResult::Return(x, _) => Ok(x), @@ -764,7 +801,8 @@ impl Engine { err, pos, ))), - }); + }) + .map(|v| (v, local_state.operations)); } } } @@ -788,7 +826,7 @@ impl Engine { /// **DO NOT** reuse the argument values unless for the first `&mut` argument - all others are silently replaced by `()`! fn exec_fn_call( &self, - state: &State, + state: &mut State, fn_name: &str, hash_fn_def: u64, args: &mut FnCallArgs, @@ -827,7 +865,7 @@ impl Engine { fn eval_script_expr( &self, scope: &mut Scope, - state: &State, + state: &mut State, script: &Dynamic, pos: Position, ) -> Result> { @@ -854,14 +892,20 @@ impl Engine { let ast = AST::new(statements, state.fn_lib.clone()); // Evaluate the AST - self.eval_ast_with_scope_raw(scope, &ast) - .map_err(|err| err.new_position(pos)) + let (result, operations) = self + .eval_ast_with_scope_raw(scope, &ast) + .map_err(|err| err.new_position(pos))?; + + state.operations += operations; + self.inc_operations(state, pos)?; + + return Ok(result); } /// Chain-evaluate a dot/index chain. fn eval_dot_index_chain_helper( &self, - state: &State, + state: &mut State, mut target: Target, rhs: &Expr, idx_values: &mut StaticVec, @@ -1145,7 +1189,7 @@ impl Engine { /// Get the value at the indexed position of a base type fn get_indexed_mut<'a>( &self, - state: &State, + state: &mut State, val: &'a mut Dynamic, is_ref: bool, mut idx: Dynamic, @@ -1153,6 +1197,8 @@ impl Engine { op_pos: Position, create: bool, ) -> Result, Box> { + self.inc_operations(state, op_pos)?; + match val { #[cfg(not(feature = "no_index"))] Dynamic(Union::Array(arr)) => { @@ -1234,6 +1280,8 @@ impl Engine { rhs: &Expr, level: usize, ) -> Result> { + self.inc_operations(state, rhs.position())?; + let mut lhs_value = self.eval_expr(scope, state, lhs, level)?; let rhs_value = self.eval_expr(scope, state, rhs, level)?; @@ -1288,6 +1336,8 @@ impl Engine { expr: &Expr, level: usize, ) -> Result> { + self.inc_operations(state, expr.position())?; + match expr { Expr::IntegerConstant(x) => Ok(x.0.into()), #[cfg(not(feature = "no_float"))] @@ -1461,9 +1511,16 @@ impl Engine { // First search in script-defined functions (can override built-in) if let Some(fn_def) = module.get_qualified_scripted_fn(*hash_fn_def) { - self.call_script_fn(None, state, name, fn_def, args.as_mut(), *pos, level) + let args = args.as_mut(); + let ops = state.operations; + let (result, operations) = + self.call_script_fn(None, state, name, fn_def, args, *pos, level, ops)?; + state.operations = operations; + self.inc_operations(state, *pos)?; + Ok(result) } else { // Then search in Rust functions + self.inc_operations(state, *pos)?; // Rust functions are indexed in two steps: // 1) Calculate a hash in a similar manner to script-defined functions, @@ -1538,6 +1595,8 @@ impl Engine { stmt: &Stmt, level: usize, ) -> Result> { + self.inc_operations(state, stmt.position())?; + match stmt { // No-op Stmt::Noop(_) => Ok(Default::default()), @@ -1546,11 +1605,10 @@ impl Engine { Stmt::Expr(expr) => { let result = self.eval_expr(scope, state, expr, level)?; - Ok(if let Expr::Assignment(_) = *expr.as_ref() { + Ok(match expr.as_ref() { // If it is an assignment, erase the result at the root - Default::default() - } else { - result + Expr::Assignment(_) => Default::default(), + _ => result, }) } @@ -1574,25 +1632,32 @@ impl Engine { } // If-else statement - Stmt::IfThenElse(x) => self - .eval_expr(scope, state, &x.0, level)? - .as_bool() - .map_err(|_| Box::new(EvalAltResult::ErrorLogicGuard(x.0.position()))) - .and_then(|guard_val| { - if guard_val { - self.eval_stmt(scope, state, &x.1, level) - } else if let Some(stmt) = &x.2 { - self.eval_stmt(scope, state, stmt, level) - } else { - Ok(Default::default()) - } - }), + Stmt::IfThenElse(x) => { + let (expr, if_block, else_block) = x.as_ref(); + + self.eval_expr(scope, state, expr, level)? + .as_bool() + .map_err(|_| Box::new(EvalAltResult::ErrorLogicGuard(expr.position()))) + .and_then(|guard_val| { + if guard_val { + self.eval_stmt(scope, state, if_block, level) + } else if let Some(stmt) = else_block { + self.eval_stmt(scope, state, stmt, level) + } else { + Ok(Default::default()) + } + }) + } // While loop Stmt::While(x) => loop { - match self.eval_expr(scope, state, &x.0, level)?.as_bool() { - Ok(true) => match self.eval_stmt(scope, state, &x.1, level) { - Ok(_) => (), + let (expr, body) = x.as_ref(); + + match self.eval_expr(scope, state, expr, level)?.as_bool() { + Ok(true) => match self.eval_stmt(scope, state, body, level) { + Ok(_) => { + self.inc_operations(state, body.position())?; + } Err(err) => match *err { EvalAltResult::ErrorLoopBreak(false, _) => (), EvalAltResult::ErrorLoopBreak(true, _) => return Ok(Default::default()), @@ -1600,14 +1665,18 @@ impl Engine { }, }, Ok(false) => return Ok(Default::default()), - Err(_) => return Err(Box::new(EvalAltResult::ErrorLogicGuard(x.0.position()))), + Err(_) => { + return Err(Box::new(EvalAltResult::ErrorLogicGuard(expr.position()))) + } } }, // Loop statement Stmt::Loop(body) => loop { match self.eval_stmt(scope, state, body, level) { - Ok(_) => (), + Ok(_) => { + self.inc_operations(state, body.position())?; + } Err(err) => match *err { EvalAltResult::ErrorLoopBreak(false, _) => (), EvalAltResult::ErrorLoopBreak(true, _) => return Ok(Default::default()), @@ -1618,7 +1687,8 @@ impl Engine { // For loop Stmt::For(x) => { - let iter_type = self.eval_expr(scope, state, &x.1, level)?; + let (name, expr, stmt) = x.as_ref(); + let iter_type = self.eval_expr(scope, state, expr, level)?; let tid = iter_type.type_id(); if let Some(iter_fn) = self @@ -1627,15 +1697,16 @@ impl Engine { .or_else(|| self.packages.get_iter(tid)) { // Add the loop variable - let var_name = unsafe_cast_var_name(&x.0, &state); + let var_name = unsafe_cast_var_name(name, &state); scope.push(var_name, ()); let index = scope.len() - 1; state.scope_level += 1; for loop_var in iter_fn(iter_type) { *scope.get_mut(index).0 = loop_var; + self.inc_operations(state, stmt.position())?; - match self.eval_stmt(scope, state, &x.2, level) { + match self.eval_stmt(scope, state, stmt, level) { Ok(_) => (), Err(err) => match *err { EvalAltResult::ErrorLoopBreak(false, _) => (), @@ -1690,26 +1761,29 @@ impl Engine { // Let statement Stmt::Let(x) if x.1.is_some() => { - let ((var_name, _), expr) = x.as_ref(); + let ((var_name, pos), expr) = x.as_ref(); let val = self.eval_expr(scope, state, expr.as_ref().unwrap(), level)?; let var_name = unsafe_cast_var_name(var_name, &state); scope.push_dynamic_value(var_name, ScopeEntryType::Normal, val, false); + self.inc_operations(state, *pos)?; Ok(Default::default()) } Stmt::Let(x) => { - let ((var_name, _), _) = x.as_ref(); + let ((var_name, pos), _) = x.as_ref(); let var_name = unsafe_cast_var_name(var_name, &state); scope.push(var_name, ()); + self.inc_operations(state, *pos)?; Ok(Default::default()) } // Const statement Stmt::Const(x) if x.1.is_constant() => { - let ((var_name, _), expr) = x.as_ref(); + let ((var_name, pos), expr) = x.as_ref(); let val = self.eval_expr(scope, state, &expr, level)?; let var_name = unsafe_cast_var_name(var_name, &state); scope.push_dynamic_value(var_name, ScopeEntryType::Constant, val, true); + self.inc_operations(state, *pos)?; Ok(Default::default()) } @@ -1718,7 +1792,7 @@ impl Engine { // Import statement Stmt::Import(x) => { - let (expr, (name, _)) = x.as_ref(); + let (expr, (name, pos)) = x.as_ref(); #[cfg(feature = "no_module")] unreachable!(); @@ -1736,6 +1810,7 @@ impl Engine { let mod_name = unsafe_cast_var_name(name, &state); scope.push_module(mod_name, module); + self.inc_operations(state, *pos)?; Ok(Default::default()) } else { Err(Box::new(EvalAltResult::ErrorModuleNotFound( @@ -1776,6 +1851,31 @@ impl Engine { } } + /// Check if the number of operations stay within limit. + fn inc_operations(&self, state: &mut State, pos: Position) -> Result<(), Box> { + state.operations += 1; + + #[cfg(not(feature = "unchecked"))] + { + // Guard against too many operations + if let Some(max) = self.max_operations { + if state.operations > max.get() { + return Err(Box::new(EvalAltResult::ErrorTooManyOperations(pos))); + } + } + } + + // Report progress - only in steps + if let Some(progress) = self.progress.as_ref() { + if !progress(state.operations) { + // Terminate script if progress returns false + return Err(Box::new(EvalAltResult::ErrorTerminated(pos))); + } + } + + Ok(()) + } + /// Map a type_name into a pretty-print name pub(crate) fn map_type_name<'a>(&'a self, name: &'a str) -> &'a str { self.type_names diff --git a/src/fn_native.rs b/src/fn_native.rs index a8daac5f..335f9919 100644 --- a/src/fn_native.rs +++ b/src/fn_native.rs @@ -20,6 +20,11 @@ pub type PrintCallback = dyn Fn(&str) + Send + Sync + 'static; #[cfg(not(feature = "sync"))] pub type PrintCallback = dyn Fn(&str) + 'static; +#[cfg(feature = "sync")] +pub type ProgressCallback = dyn Fn(u64) -> bool + Send + Sync + 'static; +#[cfg(not(feature = "sync"))] +pub type ProgressCallback = dyn Fn(u64) -> bool + 'static; + // Define callback function types #[cfg(feature = "sync")] pub trait ObjectGetCallback: Fn(&mut T) -> U + Send + Sync + 'static {} diff --git a/src/result.rs b/src/result.rs index c33c810b..455a67ce 100644 --- a/src/result.rs +++ b/src/result.rs @@ -79,8 +79,12 @@ pub enum EvalAltResult { ErrorDotExpr(String, Position), /// Arithmetic error encountered. Wrapped value is the error message. ErrorArithmetic(String, Position), + /// Number of operations over maximum limit. + ErrorTooManyOperations(Position), /// Call stack over maximum limit. ErrorStackOverflow(Position), + /// The script is prematurely terminated. + ErrorTerminated(Position), /// Run-time error encountered. Wrapped value is the error message. ErrorRuntime(String, Position), @@ -137,7 +141,9 @@ impl EvalAltResult { Self::ErrorInExpr(_) => "Malformed 'in' expression", Self::ErrorDotExpr(_, _) => "Malformed dot expression", Self::ErrorArithmetic(_, _) => "Arithmetic error", + Self::ErrorTooManyOperations(_) => "Too many operations", Self::ErrorStackOverflow(_) => "Stack overflow", + Self::ErrorTerminated(_) => "Script terminated.", Self::ErrorRuntime(_, _) => "Runtime error", Self::ErrorLoopBreak(true, _) => "Break statement not inside a loop", Self::ErrorLoopBreak(false, _) => "Continue statement not inside a loop", @@ -183,7 +189,9 @@ impl fmt::Display for EvalAltResult { | Self::ErrorAssignmentToUnknownLHS(pos) | Self::ErrorInExpr(pos) | Self::ErrorDotExpr(_, pos) - | Self::ErrorStackOverflow(pos) => write!(f, "{} ({})", desc, pos), + | Self::ErrorTooManyOperations(pos) + | Self::ErrorStackOverflow(pos) + | Self::ErrorTerminated(pos) => write!(f, "{} ({})", desc, pos), Self::ErrorRuntime(s, pos) => { write!(f, "{} ({})", if s.is_empty() { desc } else { s }, pos) @@ -299,7 +307,9 @@ impl EvalAltResult { | Self::ErrorInExpr(pos) | Self::ErrorDotExpr(_, pos) | Self::ErrorArithmetic(_, pos) + | Self::ErrorTooManyOperations(pos) | Self::ErrorStackOverflow(pos) + | Self::ErrorTerminated(pos) | Self::ErrorRuntime(_, pos) | Self::ErrorLoopBreak(_, pos) | Self::Return(_, pos) => *pos, @@ -335,7 +345,9 @@ impl EvalAltResult { | Self::ErrorInExpr(pos) | Self::ErrorDotExpr(_, pos) | Self::ErrorArithmetic(_, pos) + | Self::ErrorTooManyOperations(pos) | Self::ErrorStackOverflow(pos) + | Self::ErrorTerminated(pos) | Self::ErrorRuntime(_, pos) | Self::ErrorLoopBreak(_, pos) | Self::Return(_, pos) => *pos = new_position, diff --git a/tests/operations.rs b/tests/operations.rs new file mode 100644 index 00000000..2637ef3b --- /dev/null +++ b/tests/operations.rs @@ -0,0 +1,93 @@ +#![cfg(not(feature = "unchecked"))] +use rhai::{Engine, EvalAltResult}; + +#[test] +fn test_max_operations() -> Result<(), Box> { + let mut engine = Engine::new(); + engine.set_max_operations(500); + + engine.on_progress(|count| { + if count % 100 == 0 { + println!("{}", count); + } + true + }); + + engine.eval::<()>("let x = 0; while x < 20 { x += 1; }")?; + + assert!(matches!( + *engine + .eval::<()>("for x in range(0, 500) {}") + .expect_err("should error"), + EvalAltResult::ErrorTooManyOperations(_) + )); + + engine.set_max_operations(0); + + engine.eval::<()>("for x in range(0, 10000) {}")?; + + Ok(()) +} + +#[test] +fn test_max_operations_functions() -> Result<(), Box> { + let mut engine = Engine::new(); + engine.set_max_operations(500); + + engine.on_progress(|count| { + if count % 100 == 0 { + println!("{}", count); + } + true + }); + + engine.eval::<()>( + r#" + fn inc(x) { x + 1 } + let x = 0; + while x < 20 { x = inc(x); } + "#, + )?; + + assert!(matches!( + *engine + .eval::<()>( + r#" + fn inc(x) { x + 1 } + let x = 0; + while x < 1000 { x = inc(x); } + "# + ) + .expect_err("should error"), + EvalAltResult::ErrorTooManyOperations(_) + )); + + Ok(()) +} + +#[test] +fn test_max_operations_eval() -> Result<(), Box> { + let mut engine = Engine::new(); + engine.set_max_operations(500); + + engine.on_progress(|count| { + if count % 100 == 0 { + println!("{}", count); + } + true + }); + + assert!(matches!( + *engine + .eval::<()>( + r#" + let script = "for x in range(0, 500) {}"; + eval(script); + "# + ) + .expect_err("should error"), + EvalAltResult::ErrorTooManyOperations(_) + )); + + Ok(()) +} diff --git a/tests/stack.rs b/tests/stack.rs index 1eeb4250..5ebb550b 100644 --- a/tests/stack.rs +++ b/tests/stack.rs @@ -15,6 +15,7 @@ fn test_stack_overflow() -> Result<(), Box> { 325 ); + #[cfg(not(feature = "unchecked"))] match engine.eval::<()>( r" fn foo(n) { if n == 0 { 0 } else { n + foo(n-1) } }