Skip to main content

secure_exec_vm_config/
lib.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use ts_rs::TS;
5
6#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
7#[serde(rename_all = "camelCase", deny_unknown_fields)]
8#[ts(export, export_to = "../../../packages/core/src/generated/")]
9#[derive(Default)]
10pub struct CreateVmConfig {
11    #[serde(default, skip_serializing_if = "Option::is_none")]
12    #[ts(optional)]
13    pub cwd: Option<String>,
14    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
15    #[ts(type = "Record<string, string>")]
16    pub env: BTreeMap<String, String>,
17    #[serde(default, rename = "rootFilesystem")]
18    pub root_filesystem: RootFilesystemConfig,
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    #[ts(optional)]
21    pub permissions: Option<PermissionsPolicy>,
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    #[ts(optional)]
24    pub limits: Option<VmLimitsConfig>,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    #[ts(optional)]
27    pub dns: Option<VmDnsConfig>,
28    #[serde(
29        default,
30        rename = "nativeRoot",
31        skip_serializing_if = "Option::is_none"
32    )]
33    #[ts(optional)]
34    pub native_root: Option<NativeRootFilesystemConfig>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    #[ts(optional)]
37    pub listen: Option<VmListenPolicyConfig>,
38    #[serde(
39        default,
40        rename = "loopbackExemptPorts",
41        skip_serializing_if = "Vec::is_empty"
42    )]
43    pub loopback_exempt_ports: Vec<u16>,
44    #[serde(default, rename = "jsRuntime", skip_serializing_if = "Option::is_none")]
45    #[ts(optional)]
46    pub js_runtime: Option<JsRuntimeConfig>,
47}
48
49impl CreateVmConfig {
50    pub fn validate(&self, max_frame_bytes: usize) -> Result<(), VmConfigError> {
51        if let Some(cwd) = self.cwd.as_deref() {
52            validate_guest_path("cwd", cwd)?;
53        }
54        self.root_filesystem.validate()?;
55        if let Some(native_root) = &self.native_root {
56            native_root.validate()?;
57        }
58        if self.native_root.is_some() && !self.root_filesystem.bootstrap_entries.is_empty() {
59            return Err(VmConfigError::new(
60                "nativeRoot does not support rootFilesystem.bootstrapEntries",
61            ));
62        }
63        if let Some(dns) = &self.dns {
64            dns.validate()?;
65        }
66        if let Some(listen) = &self.listen {
67            listen.validate()?;
68        }
69        if let Some(limits) = &self.limits {
70            limits.validate(max_frame_bytes)?;
71        }
72        if let Some(js_runtime) = &self.js_runtime {
73            js_runtime.validate()?;
74        }
75        Ok(())
76    }
77}
78
79/// Guest JavaScript host-environment configuration.
80///
81/// Selects which globals/builtins/module-resolution surface guest JS sees,
82/// modeled on esbuild's `platform`. Omitting this preserves full Node.js
83/// emulation (`platform = node`).
84#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, TS)]
85#[serde(rename_all = "camelCase", deny_unknown_fields)]
86#[ts(export, export_to = "../../../packages/core/src/generated/")]
87pub struct JsRuntimeConfig {
88    /// Which host environment to emulate for guest JS. Default `node`.
89    #[serde(default)]
90    pub platform: JsRuntimePlatform,
91    /// How bare import specifiers resolve. Independent of `platform`.
92    /// Default `node`.
93    #[serde(default, rename = "moduleResolution")]
94    pub module_resolution: JsModuleResolution,
95    /// Node builtin-module allow-list. Only valid when `platform = node`.
96    /// `None` => engine default allow-list. `Some([])` => deny all builtins.
97    /// `Some([..])` => exactly those.
98    #[serde(
99        default,
100        rename = "allowedBuiltins",
101        skip_serializing_if = "Option::is_none"
102    )]
103    #[ts(optional)]
104    pub allowed_builtins: Option<Vec<String>>,
105}
106
107impl JsRuntimeConfig {
108    fn validate(&self) -> Result<(), VmConfigError> {
109        if let Some(allowed) = &self.allowed_builtins {
110            if self.platform != JsRuntimePlatform::Node {
111                return Err(VmConfigError::new(
112                    "jsRuntime.allowedBuiltins is only valid when jsRuntime.platform is \"node\"",
113                ));
114            }
115            for name in allowed {
116                if !is_known_node_builtin(name) {
117                    return Err(VmConfigError::new(format!(
118                        "jsRuntime.allowedBuiltins contains unknown builtin {name:?}"
119                    )));
120                }
121            }
122        }
123        Ok(())
124    }
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
128#[serde(rename_all = "lowercase")]
129#[ts(export, export_to = "../../../packages/core/src/generated/")]
130#[derive(Default)]
131pub enum JsRuntimePlatform {
132    /// Full Node.js host surface (process/Buffer/require, `node:*`, npm
133    /// resolution, virtual Node identity). Default.
134    #[default]
135    Node,
136    /// Web-platform globals (fetch/URL/WebCrypto/...), no Node surface.
137    Browser,
138    /// Universal primitives only (console, timers, queueMicrotask) — no web
139    /// platform, no Node surface.
140    Neutral,
141    /// Language-only: ECMAScript spec globals + WebAssembly. Nothing host-provided.
142    Bare,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
146#[serde(rename_all = "lowercase")]
147#[ts(export, export_to = "../../../packages/core/src/generated/")]
148#[derive(Default)]
149pub enum JsModuleResolution {
150    /// node_modules ancestor-walk + exports/imports/conditions + realpath. Default.
151    #[default]
152    Node,
153    /// Relative/absolute ESM from the VFS only; bare specifiers do not resolve.
154    Relative,
155    /// No resolution: any import/require (even relative) fails.
156    None,
157}
158
159/// Canonical set of recognized Node builtin module names (without the `node:`
160/// prefix), kept in sync with `normalize_builtin_specifier` in
161/// `crates/execution/src/javascript.rs`. Used to validate
162/// `jsRuntime.allowedBuiltins` entries.
163const KNOWN_NODE_BUILTINS: &[&str] = &[
164    "assert",
165    "async_hooks",
166    "buffer",
167    "child_process",
168    "cluster",
169    "console",
170    "constants",
171    "crypto",
172    "dgram",
173    "diagnostics_channel",
174    "dns",
175    "dns/promises",
176    "domain",
177    "events",
178    "fs",
179    "fs/promises",
180    "http",
181    "http2",
182    "https",
183    "inspector",
184    "module",
185    "net",
186    "os",
187    "path",
188    "path/posix",
189    "path/win32",
190    "perf_hooks",
191    "process",
192    "punycode",
193    "querystring",
194    "readline",
195    "repl",
196    "sqlite",
197    "stream",
198    "stream/consumers",
199    "stream/promises",
200    "stream/web",
201    "string_decoder",
202    "sys",
203    "timers",
204    "timers/promises",
205    "tls",
206    "trace_events",
207    "tty",
208    "url",
209    "util",
210    "util/types",
211    "v8",
212    "vm",
213    "wasi",
214    "worker_threads",
215    "zlib",
216];
217
218fn is_known_node_builtin(name: &str) -> bool {
219    let bare = name.strip_prefix("node:").unwrap_or(name);
220    KNOWN_NODE_BUILTINS.contains(&bare)
221}
222
223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
224#[serde(rename_all = "camelCase", deny_unknown_fields)]
225#[ts(export, export_to = "../../../packages/core/src/generated/")]
226pub struct RootFilesystemConfig {
227    #[serde(default)]
228    pub mode: RootFilesystemMode,
229    #[serde(default, rename = "disableDefaultBaseLayer")]
230    pub disable_default_base_layer: bool,
231    #[serde(default, skip_serializing_if = "Vec::is_empty")]
232    pub lowers: Vec<RootFilesystemLowerDescriptor>,
233    #[serde(
234        default,
235        rename = "bootstrapEntries",
236        skip_serializing_if = "Vec::is_empty"
237    )]
238    pub bootstrap_entries: Vec<RootFilesystemEntry>,
239}
240
241impl Default for RootFilesystemConfig {
242    fn default() -> Self {
243        Self {
244            mode: RootFilesystemMode::Ephemeral,
245            disable_default_base_layer: false,
246            lowers: Vec::new(),
247            bootstrap_entries: Vec::new(),
248        }
249    }
250}
251
252impl RootFilesystemConfig {
253    fn validate(&self) -> Result<(), VmConfigError> {
254        for lower in &self.lowers {
255            if let RootFilesystemLowerDescriptor::Snapshot { entries } = lower {
256                for entry in entries {
257                    entry.validate()?;
258                }
259            }
260        }
261        for entry in &self.bootstrap_entries {
262            entry.validate()?;
263        }
264        Ok(())
265    }
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
269#[serde(rename_all = "kebab-case")]
270#[ts(export, export_to = "../../../packages/core/src/generated/")]
271#[derive(Default)]
272pub enum RootFilesystemMode {
273    #[default]
274    Ephemeral,
275    ReadOnly,
276}
277
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
279#[serde(tag = "kind", rename_all = "camelCase")]
280#[ts(export, export_to = "../../../packages/core/src/generated/")]
281pub enum RootFilesystemLowerDescriptor {
282    Snapshot {
283        #[serde(default)]
284        entries: Vec<RootFilesystemEntry>,
285    },
286    BundledBaseFilesystem,
287}
288
289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
290#[serde(rename_all = "camelCase", deny_unknown_fields)]
291#[ts(export, export_to = "../../../packages/core/src/generated/")]
292pub struct RootFilesystemEntry {
293    pub path: String,
294    pub kind: RootFilesystemEntryKind,
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    #[ts(optional)]
297    pub mode: Option<u32>,
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    #[ts(optional)]
300    pub uid: Option<u32>,
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    #[ts(optional)]
303    pub gid: Option<u32>,
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    #[ts(optional)]
306    pub content: Option<String>,
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    #[ts(optional)]
309    pub encoding: Option<RootFilesystemEntryEncoding>,
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    #[ts(optional)]
312    pub target: Option<String>,
313    #[serde(default)]
314    pub executable: bool,
315}
316
317impl RootFilesystemEntry {
318    fn validate(&self) -> Result<(), VmConfigError> {
319        validate_guest_path("root filesystem entry path", &self.path)?;
320        match self.kind {
321            RootFilesystemEntryKind::File => {
322                if self.target.is_some() {
323                    return Err(VmConfigError::new(format!(
324                        "file entry {} must not include target",
325                        self.path
326                    )));
327                }
328            }
329            RootFilesystemEntryKind::Directory => {
330                if self.content.is_some() || self.encoding.is_some() || self.target.is_some() {
331                    return Err(VmConfigError::new(format!(
332                        "directory entry {} must not include content, encoding, or target",
333                        self.path
334                    )));
335                }
336            }
337            RootFilesystemEntryKind::Symlink => {
338                if self.target.as_deref().unwrap_or("").is_empty() {
339                    return Err(VmConfigError::new(format!(
340                        "symlink entry {} requires target",
341                        self.path
342                    )));
343                }
344                if self.content.is_some() || self.encoding.is_some() {
345                    return Err(VmConfigError::new(format!(
346                        "symlink entry {} must not include content or encoding",
347                        self.path
348                    )));
349                }
350            }
351        }
352        Ok(())
353    }
354}
355
356#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
357#[serde(rename_all = "lowercase")]
358#[ts(export, export_to = "../../../packages/core/src/generated/")]
359pub enum RootFilesystemEntryKind {
360    File,
361    Directory,
362    Symlink,
363}
364
365#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
366#[serde(rename_all = "lowercase")]
367#[ts(export, export_to = "../../../packages/core/src/generated/")]
368pub enum RootFilesystemEntryEncoding {
369    Utf8,
370    Base64,
371}
372
373#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
374#[serde(rename_all = "camelCase", deny_unknown_fields)]
375#[ts(export, export_to = "../../../packages/core/src/generated/")]
376pub struct NativeRootFilesystemConfig {
377    pub plugin: MountPluginDescriptor,
378    #[serde(default, rename = "readOnly")]
379    pub read_only: bool,
380}
381
382impl NativeRootFilesystemConfig {
383    fn validate(&self) -> Result<(), VmConfigError> {
384        if self.plugin.id.trim().is_empty() {
385            return Err(VmConfigError::new("nativeRoot.plugin.id is required"));
386        }
387        Ok(())
388    }
389}
390
391#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
392#[serde(rename_all = "camelCase", deny_unknown_fields)]
393#[ts(export, export_to = "../../../packages/core/src/generated/")]
394pub struct MountPluginDescriptor {
395    pub id: String,
396    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
397    #[ts(type = "import(\"../descriptors.js\").MountConfigJsonValue")]
398    pub config: serde_json::Value,
399}
400
401#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
402#[serde(rename_all = "lowercase")]
403#[ts(export, export_to = "../../../packages/core/src/generated/")]
404pub enum PermissionMode {
405    Allow,
406    Ask,
407    Deny,
408}
409
410#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
411#[serde(untagged)]
412#[ts(export, export_to = "../../../packages/core/src/generated/")]
413pub enum FsPermissionScope {
414    Mode(PermissionMode),
415    Rules(FsPermissionRuleSet),
416}
417
418#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
419#[serde(untagged)]
420#[ts(export, export_to = "../../../packages/core/src/generated/")]
421pub enum PatternPermissionScope {
422    Mode(PermissionMode),
423    Rules(PatternPermissionRuleSet),
424}
425
426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
427#[serde(rename_all = "camelCase", deny_unknown_fields)]
428#[ts(export, export_to = "../../../packages/core/src/generated/")]
429pub struct FsPermissionRuleSet {
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    #[ts(optional)]
432    pub default: Option<PermissionMode>,
433    #[serde(default)]
434    pub rules: Vec<FsPermissionRule>,
435}
436
437#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
438#[serde(rename_all = "camelCase", deny_unknown_fields)]
439#[ts(export, export_to = "../../../packages/core/src/generated/")]
440pub struct PatternPermissionRuleSet {
441    #[serde(default, skip_serializing_if = "Option::is_none")]
442    #[ts(optional)]
443    pub default: Option<PermissionMode>,
444    #[serde(default)]
445    pub rules: Vec<PatternPermissionRule>,
446}
447
448#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
449#[serde(rename_all = "camelCase", deny_unknown_fields)]
450#[ts(export, export_to = "../../../packages/core/src/generated/")]
451pub struct FsPermissionRule {
452    pub mode: PermissionMode,
453    #[serde(default, skip_serializing_if = "Vec::is_empty")]
454    pub operations: Vec<String>,
455    #[serde(default, skip_serializing_if = "Vec::is_empty")]
456    pub paths: Vec<String>,
457}
458
459#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
460#[serde(rename_all = "camelCase", deny_unknown_fields)]
461#[ts(export, export_to = "../../../packages/core/src/generated/")]
462pub struct PatternPermissionRule {
463    pub mode: PermissionMode,
464    #[serde(default, skip_serializing_if = "Vec::is_empty")]
465    pub operations: Vec<String>,
466    #[serde(default, skip_serializing_if = "Vec::is_empty")]
467    pub patterns: Vec<String>,
468}
469
470#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
471#[serde(rename_all = "camelCase", deny_unknown_fields)]
472#[ts(export, export_to = "../../../packages/core/src/generated/")]
473pub struct PermissionsPolicy {
474    #[serde(default, skip_serializing_if = "Option::is_none")]
475    #[ts(optional)]
476    pub fs: Option<FsPermissionScope>,
477    #[serde(default, skip_serializing_if = "Option::is_none")]
478    #[ts(optional)]
479    pub network: Option<PatternPermissionScope>,
480    #[serde(
481        default,
482        rename = "childProcess",
483        skip_serializing_if = "Option::is_none"
484    )]
485    #[ts(optional)]
486    pub child_process: Option<PatternPermissionScope>,
487    #[serde(default, skip_serializing_if = "Option::is_none")]
488    #[ts(optional)]
489    pub process: Option<PatternPermissionScope>,
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    #[ts(optional)]
492    pub env: Option<PatternPermissionScope>,
493    #[serde(default, skip_serializing_if = "Option::is_none")]
494    #[ts(optional)]
495    pub tool: Option<PatternPermissionScope>,
496}
497
498#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
499#[serde(rename_all = "camelCase", deny_unknown_fields)]
500#[ts(export, export_to = "../../../packages/core/src/generated/")]
501pub struct VmLimitsConfig {
502    #[serde(default, skip_serializing_if = "Option::is_none")]
503    #[ts(optional)]
504    pub resources: Option<ResourceLimitsConfig>,
505    #[serde(default, skip_serializing_if = "Option::is_none")]
506    #[ts(optional)]
507    pub http: Option<HttpLimitsConfig>,
508    #[serde(default, skip_serializing_if = "Option::is_none")]
509    #[ts(optional)]
510    pub tools: Option<ToolLimitsConfig>,
511    #[serde(default, skip_serializing_if = "Option::is_none")]
512    #[ts(optional)]
513    pub plugins: Option<PluginLimitsConfig>,
514    #[serde(default, skip_serializing_if = "Option::is_none")]
515    #[ts(optional)]
516    pub acp: Option<AcpLimitsConfig>,
517    #[serde(default, rename = "jsRuntime", skip_serializing_if = "Option::is_none")]
518    #[ts(optional)]
519    pub js_runtime: Option<JsRuntimeLimitsConfig>,
520    #[serde(default, skip_serializing_if = "Option::is_none")]
521    #[ts(optional)]
522    pub python: Option<PythonLimitsConfig>,
523    #[serde(default, skip_serializing_if = "Option::is_none")]
524    #[ts(optional)]
525    pub wasm: Option<WasmLimitsConfig>,
526}
527
528impl VmLimitsConfig {
529    fn validate(&self, max_frame_bytes: usize) -> Result<(), VmConfigError> {
530        if let Some(http) = &self.http {
531            if let Some(max_fetch_response_bytes) = http.max_fetch_response_bytes {
532                if max_fetch_response_bytes == 0 {
533                    return Err(VmConfigError::new(
534                        "limits.http.maxFetchResponseBytes must be greater than zero",
535                    ));
536                }
537                if max_fetch_response_bytes as usize > max_frame_bytes {
538                    return Err(VmConfigError::new(format!(
539                        "limits.http.maxFetchResponseBytes ({max_fetch_response_bytes}) must be <= the sidecar wire frame cap ({max_frame_bytes})"
540                    )));
541                }
542            }
543        }
544        if let Some(tools) = &self.tools {
545            if let (Some(default), Some(max)) =
546                (tools.default_tool_timeout_ms, tools.max_tool_timeout_ms)
547            {
548                if default > max {
549                    return Err(VmConfigError::new(
550                        "limits.tools.defaultToolTimeoutMs must be <= limits.tools.maxToolTimeoutMs",
551                    ));
552                }
553            }
554        }
555        Ok(())
556    }
557}
558
559macro_rules! limits_struct {
560    ($name:ident { $($field:ident),* $(,)? }) => {
561        #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
562        #[serde(rename_all = "camelCase", deny_unknown_fields)]
563        #[ts(export, export_to = "../../../packages/core/src/generated/")]
564        pub struct $name {
565            $(
566                #[serde(default, skip_serializing_if = "Option::is_none")]
567                #[ts(optional)]
568                #[ts(type = "number")]
569                pub $field: Option<u64>,
570            )*
571        }
572    };
573}
574
575limits_struct!(ResourceLimitsConfig {
576    cpu_count,
577    max_processes,
578    max_open_fds,
579    max_pipes,
580    max_ptys,
581    max_sockets,
582    max_connections,
583    max_socket_buffered_bytes,
584    max_socket_datagram_queue_len,
585    max_filesystem_bytes,
586    max_inode_count,
587    max_blocking_read_ms,
588    max_pread_bytes,
589    max_fd_write_bytes,
590    max_process_argv_bytes,
591    max_process_env_bytes,
592    max_readdir_entries,
593    max_wasm_fuel,
594    max_wasm_memory_bytes,
595    max_wasm_stack_bytes,
596});
597
598limits_struct!(HttpLimitsConfig {
599    max_fetch_response_bytes,
600});
601
602limits_struct!(ToolLimitsConfig {
603    default_tool_timeout_ms,
604    max_tool_timeout_ms,
605    max_registered_toolkits,
606    max_registered_tools_per_vm,
607    max_tools_per_toolkit,
608    max_tool_schema_bytes,
609    max_tool_examples_per_tool,
610    max_tool_example_input_bytes,
611});
612
613limits_struct!(PluginLimitsConfig {
614    max_persisted_manifest_bytes,
615    max_persisted_manifest_file_bytes,
616});
617
618limits_struct!(AcpLimitsConfig {
619    max_read_line_bytes,
620    stdout_buffer_byte_limit,
621});
622
623limits_struct!(JsRuntimeLimitsConfig {
624    v8_heap_limit_mb,
625    captured_output_limit_bytes,
626    stdin_buffer_limit_bytes,
627    event_payload_limit_bytes,
628    v8_ipc_max_frame_bytes,
629});
630
631limits_struct!(PythonLimitsConfig {
632    output_buffer_max_bytes,
633    execution_timeout_ms,
634    vfs_rpc_timeout_ms,
635});
636
637limits_struct!(WasmLimitsConfig {
638    max_module_file_bytes,
639    captured_output_limit_bytes,
640    sync_read_limit_bytes,
641});
642
643#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
644#[serde(rename_all = "camelCase", deny_unknown_fields)]
645#[ts(export, export_to = "../../../packages/core/src/generated/")]
646pub struct VmDnsConfig {
647    #[serde(default, rename = "nameServers", skip_serializing_if = "Vec::is_empty")]
648    pub name_servers: Vec<String>,
649    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
650    pub overrides: BTreeMap<String, Vec<String>>,
651}
652
653impl VmDnsConfig {
654    fn validate(&self) -> Result<(), VmConfigError> {
655        for entry in &self.name_servers {
656            if entry.trim().is_empty() {
657                return Err(VmConfigError::new(
658                    "dns.nameServers entries must not be empty",
659                ));
660            }
661        }
662        for (host, addresses) in &self.overrides {
663            if host.trim().is_empty() {
664                return Err(VmConfigError::new("dns.overrides keys must not be empty"));
665            }
666            if addresses.is_empty() {
667                return Err(VmConfigError::new(format!(
668                    "dns.overrides.{host} must contain at least one address"
669                )));
670            }
671        }
672        Ok(())
673    }
674}
675
676#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
677#[serde(rename_all = "camelCase", deny_unknown_fields)]
678#[ts(export, export_to = "../../../packages/core/src/generated/")]
679pub struct VmListenPolicyConfig {
680    #[serde(default, rename = "portMin", skip_serializing_if = "Option::is_none")]
681    #[ts(optional)]
682    pub port_min: Option<u16>,
683    #[serde(default, rename = "portMax", skip_serializing_if = "Option::is_none")]
684    #[ts(optional)]
685    pub port_max: Option<u16>,
686    #[serde(
687        default,
688        rename = "allowPrivileged",
689        skip_serializing_if = "Option::is_none"
690    )]
691    #[ts(optional)]
692    pub allow_privileged: Option<bool>,
693}
694
695impl VmListenPolicyConfig {
696    fn validate(&self) -> Result<(), VmConfigError> {
697        if self.port_min == Some(0) {
698            return Err(VmConfigError::new(
699                "listen.portMin must be between 1 and 65535",
700            ));
701        }
702        if self.port_max == Some(0) {
703            return Err(VmConfigError::new(
704                "listen.portMax must be between 1 and 65535",
705            ));
706        }
707        if let (Some(min), Some(max)) = (self.port_min, self.port_max) {
708            if min > max {
709                return Err(VmConfigError::new(
710                    "listen.portMin must be <= listen.portMax",
711                ));
712            }
713        }
714        Ok(())
715    }
716}
717
718#[derive(Debug, Clone, PartialEq, Eq)]
719pub struct VmConfigError {
720    message: String,
721}
722
723impl VmConfigError {
724    pub fn new(message: impl Into<String>) -> Self {
725        Self {
726            message: message.into(),
727        }
728    }
729}
730
731impl std::fmt::Display for VmConfigError {
732    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
733        f.write_str(&self.message)
734    }
735}
736
737impl std::error::Error for VmConfigError {}
738
739fn validate_guest_path(label: &str, path: &str) -> Result<(), VmConfigError> {
740    if !path.starts_with('/') {
741        return Err(VmConfigError::new(format!("{label} must be absolute")));
742    }
743    if path.split('/').any(|part| part == "..") {
744        return Err(VmConfigError::new(format!("{label} must not contain '..'")));
745    }
746    Ok(())
747}
748
749#[cfg(test)]
750mod tests {
751    use super::*;
752
753    #[test]
754    fn default_config_round_trips() {
755        let config = CreateVmConfig::default();
756        let json = serde_json::to_string(&config).expect("serialize config");
757        let decoded: CreateVmConfig = serde_json::from_str(&json).expect("decode config");
758        assert_eq!(decoded, config);
759    }
760
761    #[test]
762    fn unknown_fields_are_rejected() {
763        let error =
764            serde_json::from_str::<CreateVmConfig>(r#"{"rootFilesystem":{},"surprise":true}"#)
765                .expect_err("unknown fields should fail");
766        assert!(error.to_string().contains("unknown field"));
767    }
768
769    #[test]
770    fn validate_rejects_fetch_limit_above_frame_cap() {
771        let config = CreateVmConfig {
772            limits: Some(VmLimitsConfig {
773                http: Some(HttpLimitsConfig {
774                    max_fetch_response_bytes: Some(2048),
775                }),
776                ..VmLimitsConfig::default()
777            }),
778            ..CreateVmConfig::default()
779        };
780        assert!(config.validate(1024).is_err());
781    }
782
783    fn js_runtime_config(value: serde_json::Value) -> Result<CreateVmConfig, serde_json::Error> {
784        serde_json::from_value(serde_json::json!({ "jsRuntime": value }))
785    }
786
787    #[test]
788    fn js_runtime_defaults_to_node() {
789        let config: CreateVmConfig =
790            serde_json::from_value(serde_json::json!({ "jsRuntime": {} })).expect("decode");
791        let js = config.js_runtime.expect("jsRuntime present");
792        assert_eq!(js.platform, JsRuntimePlatform::Node);
793        assert_eq!(js.module_resolution, JsModuleResolution::Node);
794        assert!(js.allowed_builtins.is_none());
795    }
796
797    #[test]
798    fn js_runtime_all_platform_resolution_combos_round_trip() {
799        for platform in ["node", "browser", "neutral", "bare"] {
800            for resolution in ["node", "relative", "none"] {
801                let config = js_runtime_config(serde_json::json!({
802                    "platform": platform,
803                    "moduleResolution": resolution,
804                }))
805                .unwrap_or_else(|err| panic!("decode {platform}/{resolution}: {err}"));
806                let json = serde_json::to_string(&config).expect("serialize");
807                let decoded: CreateVmConfig = serde_json::from_str(&json).expect("re-decode");
808                assert_eq!(decoded, config);
809                assert!(config.validate(usize::MAX).is_ok());
810            }
811        }
812    }
813
814    #[test]
815    fn js_runtime_allowed_builtins_tri_state() {
816        // None => omitted.
817        let none = js_runtime_config(serde_json::json!({ "platform": "node" })).unwrap();
818        assert!(none.js_runtime.unwrap().allowed_builtins.is_none());
819        // Some([]) => deny all (representable, distinct from None).
820        let empty = js_runtime_config(serde_json::json!({ "allowedBuiltins": [] })).unwrap();
821        assert_eq!(empty.js_runtime.unwrap().allowed_builtins, Some(Vec::new()));
822        // Some([..]) => explicit.
823        let some = js_runtime_config(serde_json::json!({ "allowedBuiltins": ["path", "node:fs"] }))
824            .unwrap();
825        assert_eq!(
826            some.js_runtime.unwrap().allowed_builtins,
827            Some(vec!["path".to_owned(), "node:fs".to_owned()])
828        );
829    }
830
831    #[test]
832    fn js_runtime_rejects_allowed_builtins_under_non_node_platform() {
833        for platform in ["browser", "neutral", "bare"] {
834            let config = js_runtime_config(serde_json::json!({
835                "platform": platform,
836                "allowedBuiltins": ["path"],
837            }))
838            .unwrap();
839            let error = config
840                .validate(usize::MAX)
841                .expect_err("allowedBuiltins under non-node must reject");
842            assert!(error.to_string().contains("allowedBuiltins"));
843        }
844    }
845
846    #[test]
847    fn js_runtime_rejects_unknown_builtin_names() {
848        let config = js_runtime_config(serde_json::json!({
849            "platform": "node",
850            "allowedBuiltins": ["path", "totally_not_a_builtin"],
851        }))
852        .unwrap();
853        let error = config
854            .validate(usize::MAX)
855            .expect_err("unknown builtin must reject");
856        assert!(error.to_string().contains("unknown builtin"));
857    }
858
859    #[test]
860    fn js_runtime_accepts_empty_allow_list_under_node() {
861        let config =
862            js_runtime_config(serde_json::json!({ "platform": "node", "allowedBuiltins": [] }))
863                .unwrap();
864        assert!(config.validate(usize::MAX).is_ok());
865    }
866
867    #[test]
868    fn js_runtime_rejects_unknown_fields() {
869        let error = js_runtime_config(serde_json::json!({ "surprise": true }))
870            .expect_err("unknown jsRuntime field should fail");
871        assert!(error.to_string().contains("unknown field"));
872    }
873}