Skip to main content

agentos_vm_config/
lib.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use ts_rs::TS;
5
6/// Canonical Rust-side VM config. Unknown fields must stay rejected here and in
7/// the TS preflight schema at
8/// `packages/core/src/node-runtime-options-schema.ts`; update both when a
9/// public `NodeRuntime.create(...)` option changes the generated VM config.
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
11#[serde(rename_all = "camelCase", deny_unknown_fields)]
12#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
13#[derive(Default)]
14pub struct CreateVmConfig {
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    #[ts(optional)]
17    pub cwd: Option<String>,
18    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
19    #[ts(type = "Record<string, string>")]
20    pub env: BTreeMap<String, String>,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    #[ts(optional)]
23    pub database: Option<VmSqliteDescriptor>,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    #[ts(optional)]
26    pub user: Option<VmUserConfig>,
27    #[serde(default, rename = "rootFilesystem")]
28    pub root_filesystem: RootFilesystemConfig,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    #[ts(optional)]
31    pub permissions: Option<PermissionsPolicy>,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    #[ts(optional)]
34    pub limits: Option<VmLimitsConfig>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    #[ts(optional)]
37    pub dns: Option<VmDnsConfig>,
38    #[serde(
39        default,
40        rename = "nativeRoot",
41        skip_serializing_if = "Option::is_none"
42    )]
43    #[ts(optional)]
44    pub native_root: Option<NativeRootFilesystemConfig>,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    #[ts(optional)]
47    pub listen: Option<VmListenPolicyConfig>,
48    #[serde(
49        default,
50        rename = "loopbackExemptPorts",
51        skip_serializing_if = "Vec::is_empty"
52    )]
53    pub loopback_exempt_ports: Vec<u16>,
54    #[serde(default, rename = "jsRuntime", skip_serializing_if = "Option::is_none")]
55    #[ts(optional)]
56    pub js_runtime: Option<JsRuntimeConfig>,
57    #[serde(
58        default,
59        rename = "bootstrapCommands",
60        skip_serializing_if = "Option::is_none"
61    )]
62    #[ts(optional)]
63    pub bootstrap_commands: Option<Vec<String>>,
64}
65
66impl CreateVmConfig {
67    pub fn validate(&self, max_frame_bytes: usize) -> Result<(), VmConfigError> {
68        if let Some(cwd) = self.cwd.as_deref() {
69            validate_guest_path("cwd", cwd)?;
70        }
71        if let Some(database) = &self.database {
72            database.validate()?;
73        }
74        if let Some(user) = &self.user {
75            user.validate()?;
76        }
77        self.root_filesystem.validate()?;
78        if let Some(native_root) = &self.native_root {
79            native_root.validate()?;
80        }
81        if self.native_root.is_some() && !self.root_filesystem.bootstrap_entries.is_empty() {
82            return Err(VmConfigError::new(
83                "nativeRoot does not support rootFilesystem.bootstrapEntries",
84            ));
85        }
86        if let Some(dns) = &self.dns {
87            dns.validate()?;
88        }
89        if let Some(listen) = &self.listen {
90            listen.validate()?;
91        }
92        if let Some(limits) = &self.limits {
93            limits.validate(max_frame_bytes)?;
94        }
95        if let Some(js_runtime) = &self.js_runtime {
96            js_runtime.validate()?;
97        }
98        if let Some(bootstrap_commands) = &self.bootstrap_commands {
99            validate_command_names("bootstrapCommands", bootstrap_commands)?;
100        }
101        Ok(())
102    }
103}
104
105/// Transport used by the VM-scoped SQLite substrate.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
107#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
108#[ts(tag = "type", rename_all = "snake_case")]
109#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
110pub enum VmSqliteDescriptor {
111    /// Rivet actor SQLite reached through the actor's local runtime socket.
112    ActorUds { path: String },
113    /// A SQLite database file owned by the native sidecar host.
114    SqliteFile { path: String },
115}
116
117impl VmSqliteDescriptor {
118    fn validate(&self) -> Result<(), VmConfigError> {
119        match self {
120            Self::ActorUds { path } => {
121                validate_absolute_host_path("database.path", path)?;
122            }
123            Self::SqliteFile { path } => validate_absolute_host_path("database.path", path)?,
124        }
125        Ok(())
126    }
127}
128
129fn validate_absolute_host_path(field: &str, path: &str) -> Result<(), VmConfigError> {
130    if path.is_empty() || !path.starts_with('/') || path.as_bytes().contains(&0) {
131        return Err(VmConfigError::new(format!(
132            "{field} must be a non-empty absolute path without NUL bytes"
133        )));
134    }
135    Ok(())
136}
137
138/// Initial Linux-style credentials and account record for processes in a VM.
139#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, TS)]
140#[serde(rename_all = "camelCase", deny_unknown_fields)]
141#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
142pub struct VmUserConfig {
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    #[ts(optional)]
145    pub uid: Option<u32>,
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    #[ts(optional)]
148    pub gid: Option<u32>,
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    #[ts(optional)]
151    pub euid: Option<u32>,
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    #[ts(optional)]
154    pub egid: Option<u32>,
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    #[ts(optional)]
157    pub username: Option<String>,
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    #[ts(optional)]
160    pub homedir: Option<String>,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    #[ts(optional)]
163    pub shell: Option<String>,
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    #[ts(optional)]
166    pub gecos: Option<String>,
167    #[serde(default, rename = "groupName", skip_serializing_if = "Option::is_none")]
168    #[ts(optional)]
169    pub group_name: Option<String>,
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    #[ts(optional)]
172    pub supplementary_gids: Option<Vec<u32>>,
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    #[ts(optional)]
175    pub accounts: Option<Vec<VmUserAccountConfig>>,
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    #[ts(optional)]
178    pub groups: Option<Vec<VmGroupConfig>>,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
182#[serde(rename_all = "camelCase", deny_unknown_fields)]
183#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
184pub struct VmUserAccountConfig {
185    pub uid: u32,
186    pub gid: u32,
187    pub username: String,
188    pub homedir: String,
189    pub shell: String,
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    #[ts(optional)]
192    pub gecos: Option<String>,
193    #[serde(default, skip_serializing_if = "Vec::is_empty")]
194    pub supplementary_gids: Vec<u32>,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
198#[serde(rename_all = "camelCase", deny_unknown_fields)]
199#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
200pub struct VmGroupConfig {
201    pub gid: u32,
202    pub name: String,
203    #[serde(default, skip_serializing_if = "Vec::is_empty")]
204    pub members: Vec<String>,
205}
206
207impl VmUserConfig {
208    fn validate(&self) -> Result<(), VmConfigError> {
209        const MAX_SUPPLEMENTARY_GIDS: usize = 64;
210        const MAX_ACCOUNTS: usize = 64;
211        const MAX_GROUPS: usize = 128;
212        if self
213            .supplementary_gids
214            .as_ref()
215            .is_some_and(|groups| groups.len() > MAX_SUPPLEMENTARY_GIDS)
216        {
217            return Err(VmConfigError::new(format!(
218                "user.supplementaryGids exceeds limit of {MAX_SUPPLEMENTARY_GIDS}"
219            )));
220        }
221        for (label, value) in [
222            ("user.username", self.username.as_deref()),
223            ("user.groupName", self.group_name.as_deref()),
224        ] {
225            if value.is_some_and(|value| {
226                value.is_empty()
227                    || value.contains([':', '\n', '\r', '\0'])
228                    || value.chars().any(char::is_whitespace)
229            }) {
230                return Err(VmConfigError::new(format!("{label} is invalid")));
231            }
232        }
233        if self
234            .gecos
235            .as_deref()
236            .is_some_and(|value| value.contains([':', '\n', '\r', '\0']))
237        {
238            return Err(VmConfigError::new("user.gecos is invalid"));
239        }
240        let accounts = self.accounts.as_deref().unwrap_or_default();
241        let groups = self.groups.as_deref().unwrap_or_default();
242        if accounts.len() > MAX_ACCOUNTS {
243            return Err(VmConfigError::new(format!(
244                "user.accounts exceeds limit of {MAX_ACCOUNTS}"
245            )));
246        }
247        if groups.len() > MAX_GROUPS {
248            return Err(VmConfigError::new(format!(
249                "user.groups exceeds limit of {MAX_GROUPS}"
250            )));
251        }
252        let mut account_uids = std::collections::BTreeSet::new();
253        let mut account_names = std::collections::BTreeSet::new();
254        for account in accounts {
255            validate_account_name("user.accounts[].username", &account.username)?;
256            validate_guest_path("user.accounts[].homedir", &account.homedir)?;
257            validate_guest_path("user.accounts[].shell", &account.shell)?;
258            if account
259                .gecos
260                .as_deref()
261                .is_some_and(|value| value.contains([':', '\n', '\r', '\0']))
262            {
263                return Err(VmConfigError::new("user.accounts[].gecos is invalid"));
264            }
265            if account.supplementary_gids.len() > MAX_SUPPLEMENTARY_GIDS {
266                return Err(VmConfigError::new(format!(
267                    "user.accounts[].supplementaryGids exceeds limit of {MAX_SUPPLEMENTARY_GIDS}"
268                )));
269            }
270            if !account_uids.insert(account.uid) {
271                return Err(VmConfigError::new(format!(
272                    "duplicate user account uid {}",
273                    account.uid
274                )));
275            }
276            if !account_names.insert(account.username.as_str()) {
277                return Err(VmConfigError::new(format!(
278                    "duplicate user account name {}",
279                    account.username
280                )));
281            }
282        }
283        let mut group_gids = std::collections::BTreeSet::new();
284        let mut group_names = std::collections::BTreeSet::new();
285        for group in groups {
286            validate_account_name("user.groups[].name", &group.name)?;
287            for member in &group.members {
288                validate_account_name("user.groups[].members[]", member)?;
289            }
290            if !group_gids.insert(group.gid) {
291                return Err(VmConfigError::new(format!(
292                    "duplicate user group gid {}",
293                    group.gid
294                )));
295            }
296            if !group_names.insert(group.name.as_str()) {
297                return Err(VmConfigError::new(format!(
298                    "duplicate user group name {}",
299                    group.name
300                )));
301            }
302        }
303        if let Some(homedir) = self.homedir.as_deref() {
304            validate_guest_path("user.homedir", homedir)?;
305        }
306        if let Some(shell) = self.shell.as_deref() {
307            validate_guest_path("user.shell", shell)?;
308        }
309        Ok(())
310    }
311}
312
313fn validate_account_name(label: &str, value: &str) -> Result<(), VmConfigError> {
314    if value.is_empty()
315        || value.contains([':', '\n', '\r', '\0'])
316        || value.chars().any(char::is_whitespace)
317    {
318        return Err(VmConfigError::new(format!("{label} is invalid")));
319    }
320    Ok(())
321}
322
323/// Guest JavaScript host-environment configuration.
324///
325/// Selects which globals/builtins/module-resolution surface guest JS sees,
326/// modeled on esbuild's `platform`. Omitting this preserves full Node.js
327/// emulation (`platform = node`).
328#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, TS)]
329#[serde(rename_all = "camelCase", deny_unknown_fields)]
330#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
331pub struct JsRuntimeConfig {
332    /// Which host environment to emulate for guest JS. Default `node`.
333    #[serde(default)]
334    pub platform: JsRuntimePlatform,
335    /// How bare import specifiers resolve. Independent of `platform`.
336    /// Default `node`.
337    #[serde(default, rename = "moduleResolution")]
338    pub module_resolution: JsModuleResolution,
339    /// Node builtin-module allow-list. Only valid when `platform = node`.
340    /// `None` => engine default allow-list. `Some([])` => deny all builtins.
341    /// `Some([..])` => exactly those.
342    #[serde(
343        default,
344        rename = "allowedBuiltins",
345        skip_serializing_if = "Option::is_none"
346    )]
347    #[ts(optional)]
348    pub allowed_builtins: Option<Vec<String>>,
349    /// Opt in to a high-resolution monotonic guest clock. Default false keeps
350    /// the security-oriented 1ms timer resolution.
351    #[serde(
352        default,
353        rename = "highResolutionTime",
354        skip_serializing_if = "Option::is_none"
355    )]
356    #[ts(optional)]
357    pub high_resolution_time: Option<bool>,
358}
359
360impl JsRuntimeConfig {
361    fn validate(&self) -> Result<(), VmConfigError> {
362        if let Some(allowed) = &self.allowed_builtins {
363            if self.platform != JsRuntimePlatform::Node {
364                return Err(VmConfigError::new(
365                    "jsRuntime.allowedBuiltins is only valid when jsRuntime.platform is \"node\"",
366                ));
367            }
368            for name in allowed {
369                if !is_known_node_builtin(name) {
370                    return Err(VmConfigError::new(format!(
371                        "jsRuntime.allowedBuiltins contains unknown builtin {name:?}"
372                    )));
373                }
374            }
375        }
376        Ok(())
377    }
378}
379
380#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
381#[serde(rename_all = "lowercase")]
382#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
383#[derive(Default)]
384pub enum JsRuntimePlatform {
385    /// Full Node.js host surface (process/Buffer/require, `node:*`, npm
386    /// resolution, virtual Node identity). Default.
387    #[default]
388    Node,
389    /// Web-platform globals (fetch/URL/WebCrypto/...), no Node surface.
390    Browser,
391    /// Universal primitives only (console, timers, queueMicrotask) — no web
392    /// platform, no Node surface.
393    Neutral,
394    /// Language-only: ECMAScript spec globals + WebAssembly. Nothing host-provided.
395    Bare,
396}
397
398#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
399#[serde(rename_all = "lowercase")]
400#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
401#[derive(Default)]
402pub enum JsModuleResolution {
403    /// node_modules ancestor-walk + exports/imports/conditions + realpath. Default.
404    #[default]
405    Node,
406    /// Relative/absolute ESM from the VFS only; bare specifiers do not resolve.
407    Relative,
408    /// No resolution: any import/require (even relative) fails.
409    None,
410}
411
412/// Canonical set of recognized Node builtin module names (without the `node:`
413/// prefix), kept in sync with `normalize_builtin_specifier` in
414/// `crates/execution/src/javascript.rs`. Used to validate
415/// `jsRuntime.allowedBuiltins` entries.
416const KNOWN_NODE_BUILTINS: &[&str] = &[
417    "assert",
418    "async_hooks",
419    "buffer",
420    "child_process",
421    "cluster",
422    "console",
423    "constants",
424    "crypto",
425    "dgram",
426    "diagnostics_channel",
427    "dns",
428    "dns/promises",
429    "domain",
430    "events",
431    "fs",
432    "fs/promises",
433    "http",
434    "http2",
435    "https",
436    "inspector",
437    "module",
438    "net",
439    "os",
440    "path",
441    "path/posix",
442    "path/win32",
443    "perf_hooks",
444    "process",
445    "punycode",
446    "querystring",
447    "readline",
448    "repl",
449    "sqlite",
450    "stream",
451    "stream/consumers",
452    "stream/promises",
453    "stream/web",
454    "string_decoder",
455    "sys",
456    "timers",
457    "timers/promises",
458    "tls",
459    "trace_events",
460    "tty",
461    "url",
462    "util",
463    "util/types",
464    "v8",
465    "vm",
466    "wasi",
467    "worker_threads",
468    "zlib",
469];
470
471fn is_known_node_builtin(name: &str) -> bool {
472    let bare = name.strip_prefix("node:").unwrap_or(name);
473    KNOWN_NODE_BUILTINS.contains(&bare)
474}
475
476#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
477#[serde(rename_all = "camelCase", deny_unknown_fields)]
478#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
479pub struct RootFilesystemConfig {
480    #[serde(default)]
481    pub mode: RootFilesystemMode,
482    #[serde(default, rename = "disableDefaultBaseLayer")]
483    pub disable_default_base_layer: bool,
484    #[serde(default, skip_serializing_if = "Vec::is_empty")]
485    pub lowers: Vec<RootFilesystemLowerDescriptor>,
486    #[serde(
487        default,
488        rename = "bootstrapEntries",
489        skip_serializing_if = "Vec::is_empty"
490    )]
491    pub bootstrap_entries: Vec<RootFilesystemEntry>,
492}
493
494impl Default for RootFilesystemConfig {
495    fn default() -> Self {
496        Self {
497            mode: RootFilesystemMode::Ephemeral,
498            disable_default_base_layer: false,
499            lowers: Vec::new(),
500            bootstrap_entries: Vec::new(),
501        }
502    }
503}
504
505impl RootFilesystemConfig {
506    fn validate(&self) -> Result<(), VmConfigError> {
507        for lower in &self.lowers {
508            if let RootFilesystemLowerDescriptor::Snapshot { entries } = lower {
509                for entry in entries {
510                    entry.validate()?;
511                }
512            }
513        }
514        for entry in &self.bootstrap_entries {
515            entry.validate()?;
516        }
517        Ok(())
518    }
519}
520
521#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
522#[serde(rename_all = "kebab-case")]
523#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
524#[derive(Default)]
525pub enum RootFilesystemMode {
526    #[default]
527    Ephemeral,
528    ReadOnly,
529}
530
531#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
532#[serde(tag = "kind", rename_all = "camelCase")]
533#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
534pub enum RootFilesystemLowerDescriptor {
535    Snapshot {
536        #[serde(default)]
537        entries: Vec<RootFilesystemEntry>,
538    },
539    BundledBaseFilesystem,
540}
541
542#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
543#[serde(rename_all = "camelCase", deny_unknown_fields)]
544#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
545pub struct RootFilesystemEntry {
546    pub path: String,
547    pub kind: RootFilesystemEntryKind,
548    #[serde(default, skip_serializing_if = "Option::is_none")]
549    #[ts(optional)]
550    pub mode: Option<u32>,
551    #[serde(default, skip_serializing_if = "Option::is_none")]
552    #[ts(optional)]
553    pub uid: Option<u32>,
554    #[serde(default, skip_serializing_if = "Option::is_none")]
555    #[ts(optional)]
556    pub gid: Option<u32>,
557    #[serde(default, skip_serializing_if = "Option::is_none")]
558    #[ts(optional)]
559    pub content: Option<String>,
560    #[serde(default, skip_serializing_if = "Option::is_none")]
561    #[ts(optional)]
562    pub encoding: Option<RootFilesystemEntryEncoding>,
563    #[serde(default, skip_serializing_if = "Option::is_none")]
564    #[ts(optional)]
565    pub target: Option<String>,
566    #[serde(default)]
567    pub executable: bool,
568}
569
570impl RootFilesystemEntry {
571    fn validate(&self) -> Result<(), VmConfigError> {
572        validate_guest_path("root filesystem entry path", &self.path)?;
573        match self.kind {
574            RootFilesystemEntryKind::File => {
575                if self.target.is_some() {
576                    return Err(VmConfigError::new(format!(
577                        "file entry {} must not include target",
578                        self.path
579                    )));
580                }
581            }
582            RootFilesystemEntryKind::Directory => {
583                if self.content.is_some() || self.encoding.is_some() || self.target.is_some() {
584                    return Err(VmConfigError::new(format!(
585                        "directory entry {} must not include content, encoding, or target",
586                        self.path
587                    )));
588                }
589            }
590            RootFilesystemEntryKind::Symlink => {
591                if self.target.as_deref().unwrap_or("").is_empty() {
592                    return Err(VmConfigError::new(format!(
593                        "symlink entry {} requires target",
594                        self.path
595                    )));
596                }
597                if self.content.is_some() || self.encoding.is_some() {
598                    return Err(VmConfigError::new(format!(
599                        "symlink entry {} must not include content or encoding",
600                        self.path
601                    )));
602                }
603            }
604        }
605        Ok(())
606    }
607}
608
609#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
610#[serde(rename_all = "lowercase")]
611#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
612pub enum RootFilesystemEntryKind {
613    File,
614    Directory,
615    Symlink,
616}
617
618#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
619#[serde(rename_all = "lowercase")]
620#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
621pub enum RootFilesystemEntryEncoding {
622    Utf8,
623    Base64,
624}
625
626#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
627#[serde(rename_all = "camelCase", deny_unknown_fields)]
628#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
629pub struct NativeRootFilesystemConfig {
630    pub plugin: MountPluginDescriptor,
631    #[serde(default, rename = "readOnly")]
632    pub read_only: bool,
633}
634
635impl NativeRootFilesystemConfig {
636    fn validate(&self) -> Result<(), VmConfigError> {
637        if self.plugin.id.trim().is_empty() {
638            return Err(VmConfigError::new("nativeRoot.plugin.id is required"));
639        }
640        Ok(())
641    }
642}
643
644#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
645#[serde(rename_all = "camelCase", deny_unknown_fields)]
646#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
647pub struct MountPluginDescriptor {
648    pub id: String,
649    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
650    #[ts(type = "import(\"@rivet-dev/agentos-runtime-core/descriptors\").MountConfigJsonValue")]
651    pub config: serde_json::Value,
652}
653
654#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
655#[serde(rename_all = "lowercase")]
656#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
657pub enum PermissionMode {
658    Allow,
659    Ask,
660    Deny,
661}
662
663#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
664#[serde(untagged)]
665#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
666pub enum FsPermissionScope {
667    Mode(PermissionMode),
668    Rules(FsPermissionRuleSet),
669}
670
671#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
672#[serde(untagged)]
673#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
674pub enum PatternPermissionScope {
675    Mode(PermissionMode),
676    Rules(PatternPermissionRuleSet),
677}
678
679#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
680#[serde(rename_all = "camelCase", deny_unknown_fields)]
681#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
682pub struct FsPermissionRuleSet {
683    #[serde(default, skip_serializing_if = "Option::is_none")]
684    #[ts(optional)]
685    pub default: Option<PermissionMode>,
686    #[serde(default)]
687    pub rules: Vec<FsPermissionRule>,
688}
689
690#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
691#[serde(rename_all = "camelCase", deny_unknown_fields)]
692#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
693pub struct PatternPermissionRuleSet {
694    #[serde(default, skip_serializing_if = "Option::is_none")]
695    #[ts(optional)]
696    pub default: Option<PermissionMode>,
697    #[serde(default)]
698    pub rules: Vec<PatternPermissionRule>,
699}
700
701#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
702#[serde(rename_all = "camelCase", deny_unknown_fields)]
703#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
704pub struct FsPermissionRule {
705    pub mode: PermissionMode,
706    #[serde(default, skip_serializing_if = "Vec::is_empty")]
707    pub operations: Vec<String>,
708    #[serde(default, skip_serializing_if = "Vec::is_empty")]
709    pub paths: Vec<String>,
710}
711
712#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
713#[serde(rename_all = "camelCase", deny_unknown_fields)]
714#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
715pub struct PatternPermissionRule {
716    pub mode: PermissionMode,
717    #[serde(default, skip_serializing_if = "Vec::is_empty")]
718    pub operations: Vec<String>,
719    #[serde(default, skip_serializing_if = "Vec::is_empty")]
720    pub patterns: Vec<String>,
721}
722
723#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
724#[serde(rename_all = "camelCase", deny_unknown_fields)]
725#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
726pub struct PermissionsPolicy {
727    #[serde(default, skip_serializing_if = "Option::is_none")]
728    #[ts(optional)]
729    pub fs: Option<FsPermissionScope>,
730    #[serde(default, skip_serializing_if = "Option::is_none")]
731    #[ts(optional)]
732    pub network: Option<PatternPermissionScope>,
733    #[serde(
734        default,
735        rename = "childProcess",
736        skip_serializing_if = "Option::is_none"
737    )]
738    #[ts(optional)]
739    pub child_process: Option<PatternPermissionScope>,
740    #[serde(default, skip_serializing_if = "Option::is_none")]
741    #[ts(optional)]
742    pub process: Option<PatternPermissionScope>,
743    #[serde(default, skip_serializing_if = "Option::is_none")]
744    #[ts(optional)]
745    pub env: Option<PatternPermissionScope>,
746    #[serde(default, skip_serializing_if = "Option::is_none")]
747    #[ts(optional)]
748    pub binding: Option<PatternPermissionScope>,
749}
750
751#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
752#[serde(rename_all = "camelCase", deny_unknown_fields)]
753#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
754pub struct VmLimitsConfig {
755    #[serde(default, skip_serializing_if = "Option::is_none")]
756    #[ts(optional)]
757    pub reactor: Option<ReactorLimitsConfig>,
758    #[serde(default, skip_serializing_if = "Option::is_none")]
759    #[ts(optional)]
760    pub resources: Option<ResourceLimitsConfig>,
761    #[serde(default, skip_serializing_if = "Option::is_none")]
762    #[ts(optional)]
763    pub http: Option<HttpLimitsConfig>,
764    #[serde(default, skip_serializing_if = "Option::is_none")]
765    #[ts(optional)]
766    pub udp: Option<UdpLimitsConfig>,
767    #[serde(default, skip_serializing_if = "Option::is_none")]
768    #[ts(optional)]
769    pub tls: Option<TlsLimitsConfig>,
770    #[serde(default, skip_serializing_if = "Option::is_none")]
771    #[ts(optional)]
772    pub http2: Option<Http2LimitsConfig>,
773    #[serde(default, skip_serializing_if = "Option::is_none")]
774    #[ts(optional)]
775    pub bindings: Option<BindingLimitsConfig>,
776    #[serde(default, skip_serializing_if = "Option::is_none")]
777    #[ts(optional)]
778    pub plugins: Option<PluginLimitsConfig>,
779    #[serde(default, skip_serializing_if = "Option::is_none")]
780    #[ts(optional)]
781    pub acp: Option<AcpLimitsConfig>,
782    #[serde(default, skip_serializing_if = "Option::is_none")]
783    #[ts(optional)]
784    pub sqlite: Option<SqliteLimitsConfig>,
785    #[serde(default, rename = "jsRuntime", skip_serializing_if = "Option::is_none")]
786    #[ts(optional)]
787    pub js_runtime: Option<JsRuntimeLimitsConfig>,
788    #[serde(default, skip_serializing_if = "Option::is_none")]
789    #[ts(optional)]
790    pub python: Option<PythonLimitsConfig>,
791    #[serde(default, skip_serializing_if = "Option::is_none")]
792    #[ts(optional)]
793    pub wasm: Option<WasmLimitsConfig>,
794    #[serde(default, skip_serializing_if = "Option::is_none")]
795    #[ts(optional)]
796    pub process: Option<ProcessLimitsConfig>,
797}
798
799impl VmLimitsConfig {
800    fn validate(&self, max_frame_bytes: usize) -> Result<(), VmConfigError> {
801        if let Some(reactor) = &self.reactor {
802            validate_nonzero_options([
803                ("limits.reactor.maxCapabilities", reactor.max_capabilities),
804                ("limits.reactor.maxReadyHandles", reactor.max_ready_handles),
805                ("limits.reactor.maxTasks", reactor.max_tasks),
806                ("limits.reactor.workQuantum", reactor.work_quantum),
807                ("limits.reactor.byteQuantum", reactor.byte_quantum),
808                (
809                    "limits.reactor.maxHandleCommands",
810                    reactor.max_handle_commands,
811                ),
812                (
813                    "limits.reactor.maxHandleCommandBytes",
814                    reactor.max_handle_command_bytes,
815                ),
816                ("limits.reactor.maxBridgeCalls", reactor.max_bridge_calls),
817                (
818                    "limits.reactor.maxBridgeRequestBytes",
819                    reactor.max_bridge_request_bytes,
820                ),
821                (
822                    "limits.reactor.maxBridgeResponseBytes",
823                    reactor.max_bridge_response_bytes,
824                ),
825                (
826                    "limits.reactor.maxAsyncCompletions",
827                    reactor.max_async_completions,
828                ),
829                (
830                    "limits.reactor.maxAsyncCompletionBytes",
831                    reactor.max_async_completion_bytes,
832                ),
833                ("limits.reactor.maxBlockingJobs", reactor.max_blocking_jobs),
834                (
835                    "limits.reactor.maxBlockingBytes",
836                    reactor.max_blocking_bytes,
837                ),
838                (
839                    "limits.reactor.perHandleOperationQuantum",
840                    reactor.per_handle_operation_quantum,
841                ),
842                ("limits.reactor.acceptQuantum", reactor.accept_quantum),
843                ("limits.reactor.datagramQuantum", reactor.datagram_quantum),
844                (
845                    "limits.reactor.completionQuantum",
846                    reactor.completion_quantum,
847                ),
848                ("limits.reactor.signalQuantum", reactor.signal_quantum),
849                (
850                    "limits.reactor.shutdownDeadlineMs",
851                    reactor.shutdown_deadline_ms,
852                ),
853                (
854                    "limits.reactor.operationDeadlineMs",
855                    reactor.operation_deadline_ms,
856                ),
857            ])?;
858            validate_optional_parent(
859                "limits.reactor.maxCapabilities",
860                reactor.max_capabilities,
861                "limits.reactor.maxReadyHandles",
862                reactor.max_ready_handles,
863            )?;
864            validate_optional_parent(
865                "limits.reactor.perHandleOperationQuantum",
866                reactor.per_handle_operation_quantum,
867                "limits.reactor.maxHandleCommands",
868                reactor.max_handle_commands,
869            )?;
870            validate_optional_parent(
871                "limits.reactor.acceptQuantum",
872                reactor.accept_quantum,
873                "limits.reactor.maxCapabilities",
874                reactor.max_capabilities,
875            )?;
876            validate_optional_parent(
877                "limits.reactor.completionQuantum",
878                reactor.completion_quantum,
879                "limits.reactor.maxAsyncCompletions",
880                reactor.max_async_completions,
881            )?;
882            if let Some(max_bridge_request_bytes) = reactor.max_bridge_request_bytes {
883                if max_bridge_request_bytes > max_frame_bytes as u64 {
884                    return Err(VmConfigError::new(format!(
885                        "limits.reactor.maxBridgeRequestBytes ({max_bridge_request_bytes}) must \
886                         be <= the sidecar wire frame cap ({max_frame_bytes})"
887                    )));
888                }
889            }
890            if let Some(max_bridge_response_bytes) = reactor.max_bridge_response_bytes {
891                if max_bridge_response_bytes > max_frame_bytes as u64 {
892                    return Err(VmConfigError::new(format!(
893                        "limits.reactor.maxBridgeResponseBytes ({max_bridge_response_bytes}) must \
894                         be <= the sidecar wire frame cap ({max_frame_bytes})"
895                    )));
896                }
897            }
898        }
899        if let Some(http) = &self.http {
900            if let Some(max_fetch_response_bytes) = http.max_fetch_response_bytes {
901                if max_fetch_response_bytes == 0 {
902                    return Err(VmConfigError::new(
903                        "limits.http.maxFetchResponseBytes must be greater than zero",
904                    ));
905                }
906                if max_fetch_response_bytes as usize > max_frame_bytes {
907                    return Err(VmConfigError::new(format!(
908                        "limits.http.maxFetchResponseBytes ({max_fetch_response_bytes}) must be <= the sidecar wire frame cap ({max_frame_bytes})"
909                    )));
910                }
911            }
912        }
913        if let Some(udp) = &self.udp {
914            validate_nonzero_options([
915                (
916                    "limits.udp.maxBufferedDatagrams",
917                    udp.max_buffered_datagrams,
918                ),
919                ("limits.udp.maxBufferedBytes", udp.max_buffered_bytes),
920            ])?;
921        }
922        if let Some(tls) = &self.tls {
923            validate_nonzero_options([("limits.tls.maxBufferedBytes", tls.max_buffered_bytes)])?;
924        }
925        if let Some(http2) = &self.http2 {
926            validate_nonzero_options([
927                ("limits.http2.maxConnections", http2.max_connections),
928                ("limits.http2.maxStreams", http2.max_streams),
929                (
930                    "limits.http2.maxStreamsPerConnection",
931                    http2.max_streams_per_connection,
932                ),
933                ("limits.http2.maxBufferedBytes", http2.max_buffered_bytes),
934                ("limits.http2.maxHeaderBytes", http2.max_header_bytes),
935                ("limits.http2.maxDataBytes", http2.max_data_bytes),
936                (
937                    "limits.http2.maxPendingCommands",
938                    http2.max_pending_commands,
939                ),
940                (
941                    "limits.http2.maxPendingCommandBytes",
942                    http2.max_pending_command_bytes,
943                ),
944                ("limits.http2.maxPendingEvents", http2.max_pending_events),
945                (
946                    "limits.http2.maxPendingEventBytes",
947                    http2.max_pending_event_bytes,
948                ),
949            ])?;
950            validate_optional_parent(
951                "limits.http2.maxStreamsPerConnection",
952                http2.max_streams_per_connection,
953                "limits.http2.maxStreams",
954                http2.max_streams,
955            )?;
956            for (path, value) in [
957                ("limits.http2.maxHeaderBytes", http2.max_header_bytes),
958                ("limits.http2.maxDataBytes", http2.max_data_bytes),
959                (
960                    "limits.http2.maxPendingCommandBytes",
961                    http2.max_pending_command_bytes,
962                ),
963                (
964                    "limits.http2.maxPendingEventBytes",
965                    http2.max_pending_event_bytes,
966                ),
967            ] {
968                validate_optional_parent(
969                    path,
970                    value,
971                    "limits.http2.maxBufferedBytes",
972                    http2.max_buffered_bytes,
973                )?;
974            }
975        }
976        if let Some(resources) = &self.resources {
977            let aggregate_socket_bytes = resources.max_socket_buffered_bytes;
978            for (path, value) in [
979                (
980                    "limits.reactor.maxHandleCommandBytes",
981                    self.reactor
982                        .as_ref()
983                        .and_then(|limits| limits.max_handle_command_bytes),
984                ),
985                (
986                    "limits.http.maxFetchResponseBytes",
987                    self.http
988                        .as_ref()
989                        .and_then(|limits| limits.max_fetch_response_bytes),
990                ),
991                (
992                    "limits.udp.maxBufferedBytes",
993                    self.udp
994                        .as_ref()
995                        .and_then(|limits| limits.max_buffered_bytes),
996                ),
997                (
998                    "limits.tls.maxBufferedBytes",
999                    self.tls
1000                        .as_ref()
1001                        .and_then(|limits| limits.max_buffered_bytes),
1002                ),
1003                (
1004                    "limits.http2.maxBufferedBytes",
1005                    self.http2
1006                        .as_ref()
1007                        .and_then(|limits| limits.max_buffered_bytes),
1008                ),
1009            ] {
1010                validate_optional_parent(
1011                    path,
1012                    value,
1013                    "limits.resources.maxSocketBufferedBytes",
1014                    aggregate_socket_bytes,
1015                )?;
1016            }
1017            validate_optional_parent(
1018                "limits.udp.maxBufferedDatagrams",
1019                self.udp
1020                    .as_ref()
1021                    .and_then(|limits| limits.max_buffered_datagrams),
1022                "limits.resources.maxSocketDatagramQueueLen",
1023                resources.max_socket_datagram_queue_len,
1024            )?;
1025            validate_optional_parent(
1026                "limits.http2.maxConnections",
1027                self.http2
1028                    .as_ref()
1029                    .and_then(|limits| limits.max_connections),
1030                "limits.resources.maxConnections",
1031                resources.max_connections,
1032            )?;
1033        }
1034        if let (Some(reactor), Some(udp)) = (&self.reactor, &self.udp) {
1035            validate_optional_parent(
1036                "limits.reactor.datagramQuantum",
1037                reactor.datagram_quantum,
1038                "limits.udp.maxBufferedDatagrams",
1039                udp.max_buffered_datagrams,
1040            )?;
1041        }
1042        if let Some(bindings) = &self.bindings {
1043            if let (Some(default), Some(max)) = (
1044                bindings.default_binding_timeout_ms,
1045                bindings.max_binding_timeout_ms,
1046            ) {
1047                if default > max {
1048                    return Err(VmConfigError::new(
1049                        "limits.bindings.defaultBindingTimeoutMs must be <= limits.bindings.maxBindingTimeoutMs",
1050                    ));
1051                }
1052            }
1053        }
1054        if let Some(js_runtime) = &self.js_runtime {
1055            validate_nonzero_options([("limits.jsRuntime.maxTimers", js_runtime.max_timers)])?;
1056        }
1057        Ok(())
1058    }
1059}
1060
1061fn validate_nonzero_options<const N: usize>(
1062    values: [(&str, Option<u64>); N],
1063) -> Result<(), VmConfigError> {
1064    for (path, value) in values {
1065        if value == Some(0) {
1066            return Err(VmConfigError::new(format!(
1067                "{path} must be greater than zero"
1068            )));
1069        }
1070    }
1071    Ok(())
1072}
1073
1074fn validate_optional_parent(
1075    child_path: &str,
1076    child: Option<u64>,
1077    parent_path: &str,
1078    parent: Option<u64>,
1079) -> Result<(), VmConfigError> {
1080    if let (Some(child), Some(parent)) = (child, parent) {
1081        if child > parent {
1082            return Err(VmConfigError::new(format!(
1083                "{child_path} ({child}) must be <= {parent_path} ({parent})"
1084            )));
1085        }
1086    }
1087    Ok(())
1088}
1089
1090macro_rules! limits_struct {
1091    ($name:ident { $($field:ident),* $(,)? }) => {
1092        #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
1093        #[serde(rename_all = "camelCase", deny_unknown_fields)]
1094        #[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
1095        pub struct $name {
1096            $(
1097                #[serde(default, skip_serializing_if = "Option::is_none")]
1098                #[ts(optional)]
1099                #[ts(type = "number")]
1100                pub $field: Option<u64>,
1101            )*
1102        }
1103    };
1104}
1105
1106limits_struct!(ResourceLimitsConfig {
1107    cpu_count,
1108    max_processes,
1109    max_open_fds,
1110    max_pipes,
1111    max_ptys,
1112    max_sockets,
1113    max_connections,
1114    max_socket_buffered_bytes,
1115    max_socket_datagram_queue_len,
1116    max_filesystem_bytes,
1117    max_inode_count,
1118    max_blocking_read_ms,
1119    max_pread_bytes,
1120    max_fd_write_bytes,
1121    max_process_argv_bytes,
1122    max_process_env_bytes,
1123    max_readdir_entries,
1124    max_recursive_fs_depth,
1125    max_recursive_fs_entries,
1126    max_wasm_fuel,
1127    max_wasm_memory_bytes,
1128    max_wasm_stack_bytes,
1129});
1130
1131limits_struct!(ReactorLimitsConfig {
1132    max_capabilities,
1133    max_ready_handles,
1134    max_tasks,
1135    work_quantum,
1136    byte_quantum,
1137    max_handle_commands,
1138    max_handle_command_bytes,
1139    max_bridge_calls,
1140    max_bridge_request_bytes,
1141    max_bridge_response_bytes,
1142    max_async_completions,
1143    max_async_completion_bytes,
1144    max_blocking_jobs,
1145    max_blocking_bytes,
1146    per_handle_operation_quantum,
1147    accept_quantum,
1148    datagram_quantum,
1149    completion_quantum,
1150    signal_quantum,
1151    shutdown_deadline_ms,
1152    operation_deadline_ms,
1153});
1154
1155limits_struct!(HttpLimitsConfig {
1156    max_fetch_response_bytes,
1157});
1158
1159limits_struct!(UdpLimitsConfig {
1160    max_buffered_datagrams,
1161    max_buffered_bytes,
1162});
1163
1164limits_struct!(TlsLimitsConfig { max_buffered_bytes });
1165
1166limits_struct!(Http2LimitsConfig {
1167    max_connections,
1168    max_streams,
1169    max_streams_per_connection,
1170    max_buffered_bytes,
1171    max_header_bytes,
1172    max_data_bytes,
1173    max_pending_commands,
1174    max_pending_command_bytes,
1175    max_pending_events,
1176    max_pending_event_bytes,
1177});
1178
1179limits_struct!(BindingLimitsConfig {
1180    default_binding_timeout_ms,
1181    max_binding_timeout_ms,
1182    max_registered_collections,
1183    max_registered_bindings_per_vm,
1184    max_bindings_per_collection,
1185    max_binding_schema_bytes,
1186    max_examples_per_binding,
1187    max_binding_example_input_bytes,
1188});
1189
1190limits_struct!(PluginLimitsConfig {
1191    max_persisted_manifest_bytes,
1192    max_persisted_manifest_file_bytes,
1193});
1194
1195limits_struct!(AcpLimitsConfig {
1196    max_read_line_bytes,
1197    stdout_buffer_byte_limit,
1198    max_completed_message_bytes,
1199    max_turn_output_bytes,
1200    max_prompt_bytes,
1201    max_prompt_blocks,
1202    max_fallback_continuation_bytes,
1203    max_session_history_bytes,
1204    max_session_history_events,
1205    max_history_page_entries,
1206    max_session_list_entries,
1207    max_sessions_per_vm,
1208    max_prompts_per_session,
1209    max_prompts_per_vm,
1210    max_pending_permissions_per_session,
1211    max_pending_permissions_per_vm,
1212    max_permission_outcomes_per_session,
1213    max_permission_outcomes_per_vm,
1214});
1215
1216limits_struct!(SqliteLimitsConfig { max_result_bytes });
1217
1218limits_struct!(JsRuntimeLimitsConfig {
1219    v8_heap_limit_mb,
1220    sync_rpc_wait_timeout_ms,
1221    cpu_time_limit_ms,
1222    wall_clock_limit_ms,
1223    import_cache_materialize_timeout_ms,
1224    captured_output_limit_bytes,
1225    stdin_buffer_limit_bytes,
1226    event_payload_limit_bytes,
1227    max_timers,
1228    v8_ipc_max_frame_bytes,
1229});
1230
1231limits_struct!(PythonLimitsConfig {
1232    output_buffer_max_bytes,
1233    execution_timeout_ms,
1234    max_old_space_mb,
1235    vfs_rpc_timeout_ms,
1236});
1237
1238limits_struct!(WasmLimitsConfig {
1239    max_module_file_bytes,
1240    captured_output_limit_bytes,
1241    sync_read_limit_bytes,
1242    prewarm_timeout_ms,
1243    runner_heap_limit_mb,
1244    runner_cpu_time_limit_ms,
1245});
1246
1247limits_struct!(ProcessLimitsConfig {
1248    max_spawn_file_actions,
1249    max_spawn_file_action_bytes,
1250    pending_stdin_bytes,
1251    pending_event_count,
1252    pending_event_bytes,
1253});
1254
1255#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
1256#[serde(rename_all = "camelCase", deny_unknown_fields)]
1257#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
1258pub struct VmDnsConfig {
1259    #[serde(default, rename = "nameServers", skip_serializing_if = "Vec::is_empty")]
1260    pub name_servers: Vec<String>,
1261    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1262    pub overrides: BTreeMap<String, Vec<String>>,
1263}
1264
1265impl VmDnsConfig {
1266    fn validate(&self) -> Result<(), VmConfigError> {
1267        for entry in &self.name_servers {
1268            if entry.trim().is_empty() {
1269                return Err(VmConfigError::new(
1270                    "dns.nameServers entries must not be empty",
1271                ));
1272            }
1273        }
1274        for (host, addresses) in &self.overrides {
1275            if host.trim().is_empty() {
1276                return Err(VmConfigError::new("dns.overrides keys must not be empty"));
1277            }
1278            if addresses.is_empty() {
1279                return Err(VmConfigError::new(format!(
1280                    "dns.overrides.{host} must contain at least one address"
1281                )));
1282            }
1283        }
1284        Ok(())
1285    }
1286}
1287
1288#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
1289#[serde(rename_all = "camelCase", deny_unknown_fields)]
1290#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
1291pub struct VmListenPolicyConfig {
1292    #[serde(default, rename = "portMin", skip_serializing_if = "Option::is_none")]
1293    #[ts(optional)]
1294    pub port_min: Option<u16>,
1295    #[serde(default, rename = "portMax", skip_serializing_if = "Option::is_none")]
1296    #[ts(optional)]
1297    pub port_max: Option<u16>,
1298    #[serde(
1299        default,
1300        rename = "allowPrivileged",
1301        skip_serializing_if = "Option::is_none"
1302    )]
1303    #[ts(optional)]
1304    pub allow_privileged: Option<bool>,
1305}
1306
1307impl VmListenPolicyConfig {
1308    fn validate(&self) -> Result<(), VmConfigError> {
1309        if self.port_min == Some(0) {
1310            return Err(VmConfigError::new(
1311                "listen.portMin must be between 1 and 65535",
1312            ));
1313        }
1314        if self.port_max == Some(0) {
1315            return Err(VmConfigError::new(
1316                "listen.portMax must be between 1 and 65535",
1317            ));
1318        }
1319        if let (Some(min), Some(max)) = (self.port_min, self.port_max) {
1320            if min > max {
1321                return Err(VmConfigError::new(
1322                    "listen.portMin must be <= listen.portMax",
1323                ));
1324            }
1325        }
1326        Ok(())
1327    }
1328}
1329
1330#[derive(Debug, Clone, PartialEq, Eq)]
1331pub struct VmConfigError {
1332    message: String,
1333}
1334
1335impl VmConfigError {
1336    pub fn new(message: impl Into<String>) -> Self {
1337        Self {
1338            message: message.into(),
1339        }
1340    }
1341}
1342
1343impl std::fmt::Display for VmConfigError {
1344    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1345        f.write_str(&self.message)
1346    }
1347}
1348
1349impl std::error::Error for VmConfigError {}
1350
1351fn validate_guest_path(label: &str, path: &str) -> Result<(), VmConfigError> {
1352    if !path.starts_with('/') {
1353        return Err(VmConfigError::new(format!("{label} must be absolute")));
1354    }
1355    if path.split('/').any(|part| part == "..") {
1356        return Err(VmConfigError::new(format!("{label} must not contain '..'")));
1357    }
1358    Ok(())
1359}
1360
1361fn validate_command_names(label: &str, commands: &[String]) -> Result<(), VmConfigError> {
1362    for command in commands {
1363        if command.is_empty()
1364            || command == "."
1365            || command == ".."
1366            || command.contains('/')
1367            || command.contains('\0')
1368        {
1369            return Err(VmConfigError::new(format!(
1370                "{label} contains invalid command name {command:?}"
1371            )));
1372        }
1373    }
1374
1375    Ok(())
1376}
1377
1378#[cfg(test)]
1379mod tests {
1380    use super::*;
1381
1382    #[test]
1383    fn default_config_round_trips() {
1384        let config = CreateVmConfig::default();
1385        let json = serde_json::to_string(&config).expect("serialize config");
1386        let decoded: CreateVmConfig = serde_json::from_str(&json).expect("decode config");
1387        assert_eq!(decoded, config);
1388    }
1389
1390    #[test]
1391    fn unknown_fields_are_rejected() {
1392        let error =
1393            serde_json::from_str::<CreateVmConfig>(r#"{"rootFilesystem":{},"surprise":true}"#)
1394                .expect_err("unknown fields should fail");
1395        assert!(error.to_string().contains("unknown field"));
1396    }
1397
1398    #[test]
1399    fn root_user_config_round_trips_and_validates() {
1400        let config = CreateVmConfig {
1401            user: Some(VmUserConfig {
1402                uid: Some(0),
1403                gid: Some(0),
1404                username: Some(String::from("root")),
1405                homedir: Some(String::from("/root")),
1406                shell: Some(String::from("/bin/sh")),
1407                supplementary_gids: Some(vec![0, 10]),
1408                ..VmUserConfig::default()
1409            }),
1410            ..CreateVmConfig::default()
1411        };
1412        config.validate(usize::MAX).expect("valid root account");
1413        let json = serde_json::to_string(&config).expect("serialize root user");
1414        let decoded: CreateVmConfig = serde_json::from_str(&json).expect("decode root user");
1415        assert_eq!(decoded, config);
1416    }
1417
1418    #[test]
1419    fn user_config_rejects_unbounded_groups_and_invalid_records() {
1420        let too_many_groups = CreateVmConfig {
1421            user: Some(VmUserConfig {
1422                supplementary_gids: Some((0..65).collect()),
1423                ..VmUserConfig::default()
1424            }),
1425            ..CreateVmConfig::default()
1426        };
1427        assert!(too_many_groups.validate(usize::MAX).is_err());
1428
1429        let invalid_name = CreateVmConfig {
1430            user: Some(VmUserConfig {
1431                username: Some(String::from("bad:name")),
1432                ..VmUserConfig::default()
1433            }),
1434            ..CreateVmConfig::default()
1435        };
1436        assert!(invalid_name.validate(usize::MAX).is_err());
1437    }
1438
1439    #[test]
1440    fn validate_rejects_fetch_limit_above_frame_cap() {
1441        let config = CreateVmConfig {
1442            limits: Some(VmLimitsConfig {
1443                http: Some(HttpLimitsConfig {
1444                    max_fetch_response_bytes: Some(2048),
1445                }),
1446                ..VmLimitsConfig::default()
1447            }),
1448            ..CreateVmConfig::default()
1449        };
1450        assert!(config.validate(1024).is_err());
1451    }
1452
1453    #[test]
1454    fn canonical_reactor_and_protocol_limits_round_trip() {
1455        let config: CreateVmConfig = serde_json::from_value(serde_json::json!({
1456            "limits": {
1457                "resources": {
1458                    "maxConnections": 16,
1459                    "maxSocketBufferedBytes": 8192,
1460                    "maxSocketDatagramQueueLen": 128
1461                },
1462                "reactor": {
1463                    "maxCapabilities": 128,
1464                    "maxReadyHandles": 256,
1465                    "maxTasks": 512,
1466                    "workQuantum": 32,
1467                    "byteQuantum": 4096,
1468                    "maxHandleCommands": 64,
1469                    "maxHandleCommandBytes": 1024,
1470                    "maxBridgeCalls": 32,
1471                    "maxBridgeResponseBytes": 4096,
1472                    "maxAsyncCompletions": 64,
1473                    "maxAsyncCompletionBytes": 2048,
1474                    "maxBlockingJobs": 32,
1475                    "maxBlockingBytes": 4096,
1476                    "perHandleOperationQuantum": 8,
1477                    "acceptQuantum": 16,
1478                    "datagramQuantum": 8,
1479                    "completionQuantum": 16,
1480                    "signalQuantum": 16,
1481                    "shutdownDeadlineMs": 5000,
1482                    "operationDeadlineMs": 30000
1483                },
1484                "http": { "maxFetchResponseBytes": 4096 },
1485                "udp": {
1486                    "maxBufferedDatagrams": 64,
1487                    "maxBufferedBytes": 4096
1488                },
1489                "tls": { "maxBufferedBytes": 2048 },
1490                "http2": {
1491                    "maxConnections": 8,
1492                    "maxStreams": 64,
1493                    "maxStreamsPerConnection": 16,
1494                    "maxBufferedBytes": 8192,
1495                    "maxHeaderBytes": 1024,
1496                    "maxDataBytes": 4096,
1497                    "maxPendingCommands": 32,
1498                    "maxPendingCommandBytes": 1024,
1499                    "maxPendingEvents": 32,
1500                    "maxPendingEventBytes": 2048
1501                }
1502            }
1503        }))
1504        .expect("decode canonical limits");
1505        config.validate(16 * 1024).expect("valid relationships");
1506
1507        let json = serde_json::to_string(&config).expect("serialize canonical limits");
1508        let decoded: CreateVmConfig = serde_json::from_str(&json).expect("decode round trip");
1509        assert_eq!(decoded, config);
1510        assert!(json.contains("maxHandleCommandBytes"));
1511        assert!(json.contains("maxBufferedDatagrams"));
1512        assert!(json.contains("maxStreamsPerConnection"));
1513        assert!(json.contains("shutdownDeadlineMs"));
1514    }
1515
1516    #[test]
1517    fn canonical_limits_reject_zero_and_invalid_parent_relationships() {
1518        let cases = [
1519            (
1520                serde_json::json!({
1521                    "reactor": { "maxHandleCommands": 0 }
1522                }),
1523                "limits.reactor.maxHandleCommands",
1524            ),
1525            (
1526                serde_json::json!({
1527                    "reactor": { "maxBlockingJobs": 0 }
1528                }),
1529                "limits.reactor.maxBlockingJobs",
1530            ),
1531            (
1532                serde_json::json!({
1533                    "udp": { "maxBufferedBytes": 0 }
1534                }),
1535                "limits.udp.maxBufferedBytes",
1536            ),
1537            (
1538                serde_json::json!({
1539                    "reactor": { "maxCapabilities": 8, "maxReadyHandles": 4 }
1540                }),
1541                "limits.reactor.maxCapabilities",
1542            ),
1543            (
1544                serde_json::json!({
1545                    "resources": { "maxSocketBufferedBytes": 1024 },
1546                    "tls": { "maxBufferedBytes": 2048 }
1547                }),
1548                "limits.tls.maxBufferedBytes",
1549            ),
1550            (
1551                serde_json::json!({
1552                    "http2": {
1553                        "maxBufferedBytes": 1024,
1554                        "maxPendingEventBytes": 2048
1555                    }
1556                }),
1557                "limits.http2.maxPendingEventBytes",
1558            ),
1559            (
1560                serde_json::json!({
1561                    "resources": { "maxSocketDatagramQueueLen": 16 },
1562                    "udp": { "maxBufferedDatagrams": 32 }
1563                }),
1564                "limits.udp.maxBufferedDatagrams",
1565            ),
1566        ];
1567
1568        for (limits, expected_path) in cases {
1569            let config: CreateVmConfig = serde_json::from_value(serde_json::json!({
1570                "limits": limits
1571            }))
1572            .expect("decode invalid relationship fixture");
1573            let error = config
1574                .validate(16 * 1024)
1575                .expect_err("invalid relationship must fail");
1576            assert!(
1577                error.to_string().contains(expected_path),
1578                "expected {expected_path} in {error}"
1579            );
1580        }
1581    }
1582
1583    fn js_runtime_config(value: serde_json::Value) -> Result<CreateVmConfig, serde_json::Error> {
1584        serde_json::from_value(serde_json::json!({ "jsRuntime": value }))
1585    }
1586
1587    #[test]
1588    fn js_runtime_defaults_to_node() {
1589        let config: CreateVmConfig =
1590            serde_json::from_value(serde_json::json!({ "jsRuntime": {} })).expect("decode");
1591        let js = config.js_runtime.expect("jsRuntime present");
1592        assert_eq!(js.platform, JsRuntimePlatform::Node);
1593        assert_eq!(js.module_resolution, JsModuleResolution::Node);
1594        assert!(js.allowed_builtins.is_none());
1595        assert!(js.high_resolution_time.is_none());
1596    }
1597
1598    #[test]
1599    fn js_runtime_high_resolution_time_defaults_off_and_round_trips() {
1600        let defaulted = js_runtime_config(serde_json::json!({})).unwrap();
1601        assert!(defaulted.js_runtime.unwrap().high_resolution_time.is_none());
1602
1603        let enabled = js_runtime_config(serde_json::json!({
1604            "highResolutionTime": true,
1605        }))
1606        .unwrap();
1607        assert_eq!(
1608            enabled.js_runtime.as_ref().unwrap().high_resolution_time,
1609            Some(true)
1610        );
1611        let json = serde_json::to_string(&enabled).expect("serialize");
1612        assert!(json.contains("highResolutionTime"));
1613        let decoded: CreateVmConfig = serde_json::from_str(&json).expect("re-decode");
1614        assert_eq!(decoded, enabled);
1615    }
1616
1617    #[test]
1618    fn js_runtime_all_platform_resolution_combos_round_trip() {
1619        for platform in ["node", "browser", "neutral", "bare"] {
1620            for resolution in ["node", "relative", "none"] {
1621                let config = js_runtime_config(serde_json::json!({
1622                    "platform": platform,
1623                    "moduleResolution": resolution,
1624                }))
1625                .unwrap_or_else(|err| panic!("decode {platform}/{resolution}: {err}"));
1626                let json = serde_json::to_string(&config).expect("serialize");
1627                let decoded: CreateVmConfig = serde_json::from_str(&json).expect("re-decode");
1628                assert_eq!(decoded, config);
1629                assert!(config.validate(usize::MAX).is_ok());
1630            }
1631        }
1632    }
1633
1634    #[test]
1635    fn js_runtime_allowed_builtins_tri_state() {
1636        // None => omitted.
1637        let none = js_runtime_config(serde_json::json!({ "platform": "node" })).unwrap();
1638        assert!(none.js_runtime.unwrap().allowed_builtins.is_none());
1639        // Some([]) => deny all (representable, distinct from None).
1640        let empty = js_runtime_config(serde_json::json!({ "allowedBuiltins": [] })).unwrap();
1641        assert_eq!(empty.js_runtime.unwrap().allowed_builtins, Some(Vec::new()));
1642        // Some([..]) => explicit.
1643        let some = js_runtime_config(serde_json::json!({ "allowedBuiltins": ["path", "node:fs"] }))
1644            .unwrap();
1645        assert_eq!(
1646            some.js_runtime.unwrap().allowed_builtins,
1647            Some(vec!["path".to_owned(), "node:fs".to_owned()])
1648        );
1649    }
1650
1651    #[test]
1652    fn js_runtime_rejects_allowed_builtins_under_non_node_platform() {
1653        for platform in ["browser", "neutral", "bare"] {
1654            let config = js_runtime_config(serde_json::json!({
1655                "platform": platform,
1656                "allowedBuiltins": ["path"],
1657            }))
1658            .unwrap();
1659            let error = config
1660                .validate(usize::MAX)
1661                .expect_err("allowedBuiltins under non-node must reject");
1662            assert!(error.to_string().contains("allowedBuiltins"));
1663        }
1664    }
1665
1666    #[test]
1667    fn js_runtime_rejects_unknown_builtin_names() {
1668        let config = js_runtime_config(serde_json::json!({
1669            "platform": "node",
1670            "allowedBuiltins": ["path", "totally_not_a_builtin"],
1671        }))
1672        .unwrap();
1673        let error = config
1674            .validate(usize::MAX)
1675            .expect_err("unknown builtin must reject");
1676        assert!(error.to_string().contains("unknown builtin"));
1677    }
1678
1679    #[test]
1680    fn js_runtime_accepts_empty_allow_list_under_node() {
1681        let config =
1682            js_runtime_config(serde_json::json!({ "platform": "node", "allowedBuiltins": [] }))
1683                .unwrap();
1684        assert!(config.validate(usize::MAX).is_ok());
1685    }
1686
1687    #[test]
1688    fn js_runtime_rejects_unknown_fields() {
1689        let error = js_runtime_config(serde_json::json!({ "surprise": true }))
1690            .expect_err("unknown jsRuntime field should fail");
1691        assert!(error.to_string().contains("unknown field"));
1692    }
1693}