Skip to main content

supreme_hook_config/
script_engine.rs

1//! Encapsulates all Rhai usage for the modding system.
2//!
3//! This module provides a sandboxed Rhai engine for evaluating `enable_if`
4//! expressions, running script patches, and interpolating strings. It
5//! ensures that all evaluations are bounded, safe, and use the same
6//! parameter binding rules.
7
8use crate::{ModParameter, ParameterKind};
9use rhai::{Engine, Scope};
10use std::collections::HashMap;
11
12#[derive(Debug, thiserror::Error)]
13pub enum ScriptError {
14    #[error("Compile error: {message}")]
15    Compile { message: String },
16    #[error("Runtime error: {message}")]
17    Runtime { message: String },
18    #[error("Unknown variable: {name}")]
19    UnknownVariable { name: String },
20    #[error("Invalid return type: expected {expected}, got {actual}")]
21    InvalidReturnType {
22        expected: &'static str,
23        actual: String,
24    },
25    #[error("Encoding error: {message}")]
26    Encoding { message: String },
27    #[error("Execution limit exceeded: {message}")]
28    ExecutionLimit { message: String },
29}
30
31#[derive(Debug, thiserror::Error)]
32pub enum PatchError {
33    #[error("Script error: {0}")]
34    Script(#[from] ScriptError),
35    #[error("Encoding error: {0}")]
36    Encoding(String),
37    #[error("Invalid patch: {0}")]
38    InvalidPatch(String),
39}
40
41/// Creates a restricted Rhai engine with sandbox limits applied.
42fn create_engine() -> Engine {
43    let mut engine = Engine::new();
44
45    // Disable dangerous features
46    engine.disable_symbol("eval");
47
48    // Apply execution limits
49    engine.set_max_expr_depths(50, 50);
50    engine.set_max_call_levels(30);
51    engine.set_max_operations(10_000_000);
52
53    // `url_encode` : percent-encodes a string for safe inclusion in
54    // a URL query value, the mod templates build URLs (managed-file
55    // `remote`), and values like the tournament player name can contain
56    // spaces / non-ASCII that would otherwise make the URL invalid.
57    engine.register_fn("url_encode", |s: &str| -> String {
58        let mut out = String::with_capacity(s.len());
59        for byte in s.bytes() {
60            match byte {
61                b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
62                    out.push(byte as char);
63                }
64                _ => {
65                    out.push_str(&format!("%{byte:02X}"));
66                }
67            }
68        }
69        out
70    });
71
72    // Route print/debug to logging
73    engine.on_print(|x| log::debug!(target: "rhai", "{x}"));
74    engine.on_debug(|x, _, _| log::trace!(target: "rhai", "{x:?}"));
75
76    engine
77}
78
79/// Populates the Rhai scope with all declared parameters.
80///
81/// If a parameter has no configured value, a type-appropriate default is used:
82/// - Boolean -> `false`
83/// - Dropdown / Text -> `""`
84/// - Number -> `0.0`
85fn populate_scope(
86    scope: &mut Scope,
87    parameters: &[ModParameter],
88    configuration: &HashMap<String, serde_json::Value>,
89) {
90    for param in parameters {
91        let value = configuration
92            .get(&param.id)
93            .cloned()
94            .unwrap_or_else(|| match param.kind {
95                ParameterKind::Boolean => serde_json::json!(false),
96                ParameterKind::Dropdown { .. } | ParameterKind::Text => serde_json::json!(""),
97                ParameterKind::Number { .. } => serde_json::json!(0.0),
98            });
99
100        match param.kind {
101            ParameterKind::Boolean => {
102                scope.push(&param.id, value.as_bool().unwrap_or(false));
103            }
104            ParameterKind::Dropdown { .. } | ParameterKind::Text => {
105                scope.push(&param.id, value.as_str().unwrap_or("").to_string());
106            }
107            ParameterKind::Number { .. } => {
108                scope.push(&param.id, value.as_f64().unwrap_or(0.0));
109            }
110        }
111    }
112}
113
114/// Evaluates an `enable_if` expression and returns a boolean result.
115pub fn evaluate_bool_expression(
116    expression: &str,
117    parameters: &[ModParameter],
118    configuration: &HashMap<String, serde_json::Value>,
119) -> Result<bool, ScriptError> {
120    let engine = create_engine();
121    let mut scope = Scope::new();
122    populate_scope(&mut scope, parameters, configuration);
123
124    engine
125        .eval_expression_with_scope::<bool>(&mut scope, expression)
126        .map_err(|e| ScriptError::Runtime {
127            message: e.to_string(),
128        })
129}
130
131/// Validates an `enable_if` expression at load time.
132pub fn validate_expression(
133    expression: &str,
134    parameters: &[ModParameter],
135) -> Result<(), ScriptError> {
136    let engine = create_engine();
137    let mut scope = Scope::new();
138    populate_scope(&mut scope, parameters, &HashMap::new());
139
140    engine
141        .compile_expression(expression)
142        .map_err(|e| ScriptError::Compile {
143            message: e.to_string(),
144        })?;
145
146    engine
147        .eval_expression_with_scope::<bool>(&mut scope, expression)
148        .map_err(|e| {
149            let err_str = e.to_string();
150            if err_str.contains("Variable not found") {
151                ScriptError::UnknownVariable {
152                    name: expression.to_string(),
153                }
154            } else {
155                ScriptError::Runtime { message: err_str }
156            }
157        })?;
158    Ok(())
159}
160
161/// Runs a script patch against the provided input string.
162///
163/// Besides `input` (the file's decoded contents) and the configured
164/// parameters, the script's scope is populated with:
165/// - `file_path`: `rel_key`, the game-root-relative, lowercase,
166///   forward-slashed path of the file being patched. Stable across every
167///   read of the same file, regardless of which rule/redirect ultimately
168///   served it -- see the `rel_key` discussion in the resolver docs.
169/// - `timestamp`: the current time, in nanoseconds since the Unix epoch,
170///   at the moment this script runs. This is *not* a stable or
171///   reproducible value, usable as an ad-hoc PRNG, for example.
172pub fn run_script_patch(
173    script: &str,
174    input: &str,
175    rel_key: &str,
176    parameters: &[ModParameter],
177    configuration: &HashMap<String, serde_json::Value>,
178) -> Result<String, ScriptError> {
179    let engine = create_engine();
180    let mut scope = Scope::new();
181    scope.push("input", input.to_string());
182    scope.push("file_path", rel_key.to_string());
183    let timestamp: i64 = std::time::SystemTime::now()
184        .duration_since(std::time::UNIX_EPOCH)
185        .unwrap_or_default()
186        .as_nanos() as i64;
187    scope.push("timestamp", timestamp);
188    populate_scope(&mut scope, parameters, configuration);
189
190    let result: String = engine.eval_with_scope(&mut scope, script).map_err(|e| {
191        let err_str = e.to_string();
192        if err_str.contains("Output type incorrect") {
193            ScriptError::InvalidReturnType {
194                expected: "string",
195                actual: "unknown".to_string(),
196            }
197        } else {
198            ScriptError::Runtime { message: err_str }
199        }
200    })?;
201
202    Ok(result)
203}
204
205/// Validates a script patch at load time.
206pub fn validate_script(script: &str, parameters: &[ModParameter]) -> Result<(), ScriptError> {
207    let engine = create_engine();
208    let mut scope = Scope::new();
209    scope.push("input", String::new());
210    populate_scope(&mut scope, parameters, &HashMap::new());
211
212    engine.compile(script).map_err(|e| ScriptError::Compile {
213        message: e.to_string(),
214    })?;
215
216    Ok(())
217}
218
219/// Interpolates `${...}` expressions in `text` using Rhai's native backtick
220/// string interpolation, with each parameter's value from `configuration`
221/// available as a variable in the evaluation scope. The expression inside
222/// `${...}` can be a bare parameter id (e.g. `${Board_Style}`) or any
223/// valid Rhai expression (e.g. `${Board_Style + "_v2"}`).
224///
225/// This works by escaping backslashes and backticks in `text`, wrapping it
226/// in backticks, and evaluating it as a Rhai script. Rhai's parser natively
227/// handles the `${...}` interpolation syntax inside backtick strings.
228///
229/// If interpolation fails (e.g. an unknown variable, or a malformed
230/// `${...}` expression), an error is returned.
231pub(crate) fn interpolate_string(
232    text: &str,
233    parameters: &[ModParameter],
234    configuration: &HashMap<String, serde_json::Value>,
235) -> Result<String, ScriptError> {
236    let engine = create_engine();
237    let mut scope = Scope::new();
238    populate_scope(&mut scope, parameters, configuration);
239
240    // Escape backslashes and backticks so the text can be safely embedded
241    // into a Rhai backtick string literal. Backtick strings in Rhai support
242    // native interpolation via `${...}` without needing to escape double
243    // or single quotes.
244    let escaped_text = text.replace('\\', "\\\\").replace('`', "\\`");
245    let script = format!("`{}`", escaped_text);
246
247    engine
248        .eval_with_scope::<String>(&mut scope, &script)
249        .map_err(|e| {
250            let err_str = e.to_string();
251            if err_str.contains("Variable not found") {
252                ScriptError::UnknownVariable {
253                    name: text.to_string(),
254                }
255            } else {
256                ScriptError::Runtime { message: err_str }
257            }
258        })
259}