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/// max-call-levels = 64
31/// execution-timeout-ms = 5000
32/// ```
33#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
34#[serde(rename_all = "kebab-case", deny_unknown_fields)]
35pub struct RhaiLimitsConfig {
36    /// Maximum number of operations before Rhai terminates the script
37    /// (rhai: `max_operations`). Counter resets each call.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub max_operations: Option<u64>,
40
41    /// Maximum string size in bytes (rhai: `max_string_size`).
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub max_string_size: Option<usize>,
44
45    /// Maximum array size in elements (rhai: `max_array_size`).
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub max_array_size: Option<usize>,
48
49    /// Maximum map size in key-value pairs (rhai: `max_map_size`).
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub max_map_size: Option<usize>,
52
53    /// Maximum nesting depth for expressions (rhai: `max_expression_depth`).
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub max_expression_depth: Option<u32>,
56
57    /// Maximum nesting depth for function call expressions
58    /// (rhai: `max_function_expression_depth`).
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub max_function_expression_depth: Option<u32>,
61
62    /// Maximum call-stack depth (rhai: `max_call_levels`). Default 64 — pins a
63    /// single value and removes the upstream `Engine::new_raw()` debug/release
64    /// asymmetry (8 in debug, 64 in release). rc-dip6.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub max_call_levels: Option<u32>,
67
68    /// Maximum execution wall-clock time in milliseconds.
69    /// Rhai has no built-in timeout; the consuming code enforces this via
70    /// `Engine::on_progress` or a tokio timeout wrapper.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub execution_timeout_ms: Option<u64>,
73}
74
75// ---------------------------------------------------------------------------
76// JS (Boa) limits
77// ---------------------------------------------------------------------------
78
79/// Tunable resource limits for a single Boa JS `Context` instance.
80///
81/// Surfaced in `Camel.toml` as:
82///
83/// ```toml
84/// [languages.js.limits]
85/// execution-timeout-ms = 5000
86/// max-loop-iterations = 1000000
87/// max-recursion-depth = 64
88/// max-stack-size = 1048576
89/// ```
90#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
91#[serde(rename_all = "kebab-case", deny_unknown_fields)]
92pub struct JsLimitsConfig {
93    /// Maximum execution wall-clock time in milliseconds.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub execution_timeout_ms: Option<u64>,
96
97    /// Maximum number of loop iterations before Boa terminates execution.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub max_loop_iterations: Option<u64>,
100
101    /// Maximum recursion depth for function calls.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub max_recursion_depth: Option<usize>,
104
105    /// Maximum Boa VM stack size, in stack slots (not bytes).
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub max_stack_size: Option<usize>,
108}
109
110// ---------------------------------------------------------------------------
111// MiniJinja limits
112// ---------------------------------------------------------------------------
113
114/// Tunable resource limits for a single MiniJinja `Environment` instance.
115///
116/// Surfaced in `Camel.toml` as:
117///
118/// ```toml
119/// [languages.minijinja.limits]
120/// max-template-source-size = 1048576
121/// max-context-size = 4194304
122/// max-output-size = 4194304
123/// fuel = 100000
124/// max-recursion-depth = 64
125/// execution-timeout-ms = 5000
126/// ```
127#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
128#[serde(rename_all = "kebab-case", deny_unknown_fields)]
129pub struct MinijinjaLimitsConfig {
130    /// Maximum size of the compiled template source in bytes.
131    /// (minijinja: `Environment::set_max_template_source_size`)
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub max_template_source_size: Option<usize>,
134
135    /// Maximum serialised context size in bytes.
136    /// (minijinja: `Environment::set_max_context_size`)
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub max_context_size: Option<usize>,
139
140    /// Maximum rendered output size in bytes.
141    /// (minijinja: `Environment::set_max_output_size`)
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub max_output_size: Option<usize>,
144
145    /// Fuel limit for the MiniJinja VM (coarse instruction budget).
146    /// (minijinja: `Environment::set_fuel`)
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub fuel: Option<u64>,
149
150    /// Maximum recursion depth for template includes/blocks.
151    /// (minijinja: `Environment::set_max_recursion_depth`)
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub max_recursion_depth: Option<u32>,
154
155    /// Maximum execution wall-clock time in milliseconds.
156    /// The consuming code enforces this via a tokio timeout wrapper.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub execution_timeout_ms: Option<u64>,
159}
160
161// ---------------------------------------------------------------------------
162// Wrapper structs for Camel.toml sections
163// ---------------------------------------------------------------------------
164
165/// Rhai engine configuration block in `Camel.toml`.
166#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
167#[serde(rename_all = "kebab-case", deny_unknown_fields)]
168pub struct RhaiEngineConfig {
169    /// Resource limits for the Rhai engine.
170    #[serde(default)]
171    pub limits: RhaiLimitsConfig,
172}
173
174/// JS (Boa) engine configuration block in `Camel.toml`.
175#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
176#[serde(rename_all = "kebab-case", deny_unknown_fields)]
177pub struct JsEngineConfig {
178    /// Resource limits for the Boa JS engine.
179    #[serde(default)]
180    pub limits: JsLimitsConfig,
181}
182
183/// MiniJinja engine configuration block in `Camel.toml`.
184#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
185#[serde(rename_all = "kebab-case", deny_unknown_fields)]
186pub struct MinijinjaEngineConfig {
187    /// Resource limits for the MiniJinja engine.
188    #[serde(default)]
189    pub limits: MinijinjaLimitsConfig,
190}
191
192/// Top-level `[languages]` section in `Camel.toml`.
193///
194/// ```toml
195/// [languages.rhai.limits]
196/// max-operations = 500000
197///
198/// [languages.js.limits]
199/// execution-timeout-ms = 5000
200/// ```
201#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
202#[serde(rename_all = "kebab-case", deny_unknown_fields)]
203pub struct LanguagesConfig {
204    /// Rhai engine configuration.
205    #[serde(default)]
206    pub rhai: RhaiEngineConfig,
207
208    /// JS (Boa) engine configuration.
209    #[serde(default)]
210    pub js: JsEngineConfig,
211
212    /// MiniJinja engine configuration.
213    #[serde(default)]
214    pub minijinja: MinijinjaEngineConfig,
215}
216
217// ---------------------------------------------------------------------------
218// Tests
219// ---------------------------------------------------------------------------
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    // -- RhaiLimitsConfig tests -------------------------------------------
226
227    #[test]
228    fn rhai_defaults_to_all_none() {
229        let cfg = RhaiLimitsConfig::default();
230        assert_eq!(cfg.max_operations, None);
231        assert_eq!(cfg.max_string_size, None);
232        assert_eq!(cfg.max_array_size, None);
233        assert_eq!(cfg.max_map_size, None);
234        assert_eq!(cfg.max_expression_depth, None);
235        assert_eq!(cfg.max_function_expression_depth, None);
236        assert_eq!(cfg.execution_timeout_ms, None);
237    }
238
239    #[test]
240    fn rhai_deserialises_full_block() {
241        let toml = toml::toml! {
242            max-operations = 500000i64
243            max-string-size = 10485760i64
244            max-array-size = 100000i64
245            max-map-size = 100000i64
246            max-expression-depth = 10
247            max-function-expression-depth = 5
248            execution-timeout-ms = 5000i64
249        };
250        let cfg: RhaiLimitsConfig = toml.try_into().expect("deserialize");
251        assert_eq!(cfg.max_operations, Some(500_000));
252        assert_eq!(cfg.max_string_size, Some(10_485_760));
253        assert_eq!(cfg.max_array_size, Some(100_000));
254        assert_eq!(cfg.max_map_size, Some(100_000));
255        assert_eq!(cfg.max_expression_depth, Some(10));
256        assert_eq!(cfg.max_function_expression_depth, Some(5));
257        assert_eq!(cfg.execution_timeout_ms, Some(5000));
258    }
259
260    #[test]
261    fn rhai_deserialises_partial_block() {
262        let toml = toml::toml! {
263            max-operations = 100000i64
264            execution-timeout-ms = 3000i64
265        };
266        let cfg: RhaiLimitsConfig = toml.try_into().expect("deserialize");
267        assert_eq!(cfg.max_operations, Some(100_000));
268        assert_eq!(cfg.execution_timeout_ms, Some(3000));
269        // All other fields should be None
270        assert_eq!(cfg.max_string_size, None);
271        assert_eq!(cfg.max_expression_depth, None);
272    }
273
274    #[test]
275    fn rhai_rejects_unknown_field() {
276        let toml = toml::toml! {
277            max-operations = 100000i64
278            fuel = 1000i64
279        };
280        let result: Result<RhaiLimitsConfig, _> = toml.try_into();
281        assert!(result.is_err(), "deny_unknown_fields must reject `fuel`");
282    }
283
284    #[test]
285    fn rhai_serde_round_trip_preserves_set_fields() {
286        let original = RhaiLimitsConfig {
287            max_operations: Some(200_000),
288            max_string_size: Some(5_242_880),
289            execution_timeout_ms: Some(10_000),
290            ..Default::default()
291        };
292        let serialized = toml::to_string(&original).expect("serialize");
293        let back: RhaiLimitsConfig = toml::from_str(&serialized).expect("deserialize");
294        assert_eq!(original, back);
295    }
296
297    #[test]
298    fn rhai_skip_serializing_none_fields() {
299        let cfg = RhaiLimitsConfig {
300            max_operations: Some(100_000),
301            max_string_size: None,
302            execution_timeout_ms: Some(5000),
303            ..Default::default()
304        };
305        let s = toml::to_string(&cfg).expect("serialize");
306        assert!(s.contains("max-operations"));
307        assert!(s.contains("execution-timeout-ms"));
308        assert!(!s.contains("max-string-size"));
309        assert!(!s.contains("max-expression-depth"));
310    }
311
312    // -- JsLimitsConfig tests ---------------------------------------------
313
314    #[test]
315    fn js_defaults_to_all_none() {
316        let cfg = JsLimitsConfig::default();
317        assert_eq!(cfg.execution_timeout_ms, None);
318        assert_eq!(cfg.max_loop_iterations, None);
319        assert_eq!(cfg.max_recursion_depth, None);
320        assert_eq!(cfg.max_stack_size, None);
321    }
322
323    #[test]
324    fn js_deserialises_full_block() {
325        let toml = toml::toml! {
326            execution-timeout-ms = 5000i64
327            max-loop-iterations = 1000000i64
328            max-recursion-depth = 64i64
329            max-stack-size = 1048576i64
330        };
331        let cfg: JsLimitsConfig = toml.try_into().expect("deserialize");
332        assert_eq!(cfg.execution_timeout_ms, Some(5000));
333        assert_eq!(cfg.max_loop_iterations, Some(1_000_000));
334        assert_eq!(cfg.max_recursion_depth, Some(64));
335        assert_eq!(cfg.max_stack_size, Some(1_048_576));
336    }
337
338    #[test]
339    fn js_deserialises_partial_block() {
340        let toml = toml::toml! {
341            execution-timeout-ms = 3000i64
342            max-recursion-depth = 32i64
343        };
344        let cfg: JsLimitsConfig = toml.try_into().expect("deserialize");
345        assert_eq!(cfg.execution_timeout_ms, Some(3000));
346        assert_eq!(cfg.max_recursion_depth, Some(32));
347        assert_eq!(cfg.max_loop_iterations, None);
348        assert_eq!(cfg.max_stack_size, None);
349    }
350
351    #[test]
352    fn js_rejects_unknown_field() {
353        let toml = toml::toml! {
354            execution-timeout-ms = 5000i64
355            fuel = 1000i64
356        };
357        let result: Result<JsLimitsConfig, _> = toml.try_into();
358        assert!(result.is_err(), "deny_unknown_fields must reject `fuel`");
359    }
360
361    #[test]
362    fn js_serde_round_trip_preserves_set_fields() {
363        let original = JsLimitsConfig {
364            execution_timeout_ms: Some(10_000),
365            max_loop_iterations: Some(500_000),
366            ..Default::default()
367        };
368        let serialized = toml::to_string(&original).expect("serialize");
369        let back: JsLimitsConfig = toml::from_str(&serialized).expect("deserialize");
370        assert_eq!(original, back);
371    }
372
373    #[test]
374    fn js_skip_serializing_none_fields() {
375        let cfg = JsLimitsConfig {
376            execution_timeout_ms: Some(5000),
377            max_loop_iterations: Some(1_000_000),
378            ..Default::default()
379        };
380        let s = toml::to_string(&cfg).expect("serialize");
381        assert!(s.contains("execution-timeout-ms"));
382        assert!(s.contains("max-loop-iterations"));
383        assert!(!s.contains("max-recursion-depth"));
384        assert!(!s.contains("max-stack-size"));
385    }
386
387    // -- Wrapper struct tests ---------------------------------------------
388
389    #[test]
390    fn rhai_engine_config_defaults() {
391        let cfg = RhaiEngineConfig::default();
392        assert_eq!(cfg.limits, RhaiLimitsConfig::default());
393    }
394
395    #[test]
396    fn js_engine_config_defaults() {
397        let cfg = JsEngineConfig::default();
398        assert_eq!(cfg.limits, JsLimitsConfig::default());
399    }
400
401    #[test]
402    fn languages_config_defaults() {
403        let cfg = LanguagesConfig::default();
404        assert_eq!(cfg.rhai.limits, RhaiLimitsConfig::default());
405        assert_eq!(cfg.js.limits, JsLimitsConfig::default());
406        assert_eq!(cfg.minijinja.limits, MinijinjaLimitsConfig::default());
407    }
408
409    #[test]
410    fn languages_deserialises_both_engines() {
411        let toml_str = r#"
412            [rhai.limits]
413            max-operations = 500000
414            execution-timeout-ms = 5000
415
416            [js.limits]
417            execution-timeout-ms = 3000
418            max-loop-iterations = 1000000
419        "#;
420        let cfg: LanguagesConfig = toml::from_str(toml_str).expect("deserialize");
421        assert_eq!(cfg.rhai.limits.max_operations, Some(500_000));
422        assert_eq!(cfg.rhai.limits.execution_timeout_ms, Some(5000));
423        assert_eq!(cfg.js.limits.execution_timeout_ms, Some(3000));
424        assert_eq!(cfg.js.limits.max_loop_iterations, Some(1_000_000));
425    }
426
427    #[test]
428    fn languages_serde_round_trip() {
429        let original = LanguagesConfig {
430            rhai: RhaiEngineConfig {
431                limits: RhaiLimitsConfig {
432                    max_operations: Some(100_000),
433                    ..Default::default()
434                },
435            },
436            js: JsEngineConfig::default(),
437            minijinja: MinijinjaEngineConfig::default(),
438        };
439        let serialized = toml::to_string(&original).expect("serialize");
440        let back: LanguagesConfig = toml::from_str(&serialized).expect("deserialize");
441        assert_eq!(original, back);
442    }
443
444    // -- MinijinjaLimitsConfig tests ---------------------------------------
445
446    #[test]
447    fn minijinja_defaults_to_all_none() {
448        let cfg = MinijinjaLimitsConfig::default();
449        assert_eq!(cfg.max_template_source_size, None);
450        assert_eq!(cfg.max_context_size, None);
451        assert_eq!(cfg.max_output_size, None);
452        assert_eq!(cfg.fuel, None);
453        assert_eq!(cfg.max_recursion_depth, None);
454        assert_eq!(cfg.execution_timeout_ms, None);
455    }
456
457    #[test]
458    fn minijinja_deserialises_full_block() {
459        let toml = toml::toml! {
460            max-template-source-size = 1048576i64
461            max-context-size = 4194304i64
462            max-output-size = 4194304i64
463            fuel = 100000i64
464            max-recursion-depth = 64
465            execution-timeout-ms = 5000i64
466        };
467        let cfg: MinijinjaLimitsConfig = toml.try_into().expect("deserialize");
468        assert_eq!(cfg.max_template_source_size, Some(1_048_576));
469        assert_eq!(cfg.max_context_size, Some(4_194_304));
470        assert_eq!(cfg.max_output_size, Some(4_194_304));
471        assert_eq!(cfg.fuel, Some(100_000));
472        assert_eq!(cfg.max_recursion_depth, Some(64));
473        assert_eq!(cfg.execution_timeout_ms, Some(5_000));
474    }
475
476    #[test]
477    fn minijinja_rejects_unknown_field() {
478        let toml = toml::toml! {
479            fuel = 1000i64
480            bogus = 1i64
481        };
482        let result: Result<MinijinjaLimitsConfig, _> = toml.try_into();
483        assert!(result.is_err(), "deny_unknown_fields must reject `bogus`");
484    }
485
486    #[test]
487    fn minijinja_skip_serializing_none_fields() {
488        let cfg = MinijinjaLimitsConfig {
489            fuel: Some(100_000),
490            max_recursion_depth: Some(64),
491            ..Default::default()
492        };
493        let s = toml::to_string(&cfg).expect("serialize");
494        assert!(s.contains("fuel") && s.contains("max-recursion-depth"));
495        assert!(!s.contains("max-template-source-size") && !s.contains("max-context-size"));
496    }
497}