Skip to main content

camel_language_api/
language_limits.rs

1//! Tunable resource limits for in-process scripting engines (Rhai, Boa JS).
2//!
3//! These types live in `camel-language-api` (the lowest shared language contract
4//! crate) to avoid a circular dependency: `camel-language-rhai` / `camel-language-js`
5//! already depend on `camel-language-api`, so the limit types must be defined here
6//! rather than in `camel-config` (which those crates cannot depend on).
7//!
8//! All fields are `Option`; `None` means "use the rust-camel runtime default" —
9//! never the upstream engine's unlimited default (per ADR-0011). The resolve
10//! functions in each language crate document the concrete defaults.
11
12use serde::{Deserialize, Serialize};
13
14// ---------------------------------------------------------------------------
15// Rhai limits
16// ---------------------------------------------------------------------------
17
18/// Tunable resource limits for a single Rhai `Engine` instance.
19///
20/// Surfaced in `Camel.toml` as:
21///
22/// ```toml
23/// [languages.rhai.limits]
24/// max-operations = 500000
25/// max-string-size = 10485760
26/// max-array-size = 100000
27/// max-map-size = 100000
28/// max-expression-depth = 10
29/// max-function-expression-depth = 5
30/// execution-timeout-ms = 5000
31/// ```
32#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
33#[serde(rename_all = "kebab-case", deny_unknown_fields)]
34pub struct RhaiLimitsConfig {
35    /// Maximum number of operations before Rhai terminates the script
36    /// (rhai: `max_operations`). Counter resets each call.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub max_operations: Option<u64>,
39
40    /// Maximum string size in bytes (rhai: `max_string_size`).
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub max_string_size: Option<usize>,
43
44    /// Maximum array size in elements (rhai: `max_array_size`).
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub max_array_size: Option<usize>,
47
48    /// Maximum map size in key-value pairs (rhai: `max_map_size`).
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub max_map_size: Option<usize>,
51
52    /// Maximum nesting depth for expressions (rhai: `max_expression_depth`).
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub max_expression_depth: Option<u32>,
55
56    /// Maximum nesting depth for function call expressions
57    /// (rhai: `max_function_expression_depth`).
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub max_function_expression_depth: Option<u32>,
60
61    /// Maximum execution wall-clock time in milliseconds.
62    /// Rhai has no built-in timeout; the consuming code enforces this via
63    /// `Engine::on_progress` or a tokio timeout wrapper.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub execution_timeout_ms: Option<u64>,
66}
67
68// ---------------------------------------------------------------------------
69// JS (Boa) limits
70// ---------------------------------------------------------------------------
71
72/// Tunable resource limits for a single Boa JS `Context` instance.
73///
74/// Surfaced in `Camel.toml` as:
75///
76/// ```toml
77/// [languages.js.limits]
78/// execution-timeout-ms = 5000
79/// max-loop-iterations = 1000000
80/// max-recursion-depth = 64
81/// max-stack-size = 1048576
82/// ```
83#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
84#[serde(rename_all = "kebab-case", deny_unknown_fields)]
85pub struct JsLimitsConfig {
86    /// Maximum execution wall-clock time in milliseconds.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub execution_timeout_ms: Option<u64>,
89
90    /// Maximum number of loop iterations before Boa terminates execution.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub max_loop_iterations: Option<u64>,
93
94    /// Maximum recursion depth for function calls.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub max_recursion_depth: Option<usize>,
97
98    /// Maximum Boa VM stack size, in stack slots (not bytes).
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub max_stack_size: Option<usize>,
101}
102
103// ---------------------------------------------------------------------------
104// Wrapper structs for Camel.toml sections
105// ---------------------------------------------------------------------------
106
107/// Rhai engine configuration block in `Camel.toml`.
108#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
109#[serde(rename_all = "kebab-case", deny_unknown_fields)]
110pub struct RhaiEngineConfig {
111    /// Resource limits for the Rhai engine.
112    #[serde(default)]
113    pub limits: RhaiLimitsConfig,
114}
115
116/// JS (Boa) engine configuration block in `Camel.toml`.
117#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
118#[serde(rename_all = "kebab-case", deny_unknown_fields)]
119pub struct JsEngineConfig {
120    /// Resource limits for the Boa JS engine.
121    #[serde(default)]
122    pub limits: JsLimitsConfig,
123}
124
125/// Top-level `[languages]` section in `Camel.toml`.
126///
127/// ```toml
128/// [languages.rhai.limits]
129/// max-operations = 500000
130///
131/// [languages.js.limits]
132/// execution-timeout-ms = 5000
133/// ```
134#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
135#[serde(rename_all = "kebab-case", deny_unknown_fields)]
136pub struct LanguagesConfig {
137    /// Rhai engine configuration.
138    #[serde(default)]
139    pub rhai: RhaiEngineConfig,
140
141    /// JS (Boa) engine configuration.
142    #[serde(default)]
143    pub js: JsEngineConfig,
144}
145
146// ---------------------------------------------------------------------------
147// Tests
148// ---------------------------------------------------------------------------
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    // -- RhaiLimitsConfig tests -------------------------------------------
155
156    #[test]
157    fn rhai_defaults_to_all_none() {
158        let cfg = RhaiLimitsConfig::default();
159        assert_eq!(cfg.max_operations, None);
160        assert_eq!(cfg.max_string_size, None);
161        assert_eq!(cfg.max_array_size, None);
162        assert_eq!(cfg.max_map_size, None);
163        assert_eq!(cfg.max_expression_depth, None);
164        assert_eq!(cfg.max_function_expression_depth, None);
165        assert_eq!(cfg.execution_timeout_ms, None);
166    }
167
168    #[test]
169    fn rhai_deserialises_full_block() {
170        let toml = toml::toml! {
171            max-operations = 500000i64
172            max-string-size = 10485760i64
173            max-array-size = 100000i64
174            max-map-size = 100000i64
175            max-expression-depth = 10
176            max-function-expression-depth = 5
177            execution-timeout-ms = 5000i64
178        };
179        let cfg: RhaiLimitsConfig = toml.try_into().expect("deserialize");
180        assert_eq!(cfg.max_operations, Some(500_000));
181        assert_eq!(cfg.max_string_size, Some(10_485_760));
182        assert_eq!(cfg.max_array_size, Some(100_000));
183        assert_eq!(cfg.max_map_size, Some(100_000));
184        assert_eq!(cfg.max_expression_depth, Some(10));
185        assert_eq!(cfg.max_function_expression_depth, Some(5));
186        assert_eq!(cfg.execution_timeout_ms, Some(5000));
187    }
188
189    #[test]
190    fn rhai_deserialises_partial_block() {
191        let toml = toml::toml! {
192            max-operations = 100000i64
193            execution-timeout-ms = 3000i64
194        };
195        let cfg: RhaiLimitsConfig = toml.try_into().expect("deserialize");
196        assert_eq!(cfg.max_operations, Some(100_000));
197        assert_eq!(cfg.execution_timeout_ms, Some(3000));
198        // All other fields should be None
199        assert_eq!(cfg.max_string_size, None);
200        assert_eq!(cfg.max_expression_depth, None);
201    }
202
203    #[test]
204    fn rhai_rejects_unknown_field() {
205        let toml = toml::toml! {
206            max-operations = 100000i64
207            fuel = 1000i64
208        };
209        let result: Result<RhaiLimitsConfig, _> = toml.try_into();
210        assert!(result.is_err(), "deny_unknown_fields must reject `fuel`");
211    }
212
213    #[test]
214    fn rhai_serde_round_trip_preserves_set_fields() {
215        let original = RhaiLimitsConfig {
216            max_operations: Some(200_000),
217            max_string_size: Some(5_242_880),
218            execution_timeout_ms: Some(10_000),
219            ..Default::default()
220        };
221        let serialized = toml::to_string(&original).expect("serialize");
222        let back: RhaiLimitsConfig = toml::from_str(&serialized).expect("deserialize");
223        assert_eq!(original, back);
224    }
225
226    #[test]
227    fn rhai_skip_serializing_none_fields() {
228        let cfg = RhaiLimitsConfig {
229            max_operations: Some(100_000),
230            max_string_size: None,
231            execution_timeout_ms: Some(5000),
232            ..Default::default()
233        };
234        let s = toml::to_string(&cfg).expect("serialize");
235        assert!(s.contains("max-operations"));
236        assert!(s.contains("execution-timeout-ms"));
237        assert!(!s.contains("max-string-size"));
238        assert!(!s.contains("max-expression-depth"));
239    }
240
241    // -- JsLimitsConfig tests ---------------------------------------------
242
243    #[test]
244    fn js_defaults_to_all_none() {
245        let cfg = JsLimitsConfig::default();
246        assert_eq!(cfg.execution_timeout_ms, None);
247        assert_eq!(cfg.max_loop_iterations, None);
248        assert_eq!(cfg.max_recursion_depth, None);
249        assert_eq!(cfg.max_stack_size, None);
250    }
251
252    #[test]
253    fn js_deserialises_full_block() {
254        let toml = toml::toml! {
255            execution-timeout-ms = 5000i64
256            max-loop-iterations = 1000000i64
257            max-recursion-depth = 64i64
258            max-stack-size = 1048576i64
259        };
260        let cfg: JsLimitsConfig = toml.try_into().expect("deserialize");
261        assert_eq!(cfg.execution_timeout_ms, Some(5000));
262        assert_eq!(cfg.max_loop_iterations, Some(1_000_000));
263        assert_eq!(cfg.max_recursion_depth, Some(64));
264        assert_eq!(cfg.max_stack_size, Some(1_048_576));
265    }
266
267    #[test]
268    fn js_deserialises_partial_block() {
269        let toml = toml::toml! {
270            execution-timeout-ms = 3000i64
271            max-recursion-depth = 32i64
272        };
273        let cfg: JsLimitsConfig = toml.try_into().expect("deserialize");
274        assert_eq!(cfg.execution_timeout_ms, Some(3000));
275        assert_eq!(cfg.max_recursion_depth, Some(32));
276        assert_eq!(cfg.max_loop_iterations, None);
277        assert_eq!(cfg.max_stack_size, None);
278    }
279
280    #[test]
281    fn js_rejects_unknown_field() {
282        let toml = toml::toml! {
283            execution-timeout-ms = 5000i64
284            fuel = 1000i64
285        };
286        let result: Result<JsLimitsConfig, _> = toml.try_into();
287        assert!(result.is_err(), "deny_unknown_fields must reject `fuel`");
288    }
289
290    #[test]
291    fn js_serde_round_trip_preserves_set_fields() {
292        let original = JsLimitsConfig {
293            execution_timeout_ms: Some(10_000),
294            max_loop_iterations: Some(500_000),
295            ..Default::default()
296        };
297        let serialized = toml::to_string(&original).expect("serialize");
298        let back: JsLimitsConfig = toml::from_str(&serialized).expect("deserialize");
299        assert_eq!(original, back);
300    }
301
302    #[test]
303    fn js_skip_serializing_none_fields() {
304        let cfg = JsLimitsConfig {
305            execution_timeout_ms: Some(5000),
306            max_loop_iterations: Some(1_000_000),
307            ..Default::default()
308        };
309        let s = toml::to_string(&cfg).expect("serialize");
310        assert!(s.contains("execution-timeout-ms"));
311        assert!(s.contains("max-loop-iterations"));
312        assert!(!s.contains("max-recursion-depth"));
313        assert!(!s.contains("max-stack-size"));
314    }
315
316    // -- Wrapper struct tests ---------------------------------------------
317
318    #[test]
319    fn rhai_engine_config_defaults() {
320        let cfg = RhaiEngineConfig::default();
321        assert_eq!(cfg.limits, RhaiLimitsConfig::default());
322    }
323
324    #[test]
325    fn js_engine_config_defaults() {
326        let cfg = JsEngineConfig::default();
327        assert_eq!(cfg.limits, JsLimitsConfig::default());
328    }
329
330    #[test]
331    fn languages_config_defaults() {
332        let cfg = LanguagesConfig::default();
333        assert_eq!(cfg.rhai.limits, RhaiLimitsConfig::default());
334        assert_eq!(cfg.js.limits, JsLimitsConfig::default());
335    }
336
337    #[test]
338    fn languages_deserialises_both_engines() {
339        let toml_str = r#"
340            [rhai.limits]
341            max-operations = 500000
342            execution-timeout-ms = 5000
343
344            [js.limits]
345            execution-timeout-ms = 3000
346            max-loop-iterations = 1000000
347        "#;
348        let cfg: LanguagesConfig = toml::from_str(toml_str).expect("deserialize");
349        assert_eq!(cfg.rhai.limits.max_operations, Some(500_000));
350        assert_eq!(cfg.rhai.limits.execution_timeout_ms, Some(5000));
351        assert_eq!(cfg.js.limits.execution_timeout_ms, Some(3000));
352        assert_eq!(cfg.js.limits.max_loop_iterations, Some(1_000_000));
353    }
354
355    #[test]
356    fn languages_serde_round_trip() {
357        let original = LanguagesConfig {
358            rhai: RhaiEngineConfig {
359                limits: RhaiLimitsConfig {
360                    max_operations: Some(100_000),
361                    ..Default::default()
362                },
363            },
364            js: JsEngineConfig::default(),
365        };
366        let serialized = toml::to_string(&original).expect("serialize");
367        let back: LanguagesConfig = toml::from_str(&serialized).expect("deserialize");
368        assert_eq!(original, back);
369    }
370}