Skip to main content

camel_component_wasm/
config.rs

1//! WASM plugin configuration for Processor URI query params and `Camel.toml`
2//! limits blocks used by Bean/AuthorizationPolicy/SecurityPolicy.
3//!
4//! `max_memory_bytes` is enforced at runtime via
5//! `wasmtime::StoreLimitsBuilder::memory_size` in `WasmRuntime::create_host_state`.
6//! The default (50 MiB) is intentionally tight; raise it through `Camel.toml`
7//! (`[default.beans.<name>.limits]` or `[permissions.providers.<name>.limits]`)
8//! or via the `wasm:` URI query string (`?max-memory=N`) for Processor plugins.
9//! Timeout uses epoch interruption.
10
11use std::path::Path;
12use std::time::Duration;
13
14use camel_api::body::DEFAULT_MATERIALIZE_LIMIT;
15
16/// Default execution timeout in seconds.
17const DEFAULT_TIMEOUT_SECS: u64 = 30;
18
19/// Default maximum linear memory in bytes (50 MB).
20const DEFAULT_MAX_MEMORY_BYTES: u64 = 50 * 1024 * 1024;
21
22/// Default maximum concurrent `call_process` executions per producer.
23const DEFAULT_MAX_CONCURRENT_CALLS: usize = 4;
24
25/// Default maximum .wasm file size in bytes (10 MB).
26const DEFAULT_MAX_WASM_SIZE_BYTES: u64 = 10 * 1024 * 1024;
27
28/// Default maximum bytes for the streaming body bridge.
29pub(crate) const DEFAULT_MAX_STREAM_BYTES: u64 = DEFAULT_MATERIALIZE_LIMIT as u64;
30
31/// Default maximum core instances per store (matches wasmtime default).
32const DEFAULT_MAX_INSTANCES: usize = 10_000;
33
34/// Default maximum tables per store (matches wasmtime default).
35const DEFAULT_MAX_TABLES: usize = 10_000;
36
37/// Default maximum number of `StateStore` entries per producer.
38pub(crate) const DEFAULT_MAX_KV_ENTRIES: usize = 256;
39
40/// Default maximum byte length of a `StateStore` key.
41pub(crate) const DEFAULT_MAX_KEY_BYTES: usize = 1024;
42
43/// Default maximum byte length of a `StateStore` value (64 KiB).
44pub(crate) const DEFAULT_MAX_VALUE_BYTES: usize = 65_536;
45
46/// Epoch tick interval in milliseconds (same as Surrealism).
47const EPOCH_INTERVAL_MILLIS: u64 = 10;
48
49/// Configuration for a WASM plugin instance.
50///
51/// Parsed from URI query parameters or Camel.toml.
52/// Example URI: `wasm:plugin.wasm?timeout=10&max-memory=52428800`
53#[derive(Debug, Clone)]
54pub struct WasmConfig {
55    /// Maximum execution time per guest call, in seconds.
56    pub timeout_secs: u64,
57
58    /// Maximum linear memory the guest can allocate, in bytes.
59    /// Enforced via `wasmtime::StoreLimitsBuilder::memory_size`.
60    pub max_memory_bytes: u64,
61
62    /// Maximum concurrent `call_process` executions per producer.
63    pub max_concurrent_calls: usize,
64
65    /// Maximum .wasm file size in bytes. Files exceeding this are rejected
66    /// before compilation to prevent DoS via pathologically large modules.
67    /// Default: 10 MB.
68    pub max_wasm_size_bytes: u64,
69
70    /// Comma-separated URI schemes the guest may call via camel_call/camel_poll.
71    /// Empty string = deny all (fail-closed). Example: "log,direct,file".
72    /// Ignored for AuthorizationPolicy/SecurityPolicy worlds (always denied).
73    pub allow_call_schemes: String,
74
75    /// Maximum bytes for the streaming body bridge.
76    pub max_stream_bytes: u64,
77
78    /// Maximum core instances per store. Default 10_000 (matches wasmtime).
79    pub max_instances: usize,
80
81    /// Maximum tables per store. Default 10_000 (matches wasmtime).
82    pub max_tables: usize,
83
84    /// Maximum table elements. `None` = no cap (wasmtime unlimited).
85    pub max_table_elements: Option<usize>,
86
87    /// Maximum number of key/value entries in the per-producer `StateStore`.
88    /// Bounds the host-side KV allocation that wasmtime store limits do not
89    /// account for (gap `F-camel-component-wasm-I4`).
90    pub max_kv_entries: usize,
91
92    /// Maximum byte length of a `StateStore` key.
93    pub max_key_bytes: usize,
94
95    /// Maximum byte length of a `StateStore` value.
96    pub max_value_bytes: usize,
97}
98
99impl Default for WasmConfig {
100    fn default() -> Self {
101        Self {
102            timeout_secs: DEFAULT_TIMEOUT_SECS,
103            max_memory_bytes: DEFAULT_MAX_MEMORY_BYTES,
104            max_concurrent_calls: DEFAULT_MAX_CONCURRENT_CALLS,
105            max_wasm_size_bytes: DEFAULT_MAX_WASM_SIZE_BYTES,
106            allow_call_schemes: String::new(),
107            max_stream_bytes: DEFAULT_MAX_STREAM_BYTES,
108            max_instances: DEFAULT_MAX_INSTANCES,
109            max_tables: DEFAULT_MAX_TABLES,
110            max_table_elements: None,
111            max_kv_entries: DEFAULT_MAX_KV_ENTRIES,
112            max_key_bytes: DEFAULT_MAX_KEY_BYTES,
113            max_value_bytes: DEFAULT_MAX_VALUE_BYTES,
114        }
115    }
116}
117
118impl WasmConfig {
119    /// Build concrete runtime config from optional `Camel.toml` WASM limits.
120    ///
121    /// `None` values use runtime defaults matching `WasmConfig::default()`.
122    /// This constructor is the single source of truth for `WasmConfig` defaults
123    /// sourced from `Camel.toml` — no silent fallback lie elsewhere (ADR-0011).
124    pub fn from_limits(limits: &camel_config::WasmLimitsConfig) -> WasmConfig {
125        WasmConfig {
126            timeout_secs: limits.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS),
127            max_memory_bytes: limits.max_memory.unwrap_or(DEFAULT_MAX_MEMORY_BYTES),
128            max_concurrent_calls: limits
129                .max_concurrent_calls
130                .unwrap_or(DEFAULT_MAX_CONCURRENT_CALLS),
131            max_wasm_size_bytes: limits.max_wasm_size.unwrap_or(DEFAULT_MAX_WASM_SIZE_BYTES),
132            allow_call_schemes: limits.allow_call_schemes.clone().unwrap_or_default(),
133            max_stream_bytes: limits.max_stream_bytes.unwrap_or(DEFAULT_MAX_STREAM_BYTES),
134            max_instances: limits.max_instances.unwrap_or(DEFAULT_MAX_INSTANCES),
135            max_tables: limits.max_tables.unwrap_or(DEFAULT_MAX_TABLES),
136            max_table_elements: limits.max_table_elements,
137            max_kv_entries: limits.max_kv_entries.unwrap_or(DEFAULT_MAX_KV_ENTRIES),
138            max_key_bytes: limits.max_key_bytes.unwrap_or(DEFAULT_MAX_KEY_BYTES),
139            max_value_bytes: limits.max_value_bytes.unwrap_or(DEFAULT_MAX_VALUE_BYTES),
140        }
141    }
142
143    /// Parse `WasmConfig` from the query portion of a WASM URI.
144    ///
145    /// `uri_without_scheme` is everything after `wasm:`, e.g.
146    /// `plugins/my_processor.wasm?timeout=10&max-memory=52428800`.
147    ///
148    /// Returns `(path, config)` where path has no query string.
149    pub fn from_uri(uri_without_scheme: &str) -> (String, WasmConfig) {
150        let (path, query) = match uri_without_scheme.find('?') {
151            Some(i) => (&uri_without_scheme[..i], Some(&uri_without_scheme[i + 1..])),
152            None => (uri_without_scheme, None),
153        };
154
155        let mut config = WasmConfig::default();
156
157        if let Some(q) = query {
158            for pair in q.split('&') {
159                if let Some((key, value)) = pair.split_once('=') {
160                    match key {
161                        "timeout" => {
162                            if let Ok(secs) = value.parse::<u64>()
163                                && secs > 0
164                            {
165                                config.timeout_secs = secs;
166                            }
167                        }
168                        "max-memory" => {
169                            if let Ok(bytes) = value.parse::<u64>()
170                                && bytes > 0
171                            {
172                                config.max_memory_bytes = bytes;
173                            }
174                        }
175                        "max-concurrent-calls" => {
176                            if let Ok(max) = value.parse::<usize>()
177                                && max > 0
178                            {
179                                config.max_concurrent_calls = max;
180                            }
181                        }
182                        "max-wasm-size" => {
183                            if let Ok(bytes) = value.parse::<u64>()
184                                && bytes > 0
185                            {
186                                config.max_wasm_size_bytes = bytes;
187                            }
188                        }
189                        "allow-call" => {
190                            config.allow_call_schemes = value.to_string();
191                        }
192                        "max-stream-bytes" => {
193                            if let Ok(bytes) = value.parse::<u64>()
194                                && bytes > 0
195                            {
196                                config.max_stream_bytes = bytes;
197                            }
198                        }
199                        "max-instances" => {
200                            if let Ok(n) = value.parse::<usize>()
201                                && n > 0
202                            {
203                                config.max_instances = n;
204                            }
205                        }
206                        "max-tables" => {
207                            if let Ok(n) = value.parse::<usize>()
208                                && n > 0
209                            {
210                                config.max_tables = n;
211                            }
212                        }
213                        "max-table-elements" => {
214                            if let Ok(n) = value.parse::<usize>()
215                                && n > 0
216                            {
217                                config.max_table_elements = Some(n);
218                            }
219                        }
220                        "max-kv-entries" => {
221                            if let Ok(n) = value.parse::<usize>()
222                                && n > 0
223                            {
224                                config.max_kv_entries = n;
225                            }
226                        }
227                        "max-key-bytes" => {
228                            if let Ok(n) = value.parse::<usize>()
229                                && n > 0
230                            {
231                                config.max_key_bytes = n;
232                            }
233                        }
234                        "max-value-bytes" => {
235                            if let Ok(n) = value.parse::<usize>()
236                                && n > 0
237                            {
238                                config.max_value_bytes = n;
239                            }
240                        }
241                        _ => {} // ignore unknown params
242                    }
243                }
244            }
245        }
246
247        (path.to_string(), config)
248    }
249
250    /// Convert the wall-clock timeout to an epoch deadline (number of ticks).
251    ///
252    /// At 10ms per tick: deadline = timeout_secs * 100
253    pub fn epoch_deadline(&self) -> u64 {
254        self.timeout_secs * (1000 / EPOCH_INTERVAL_MILLIS)
255    }
256
257    /// The interval at which the epoch ticker thread increments the epoch.
258    pub fn epoch_interval(&self) -> Duration {
259        Duration::from_millis(EPOCH_INTERVAL_MILLIS)
260    }
261
262    /// Returns the configured epoch interval in milliseconds.
263    pub fn epoch_interval_millis(&self) -> u64 {
264        EPOCH_INTERVAL_MILLIS
265    }
266
267    pub fn classify_error(
268        &self,
269        plugin_path: &Path,
270        e: wasmtime::Error,
271    ) -> crate::error::WasmError {
272        classify_error(self, plugin_path, e)
273    }
274}
275
276/// Hoisted free-function form of [`WasmConfig::classify_error`] so spawned
277/// tasks (which cannot borrow `&self`) can classify wasmtime errors.
278///
279/// Captures `config` + `plugin_path` by value/clone at spawn site; the
280/// wasmtime error is consumed.
281pub fn classify_error(
282    config: &WasmConfig,
283    plugin_path: &Path,
284    e: wasmtime::Error,
285) -> crate::error::WasmError {
286    use crate::error::{TrapReason, WasmError};
287    let name = plugin_path.display().to_string();
288    if let Some(trap) = e.downcast_ref::<wasmtime::Trap>() {
289        match WasmError::classify_trap(trap) {
290            TrapReason::Timeout => WasmError::Timeout {
291                plugin: name,
292                timeout_secs: config.timeout_secs,
293            },
294            TrapReason::OutOfMemory => WasmError::OutOfMemory {
295                plugin: name,
296                max_memory_bytes: config.max_memory_bytes,
297            },
298            other => WasmError::Trap {
299                plugin: name,
300                reason: other,
301            },
302        }
303    } else {
304        WasmError::GuestPanic(e.to_string())
305    }
306}
307
308pub fn validate_wasm_size(path: &std::path::Path, max_bytes: u64) -> Result<(), String> {
309    let metadata = std::fs::metadata(path)
310        .map_err(|e| format!("cannot stat wasm module {}: {}", path.display(), e))?;
311    let size = metadata.len();
312    if size > max_bytes {
313        return Err(format!(
314            "wasm module {} is {} bytes ({} KiB), exceeds cap of {} bytes ({} KiB)",
315            path.display(),
316            size,
317            size / 1024,
318            max_bytes,
319            max_bytes / 1024,
320        ));
321    }
322    Ok(())
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    #[test]
330    fn test_default_config() {
331        let config = WasmConfig::default();
332        assert_eq!(config.timeout_secs, 30);
333        assert_eq!(config.max_memory_bytes, 50 * 1024 * 1024);
334        assert_eq!(config.max_concurrent_calls, 4);
335    }
336
337    #[test]
338    fn test_from_uri_no_params() {
339        let (path, config) = WasmConfig::from_uri("plugins/test.wasm");
340        assert_eq!(path, "plugins/test.wasm");
341        assert_eq!(config.timeout_secs, 30);
342        assert_eq!(config.max_memory_bytes, 50 * 1024 * 1024);
343        assert_eq!(config.max_concurrent_calls, 4);
344    }
345
346    #[test]
347    fn test_from_uri_with_timeout() {
348        let (path, config) = WasmConfig::from_uri("plugins/test.wasm?timeout=10");
349        assert_eq!(path, "plugins/test.wasm");
350        assert_eq!(config.timeout_secs, 10);
351        assert_eq!(config.max_memory_bytes, 50 * 1024 * 1024);
352        assert_eq!(config.max_concurrent_calls, 4);
353    }
354
355    #[test]
356    fn test_from_uri_with_max_memory() {
357        let (path, config) = WasmConfig::from_uri("plugins/test.wasm?max-memory=10485760");
358        assert_eq!(path, "plugins/test.wasm");
359        assert_eq!(config.timeout_secs, 30);
360        assert_eq!(config.max_memory_bytes, 10_485_760);
361        assert_eq!(config.max_concurrent_calls, 4);
362    }
363
364    #[test]
365    fn test_from_uri_with_both_params() {
366        let (path, config) = WasmConfig::from_uri("plugins/test.wasm?timeout=5&max-memory=1048576");
367        assert_eq!(path, "plugins/test.wasm");
368        assert_eq!(config.timeout_secs, 5);
369        assert_eq!(config.max_memory_bytes, 1_048_576);
370        assert_eq!(config.max_concurrent_calls, 4);
371    }
372
373    #[test]
374    fn test_from_uri_with_max_concurrent_calls() {
375        let (path, config) = WasmConfig::from_uri("plugins/test.wasm?max-concurrent-calls=8");
376        assert_eq!(path, "plugins/test.wasm");
377        assert_eq!(config.max_concurrent_calls, 8);
378    }
379
380    #[test]
381    fn test_from_uri_ignores_unknown_params() {
382        let (path, config) = WasmConfig::from_uri("plugins/test.wasm?foo=bar&timeout=60");
383        assert_eq!(path, "plugins/test.wasm");
384        assert_eq!(config.timeout_secs, 60);
385    }
386
387    #[test]
388    fn test_from_uri_ignores_invalid_values() {
389        let (_path, config) = WasmConfig::from_uri("plugins/test.wasm?timeout=abc");
390        assert_eq!(config.timeout_secs, 30); // stays default
391    }
392
393    #[test]
394    fn test_from_uri_ignores_zero_values() {
395        let (_path, config) = WasmConfig::from_uri("plugins/test.wasm?timeout=0&max-memory=0");
396        assert_eq!(config.timeout_secs, 30); // stays default
397        assert_eq!(config.max_memory_bytes, 50 * 1024 * 1024); // stays default
398        assert_eq!(config.max_concurrent_calls, 4);
399    }
400
401    #[test]
402    fn test_epoch_deadline() {
403        let config = WasmConfig {
404            timeout_secs: 30,
405            max_memory_bytes: 0,
406            max_concurrent_calls: 4,
407            ..WasmConfig::default()
408        };
409        assert_eq!(config.epoch_deadline(), 3000); // 30s * 100 ticks/s
410    }
411
412    #[test]
413    fn test_epoch_deadline_custom_timeout() {
414        let config = WasmConfig {
415            timeout_secs: 5,
416            max_memory_bytes: 0,
417            max_concurrent_calls: 4,
418            ..WasmConfig::default()
419        };
420        assert_eq!(config.epoch_deadline(), 500);
421    }
422
423    #[test]
424    fn test_epoch_interval() {
425        let config = WasmConfig::default();
426        assert_eq!(config.epoch_interval(), Duration::from_millis(10));
427    }
428
429    #[test]
430    fn from_limits_applies_provided_values() {
431        let limits = camel_config::WasmLimitsConfig {
432            timeout_secs: Some(90),
433            max_memory: Some(128 * 1024 * 1024),
434            max_concurrent_calls: Some(2),
435            ..camel_config::WasmLimitsConfig::default()
436        };
437
438        let config = WasmConfig::from_limits(&limits);
439
440        assert_eq!(config.timeout_secs, 90);
441        assert_eq!(config.max_memory_bytes, 128 * 1024 * 1024);
442        assert_eq!(config.max_concurrent_calls, 2);
443    }
444
445    #[test]
446    fn from_limits_falls_back_to_runtime_defaults_when_none() {
447        let limits = camel_config::WasmLimitsConfig::default();
448
449        let config = WasmConfig::from_limits(&limits);
450
451        assert_eq!(config.timeout_secs, DEFAULT_TIMEOUT_SECS);
452        assert_eq!(config.max_memory_bytes, DEFAULT_MAX_MEMORY_BYTES);
453        assert_eq!(config.max_concurrent_calls, 4);
454    }
455
456    #[test]
457    fn from_limits_mixed_some_and_none() {
458        let limits = camel_config::WasmLimitsConfig {
459            timeout_secs: Some(15),
460            max_memory: None,
461            max_concurrent_calls: Some(1),
462            ..camel_config::WasmLimitsConfig::default()
463        };
464
465        let config = WasmConfig::from_limits(&limits);
466
467        assert_eq!(config.timeout_secs, 15);
468        assert_eq!(config.max_memory_bytes, DEFAULT_MAX_MEMORY_BYTES);
469        assert_eq!(config.max_concurrent_calls, 1);
470    }
471
472    #[test]
473    fn test_validate_wasm_size_rejects_oversized() {
474        let dir = tempfile::tempdir().unwrap();
475        let path = dir.path().join("big.wasm");
476        std::fs::write(&path, vec![0u8; 100]).unwrap();
477        let err = validate_wasm_size(&path, 50).unwrap_err();
478        assert!(err.contains("exceeds cap"), "got: {err}");
479    }
480
481    #[test]
482    fn test_validate_wasm_size_allows_within_cap() {
483        let dir = tempfile::tempdir().unwrap();
484        let path = dir.path().join("ok.wasm");
485        std::fs::write(&path, vec![0u8; 100]).unwrap();
486        validate_wasm_size(&path, 200).expect("100 bytes within 200 cap");
487    }
488
489    #[test]
490    fn test_default_max_wasm_size_bytes() {
491        let config = WasmConfig::default();
492        assert_eq!(config.max_wasm_size_bytes, 10 * 1024 * 1024);
493    }
494
495    #[test]
496    fn test_from_uri_max_wasm_size() {
497        let (_path, config) = WasmConfig::from_uri("p.wasm?max-wasm-size=1048576");
498        assert_eq!(config.max_wasm_size_bytes, 1_048_576);
499    }
500
501    #[test]
502    fn test_validate_wasm_size_errors_on_missing_file() {
503        let err = validate_wasm_size(std::path::Path::new("/nonexistent.wasm"), 1000).unwrap_err();
504        assert!(err.contains("cannot stat"));
505    }
506
507    #[test]
508    fn wasm_config_default_max_stream_bytes() {
509        let cfg = WasmConfig::default();
510        assert_eq!(cfg.max_stream_bytes, DEFAULT_MATERIALIZE_LIMIT as u64);
511    }
512
513    #[test]
514    fn wasm_config_from_uri_max_stream_bytes() {
515        let (_, cfg) = WasmConfig::from_uri("test.wasm?max-stream-bytes=52428800");
516        assert_eq!(cfg.max_stream_bytes, 52_428_800);
517    }
518
519    #[test]
520    fn wasm_config_from_uri_max_stream_bytes_ignores_zero() {
521        let (_, cfg) = WasmConfig::from_uri("test.wasm?max-stream-bytes=0");
522        assert_eq!(cfg.max_stream_bytes, DEFAULT_MAX_STREAM_BYTES);
523    }
524
525    #[test]
526    fn wasm_config_from_limits_max_stream_bytes() {
527        let limits = camel_config::WasmLimitsConfig {
528            max_stream_bytes: Some(52_428_800),
529            ..camel_config::WasmLimitsConfig::default()
530        };
531        let cfg = WasmConfig::from_limits(&limits);
532        assert_eq!(cfg.max_stream_bytes, 52_428_800);
533    }
534
535    #[test]
536    fn wasm_config_from_limits_max_stream_bytes_default() {
537        let limits = camel_config::WasmLimitsConfig::default();
538        let cfg = WasmConfig::from_limits(&limits);
539        assert_eq!(cfg.max_stream_bytes, DEFAULT_MAX_STREAM_BYTES);
540    }
541
542    #[test]
543    fn wasm_config_default_instances_tables_table_elements() {
544        let cfg = WasmConfig::default();
545        assert_eq!(cfg.max_instances, 10_000);
546        assert_eq!(cfg.max_tables, 10_000);
547        assert_eq!(cfg.max_table_elements, None);
548    }
549
550    #[test]
551    fn wasm_config_from_limits_instances_tables_table_elements() {
552        let limits = camel_config::WasmLimitsConfig {
553            max_instances: Some(100),
554            max_tables: Some(50),
555            max_table_elements: Some(200),
556            ..camel_config::WasmLimitsConfig::default()
557        };
558        let cfg = WasmConfig::from_limits(&limits);
559        assert_eq!(cfg.max_instances, 100);
560        assert_eq!(cfg.max_tables, 50);
561        assert_eq!(cfg.max_table_elements, Some(200));
562    }
563
564    #[test]
565    fn wasm_config_from_limits_instances_tables_table_elements_defaults() {
566        let limits = camel_config::WasmLimitsConfig::default();
567        let cfg = WasmConfig::from_limits(&limits);
568        assert_eq!(cfg.max_instances, 10_000);
569        assert_eq!(cfg.max_tables, 10_000);
570        assert_eq!(cfg.max_table_elements, None);
571    }
572
573    #[test]
574    fn wasm_config_from_uri_max_instances() {
575        let (_, cfg) = WasmConfig::from_uri("test.wasm?max-instances=100");
576        assert_eq!(cfg.max_instances, 100);
577    }
578
579    #[test]
580    fn wasm_config_from_uri_max_tables() {
581        let (_, cfg) = WasmConfig::from_uri("test.wasm?max-tables=50");
582        assert_eq!(cfg.max_tables, 50);
583    }
584
585    #[test]
586    fn wasm_config_from_uri_max_table_elements() {
587        let (_, cfg) = WasmConfig::from_uri("test.wasm?max-table-elements=200");
588        assert_eq!(cfg.max_table_elements, Some(200));
589    }
590}