1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use ts_rs::TS;
5
6#[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#[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 ActorUds { path: String },
113 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#[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#[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 #[serde(default)]
334 pub platform: JsRuntimePlatform,
335 #[serde(default, rename = "moduleResolution")]
338 pub module_resolution: JsModuleResolution,
339 #[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 #[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 #[default]
388 Node,
389 Browser,
391 Neutral,
394 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 #[default]
405 Node,
406 Relative,
408 None,
410}
411
412const 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 execution: Option<ExecutionLimitsConfig>,
797 #[serde(default, skip_serializing_if = "Option::is_none")]
798 #[ts(optional)]
799 pub process: Option<ProcessLimitsConfig>,
800}
801
802impl VmLimitsConfig {
803 fn validate(&self, max_frame_bytes: usize) -> Result<(), VmConfigError> {
804 if let Some(reactor) = &self.reactor {
805 validate_nonzero_options([
806 ("limits.reactor.maxCapabilities", reactor.max_capabilities),
807 ("limits.reactor.maxReadyHandles", reactor.max_ready_handles),
808 ("limits.reactor.maxTasks", reactor.max_tasks),
809 ("limits.reactor.workQuantum", reactor.work_quantum),
810 ("limits.reactor.byteQuantum", reactor.byte_quantum),
811 (
812 "limits.reactor.maxHandleCommands",
813 reactor.max_handle_commands,
814 ),
815 (
816 "limits.reactor.maxHandleCommandBytes",
817 reactor.max_handle_command_bytes,
818 ),
819 ("limits.reactor.maxBridgeCalls", reactor.max_bridge_calls),
820 (
821 "limits.reactor.maxBridgeRequestBytes",
822 reactor.max_bridge_request_bytes,
823 ),
824 (
825 "limits.reactor.maxBridgeResponseBytes",
826 reactor.max_bridge_response_bytes,
827 ),
828 (
829 "limits.reactor.maxAsyncCompletions",
830 reactor.max_async_completions,
831 ),
832 (
833 "limits.reactor.maxAsyncCompletionBytes",
834 reactor.max_async_completion_bytes,
835 ),
836 ("limits.reactor.maxBlockingJobs", reactor.max_blocking_jobs),
837 (
838 "limits.reactor.maxBlockingBytes",
839 reactor.max_blocking_bytes,
840 ),
841 (
842 "limits.reactor.perHandleOperationQuantum",
843 reactor.per_handle_operation_quantum,
844 ),
845 ("limits.reactor.acceptQuantum", reactor.accept_quantum),
846 ("limits.reactor.datagramQuantum", reactor.datagram_quantum),
847 (
848 "limits.reactor.completionQuantum",
849 reactor.completion_quantum,
850 ),
851 ("limits.reactor.signalQuantum", reactor.signal_quantum),
852 (
853 "limits.reactor.shutdownDeadlineMs",
854 reactor.shutdown_deadline_ms,
855 ),
856 (
857 "limits.reactor.operationDeadlineMs",
858 reactor.operation_deadline_ms,
859 ),
860 ])?;
861 validate_optional_parent(
862 "limits.reactor.maxCapabilities",
863 reactor.max_capabilities,
864 "limits.reactor.maxReadyHandles",
865 reactor.max_ready_handles,
866 )?;
867 validate_optional_parent(
868 "limits.reactor.perHandleOperationQuantum",
869 reactor.per_handle_operation_quantum,
870 "limits.reactor.maxHandleCommands",
871 reactor.max_handle_commands,
872 )?;
873 validate_optional_parent(
874 "limits.reactor.acceptQuantum",
875 reactor.accept_quantum,
876 "limits.reactor.maxCapabilities",
877 reactor.max_capabilities,
878 )?;
879 validate_optional_parent(
880 "limits.reactor.completionQuantum",
881 reactor.completion_quantum,
882 "limits.reactor.maxAsyncCompletions",
883 reactor.max_async_completions,
884 )?;
885 if let Some(max_bridge_request_bytes) = reactor.max_bridge_request_bytes {
886 if max_bridge_request_bytes > max_frame_bytes as u64 {
887 return Err(VmConfigError::new(format!(
888 "limits.reactor.maxBridgeRequestBytes ({max_bridge_request_bytes}) must \
889 be <= the sidecar wire frame cap ({max_frame_bytes})"
890 )));
891 }
892 }
893 if let Some(max_bridge_response_bytes) = reactor.max_bridge_response_bytes {
894 if max_bridge_response_bytes > max_frame_bytes as u64 {
895 return Err(VmConfigError::new(format!(
896 "limits.reactor.maxBridgeResponseBytes ({max_bridge_response_bytes}) must \
897 be <= the sidecar wire frame cap ({max_frame_bytes})"
898 )));
899 }
900 }
901 }
902 if let Some(http) = &self.http {
903 if let Some(max_fetch_response_bytes) = http.max_fetch_response_bytes {
904 if max_fetch_response_bytes == 0 {
905 return Err(VmConfigError::new(
906 "limits.http.maxFetchResponseBytes must be greater than zero",
907 ));
908 }
909 if max_fetch_response_bytes as usize > max_frame_bytes {
910 return Err(VmConfigError::new(format!(
911 "limits.http.maxFetchResponseBytes ({max_fetch_response_bytes}) must be <= the sidecar wire frame cap ({max_frame_bytes})"
912 )));
913 }
914 }
915 }
916 if let Some(udp) = &self.udp {
917 validate_nonzero_options([
918 (
919 "limits.udp.maxBufferedDatagrams",
920 udp.max_buffered_datagrams,
921 ),
922 ("limits.udp.maxBufferedBytes", udp.max_buffered_bytes),
923 ])?;
924 }
925 if let Some(tls) = &self.tls {
926 validate_nonzero_options([("limits.tls.maxBufferedBytes", tls.max_buffered_bytes)])?;
927 }
928 if let Some(http2) = &self.http2 {
929 validate_nonzero_options([
930 ("limits.http2.maxConnections", http2.max_connections),
931 ("limits.http2.maxStreams", http2.max_streams),
932 (
933 "limits.http2.maxStreamsPerConnection",
934 http2.max_streams_per_connection,
935 ),
936 ("limits.http2.maxBufferedBytes", http2.max_buffered_bytes),
937 ("limits.http2.maxHeaderBytes", http2.max_header_bytes),
938 ("limits.http2.maxDataBytes", http2.max_data_bytes),
939 (
940 "limits.http2.maxPendingCommands",
941 http2.max_pending_commands,
942 ),
943 (
944 "limits.http2.maxPendingCommandBytes",
945 http2.max_pending_command_bytes,
946 ),
947 ("limits.http2.maxPendingEvents", http2.max_pending_events),
948 (
949 "limits.http2.maxPendingEventBytes",
950 http2.max_pending_event_bytes,
951 ),
952 ])?;
953 validate_optional_parent(
954 "limits.http2.maxStreamsPerConnection",
955 http2.max_streams_per_connection,
956 "limits.http2.maxStreams",
957 http2.max_streams,
958 )?;
959 for (path, value) in [
960 ("limits.http2.maxHeaderBytes", http2.max_header_bytes),
961 ("limits.http2.maxDataBytes", http2.max_data_bytes),
962 (
963 "limits.http2.maxPendingCommandBytes",
964 http2.max_pending_command_bytes,
965 ),
966 (
967 "limits.http2.maxPendingEventBytes",
968 http2.max_pending_event_bytes,
969 ),
970 ] {
971 validate_optional_parent(
972 path,
973 value,
974 "limits.http2.maxBufferedBytes",
975 http2.max_buffered_bytes,
976 )?;
977 }
978 }
979 if let Some(resources) = &self.resources {
980 let aggregate_socket_bytes = resources.max_socket_buffered_bytes;
981 for (path, value) in [
982 (
983 "limits.reactor.maxHandleCommandBytes",
984 self.reactor
985 .as_ref()
986 .and_then(|limits| limits.max_handle_command_bytes),
987 ),
988 (
989 "limits.http.maxFetchResponseBytes",
990 self.http
991 .as_ref()
992 .and_then(|limits| limits.max_fetch_response_bytes),
993 ),
994 (
995 "limits.udp.maxBufferedBytes",
996 self.udp
997 .as_ref()
998 .and_then(|limits| limits.max_buffered_bytes),
999 ),
1000 (
1001 "limits.tls.maxBufferedBytes",
1002 self.tls
1003 .as_ref()
1004 .and_then(|limits| limits.max_buffered_bytes),
1005 ),
1006 (
1007 "limits.http2.maxBufferedBytes",
1008 self.http2
1009 .as_ref()
1010 .and_then(|limits| limits.max_buffered_bytes),
1011 ),
1012 ] {
1013 validate_optional_parent(
1014 path,
1015 value,
1016 "limits.resources.maxSocketBufferedBytes",
1017 aggregate_socket_bytes,
1018 )?;
1019 }
1020 validate_optional_parent(
1021 "limits.udp.maxBufferedDatagrams",
1022 self.udp
1023 .as_ref()
1024 .and_then(|limits| limits.max_buffered_datagrams),
1025 "limits.resources.maxSocketDatagramQueueLen",
1026 resources.max_socket_datagram_queue_len,
1027 )?;
1028 validate_optional_parent(
1029 "limits.http2.maxConnections",
1030 self.http2
1031 .as_ref()
1032 .and_then(|limits| limits.max_connections),
1033 "limits.resources.maxConnections",
1034 resources.max_connections,
1035 )?;
1036 }
1037 if let (Some(reactor), Some(udp)) = (&self.reactor, &self.udp) {
1038 validate_optional_parent(
1039 "limits.reactor.datagramQuantum",
1040 reactor.datagram_quantum,
1041 "limits.udp.maxBufferedDatagrams",
1042 udp.max_buffered_datagrams,
1043 )?;
1044 }
1045 if let Some(bindings) = &self.bindings {
1046 if let (Some(default), Some(max)) = (
1047 bindings.default_binding_timeout_ms,
1048 bindings.max_binding_timeout_ms,
1049 ) {
1050 if default > max {
1051 return Err(VmConfigError::new(
1052 "limits.bindings.defaultBindingTimeoutMs must be <= limits.bindings.maxBindingTimeoutMs",
1053 ));
1054 }
1055 }
1056 }
1057 if let Some(js_runtime) = &self.js_runtime {
1058 validate_nonzero_options([("limits.jsRuntime.maxTimers", js_runtime.max_timers)])?;
1059 }
1060 if let Some(execution) = &self.execution {
1061 validate_nonzero_options([
1062 (
1063 "limits.execution.completedTtlMs",
1064 execution.completed_ttl_ms,
1065 ),
1066 (
1067 "limits.execution.maxCompletedExecutions",
1068 execution.max_completed_executions,
1069 ),
1070 (
1071 "limits.execution.liveExecutionWarningThreshold",
1072 execution.live_execution_warning_threshold,
1073 ),
1074 ])?;
1075 const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
1076 for (path, value) in [
1077 (
1078 "limits.execution.completedTtlMs",
1079 execution.completed_ttl_ms,
1080 ),
1081 (
1082 "limits.execution.maxCompletedExecutions",
1083 execution.max_completed_executions,
1084 ),
1085 (
1086 "limits.execution.liveExecutionWarningThreshold",
1087 execution.live_execution_warning_threshold,
1088 ),
1089 ] {
1090 if value.is_some_and(|value| value > MAX_SAFE_INTEGER) {
1091 return Err(VmConfigError::new(format!("{path} must be a safe integer")));
1092 }
1093 }
1094 }
1095 Ok(())
1096 }
1097}
1098
1099fn validate_nonzero_options<const N: usize>(
1100 values: [(&str, Option<u64>); N],
1101) -> Result<(), VmConfigError> {
1102 for (path, value) in values {
1103 if value == Some(0) {
1104 return Err(VmConfigError::new(format!(
1105 "{path} must be greater than zero"
1106 )));
1107 }
1108 }
1109 Ok(())
1110}
1111
1112fn validate_optional_parent(
1113 child_path: &str,
1114 child: Option<u64>,
1115 parent_path: &str,
1116 parent: Option<u64>,
1117) -> Result<(), VmConfigError> {
1118 if let (Some(child), Some(parent)) = (child, parent) {
1119 if child > parent {
1120 return Err(VmConfigError::new(format!(
1121 "{child_path} ({child}) must be <= {parent_path} ({parent})"
1122 )));
1123 }
1124 }
1125 Ok(())
1126}
1127
1128macro_rules! limits_struct {
1129 ($name:ident { $($field:ident),* $(,)? }) => {
1130 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
1131 #[serde(rename_all = "camelCase", deny_unknown_fields)]
1132 #[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
1133 pub struct $name {
1134 $(
1135 #[serde(default, skip_serializing_if = "Option::is_none")]
1136 #[ts(optional)]
1137 #[ts(type = "number")]
1138 pub $field: Option<u64>,
1139 )*
1140 }
1141 };
1142}
1143
1144limits_struct!(ResourceLimitsConfig {
1145 cpu_count,
1146 max_processes,
1147 max_open_fds,
1148 max_pipes,
1149 max_ptys,
1150 max_sockets,
1151 max_connections,
1152 max_socket_buffered_bytes,
1153 max_socket_datagram_queue_len,
1154 max_filesystem_bytes,
1155 max_inode_count,
1156 max_blocking_read_ms,
1157 max_pread_bytes,
1158 max_fd_write_bytes,
1159 max_process_argv_bytes,
1160 max_process_env_bytes,
1161 max_readdir_entries,
1162 max_recursive_fs_depth,
1163 max_recursive_fs_entries,
1164 max_wasm_fuel,
1165 max_wasm_memory_bytes,
1166 max_wasm_stack_bytes,
1167});
1168
1169limits_struct!(ReactorLimitsConfig {
1170 max_capabilities,
1171 max_ready_handles,
1172 max_tasks,
1173 work_quantum,
1174 byte_quantum,
1175 max_handle_commands,
1176 max_handle_command_bytes,
1177 max_bridge_calls,
1178 max_bridge_request_bytes,
1179 max_bridge_response_bytes,
1180 max_async_completions,
1181 max_async_completion_bytes,
1182 max_blocking_jobs,
1183 max_blocking_bytes,
1184 per_handle_operation_quantum,
1185 accept_quantum,
1186 datagram_quantum,
1187 completion_quantum,
1188 signal_quantum,
1189 shutdown_deadline_ms,
1190 operation_deadline_ms,
1191});
1192
1193limits_struct!(HttpLimitsConfig {
1194 max_fetch_response_bytes,
1195});
1196
1197limits_struct!(UdpLimitsConfig {
1198 max_buffered_datagrams,
1199 max_buffered_bytes,
1200});
1201
1202limits_struct!(TlsLimitsConfig { max_buffered_bytes });
1203
1204limits_struct!(Http2LimitsConfig {
1205 max_connections,
1206 max_streams,
1207 max_streams_per_connection,
1208 max_buffered_bytes,
1209 max_header_bytes,
1210 max_data_bytes,
1211 max_pending_commands,
1212 max_pending_command_bytes,
1213 max_pending_events,
1214 max_pending_event_bytes,
1215});
1216
1217limits_struct!(BindingLimitsConfig {
1218 default_binding_timeout_ms,
1219 max_binding_timeout_ms,
1220 max_registered_collections,
1221 max_registered_bindings_per_vm,
1222 max_bindings_per_collection,
1223 max_binding_schema_bytes,
1224 max_examples_per_binding,
1225 max_binding_example_input_bytes,
1226});
1227
1228limits_struct!(PluginLimitsConfig {
1229 max_persisted_manifest_bytes,
1230 max_persisted_manifest_file_bytes,
1231});
1232
1233limits_struct!(AcpLimitsConfig {
1234 max_read_line_bytes,
1235 stdout_buffer_byte_limit,
1236 max_completed_message_bytes,
1237 max_turn_output_bytes,
1238 max_prompt_bytes,
1239 max_prompt_blocks,
1240 max_fallback_continuation_bytes,
1241 max_session_history_bytes,
1242 max_session_history_events,
1243 max_history_page_entries,
1244 max_session_list_entries,
1245 max_sessions_per_vm,
1246 max_prompts_per_session,
1247 max_prompts_per_vm,
1248 max_pending_permissions_per_session,
1249 max_pending_permissions_per_vm,
1250 max_permission_outcomes_per_session,
1251 max_permission_outcomes_per_vm,
1252});
1253
1254limits_struct!(SqliteLimitsConfig { max_result_bytes });
1255
1256limits_struct!(JsRuntimeLimitsConfig {
1257 v8_heap_limit_mb,
1258 sync_rpc_wait_timeout_ms,
1259 cpu_time_limit_ms,
1260 wall_clock_limit_ms,
1261 import_cache_materialize_timeout_ms,
1262 captured_output_limit_bytes,
1263 stdin_buffer_limit_bytes,
1264 event_payload_limit_bytes,
1265 max_timers,
1266 v8_ipc_max_frame_bytes,
1267});
1268
1269limits_struct!(PythonLimitsConfig {
1270 output_buffer_max_bytes,
1271 execution_timeout_ms,
1272 max_old_space_mb,
1273 vfs_rpc_timeout_ms,
1274});
1275
1276limits_struct!(WasmLimitsConfig {
1277 max_module_file_bytes,
1278 captured_output_limit_bytes,
1279 sync_read_limit_bytes,
1280 prewarm_timeout_ms,
1281 runner_heap_limit_mb,
1282 runner_cpu_time_limit_ms,
1283});
1284
1285limits_struct!(ExecutionLimitsConfig {
1286 completed_ttl_ms,
1287 max_completed_executions,
1288 live_execution_warning_threshold,
1289});
1290
1291limits_struct!(ProcessLimitsConfig {
1292 max_spawn_file_actions,
1293 max_spawn_file_action_bytes,
1294 pending_stdin_bytes,
1295 pending_event_count,
1296 pending_event_bytes,
1297});
1298
1299#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
1300#[serde(rename_all = "camelCase", deny_unknown_fields)]
1301#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
1302pub struct VmDnsConfig {
1303 #[serde(default, rename = "nameServers", skip_serializing_if = "Vec::is_empty")]
1304 pub name_servers: Vec<String>,
1305 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1306 pub overrides: BTreeMap<String, Vec<String>>,
1307}
1308
1309impl VmDnsConfig {
1310 fn validate(&self) -> Result<(), VmConfigError> {
1311 for entry in &self.name_servers {
1312 if entry.trim().is_empty() {
1313 return Err(VmConfigError::new(
1314 "dns.nameServers entries must not be empty",
1315 ));
1316 }
1317 }
1318 for (host, addresses) in &self.overrides {
1319 if host.trim().is_empty() {
1320 return Err(VmConfigError::new("dns.overrides keys must not be empty"));
1321 }
1322 if addresses.is_empty() {
1323 return Err(VmConfigError::new(format!(
1324 "dns.overrides.{host} must contain at least one address"
1325 )));
1326 }
1327 }
1328 Ok(())
1329 }
1330}
1331
1332#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
1333#[serde(rename_all = "camelCase", deny_unknown_fields)]
1334#[ts(export, export_to = "../../../packages/runtime-core/src/generated/")]
1335pub struct VmListenPolicyConfig {
1336 #[serde(default, rename = "portMin", skip_serializing_if = "Option::is_none")]
1337 #[ts(optional)]
1338 pub port_min: Option<u16>,
1339 #[serde(default, rename = "portMax", skip_serializing_if = "Option::is_none")]
1340 #[ts(optional)]
1341 pub port_max: Option<u16>,
1342 #[serde(
1343 default,
1344 rename = "allowPrivileged",
1345 skip_serializing_if = "Option::is_none"
1346 )]
1347 #[ts(optional)]
1348 pub allow_privileged: Option<bool>,
1349}
1350
1351impl VmListenPolicyConfig {
1352 fn validate(&self) -> Result<(), VmConfigError> {
1353 if self.port_min == Some(0) {
1354 return Err(VmConfigError::new(
1355 "listen.portMin must be between 1 and 65535",
1356 ));
1357 }
1358 if self.port_max == Some(0) {
1359 return Err(VmConfigError::new(
1360 "listen.portMax must be between 1 and 65535",
1361 ));
1362 }
1363 if let (Some(min), Some(max)) = (self.port_min, self.port_max) {
1364 if min > max {
1365 return Err(VmConfigError::new(
1366 "listen.portMin must be <= listen.portMax",
1367 ));
1368 }
1369 }
1370 Ok(())
1371 }
1372}
1373
1374#[derive(Debug, Clone, PartialEq, Eq)]
1375pub struct VmConfigError {
1376 message: String,
1377}
1378
1379impl VmConfigError {
1380 pub fn new(message: impl Into<String>) -> Self {
1381 Self {
1382 message: message.into(),
1383 }
1384 }
1385}
1386
1387impl std::fmt::Display for VmConfigError {
1388 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1389 f.write_str(&self.message)
1390 }
1391}
1392
1393impl std::error::Error for VmConfigError {}
1394
1395fn validate_guest_path(label: &str, path: &str) -> Result<(), VmConfigError> {
1396 if !path.starts_with('/') {
1397 return Err(VmConfigError::new(format!("{label} must be absolute")));
1398 }
1399 if path.split('/').any(|part| part == "..") {
1400 return Err(VmConfigError::new(format!("{label} must not contain '..'")));
1401 }
1402 Ok(())
1403}
1404
1405fn validate_command_names(label: &str, commands: &[String]) -> Result<(), VmConfigError> {
1406 for command in commands {
1407 if command.is_empty()
1408 || command == "."
1409 || command == ".."
1410 || command.contains('/')
1411 || command.contains('\0')
1412 {
1413 return Err(VmConfigError::new(format!(
1414 "{label} contains invalid command name {command:?}"
1415 )));
1416 }
1417 }
1418
1419 Ok(())
1420}
1421
1422#[cfg(test)]
1423mod tests {
1424 use super::*;
1425
1426 #[test]
1427 fn default_config_round_trips() {
1428 let config = CreateVmConfig::default();
1429 let json = serde_json::to_string(&config).expect("serialize config");
1430 let decoded: CreateVmConfig = serde_json::from_str(&json).expect("decode config");
1431 assert_eq!(decoded, config);
1432 }
1433
1434 #[test]
1435 fn unknown_fields_are_rejected() {
1436 let error =
1437 serde_json::from_str::<CreateVmConfig>(r#"{"rootFilesystem":{},"surprise":true}"#)
1438 .expect_err("unknown fields should fail");
1439 assert!(error.to_string().contains("unknown field"));
1440 }
1441
1442 #[test]
1443 fn root_user_config_round_trips_and_validates() {
1444 let config = CreateVmConfig {
1445 user: Some(VmUserConfig {
1446 uid: Some(0),
1447 gid: Some(0),
1448 username: Some(String::from("root")),
1449 homedir: Some(String::from("/root")),
1450 shell: Some(String::from("/bin/sh")),
1451 supplementary_gids: Some(vec![0, 10]),
1452 ..VmUserConfig::default()
1453 }),
1454 ..CreateVmConfig::default()
1455 };
1456 config.validate(usize::MAX).expect("valid root account");
1457 let json = serde_json::to_string(&config).expect("serialize root user");
1458 let decoded: CreateVmConfig = serde_json::from_str(&json).expect("decode root user");
1459 assert_eq!(decoded, config);
1460 }
1461
1462 #[test]
1463 fn user_config_rejects_unbounded_groups_and_invalid_records() {
1464 let too_many_groups = CreateVmConfig {
1465 user: Some(VmUserConfig {
1466 supplementary_gids: Some((0..65).collect()),
1467 ..VmUserConfig::default()
1468 }),
1469 ..CreateVmConfig::default()
1470 };
1471 assert!(too_many_groups.validate(usize::MAX).is_err());
1472
1473 let invalid_name = CreateVmConfig {
1474 user: Some(VmUserConfig {
1475 username: Some(String::from("bad:name")),
1476 ..VmUserConfig::default()
1477 }),
1478 ..CreateVmConfig::default()
1479 };
1480 assert!(invalid_name.validate(usize::MAX).is_err());
1481 }
1482
1483 #[test]
1484 fn validate_rejects_fetch_limit_above_frame_cap() {
1485 let config = CreateVmConfig {
1486 limits: Some(VmLimitsConfig {
1487 http: Some(HttpLimitsConfig {
1488 max_fetch_response_bytes: Some(2048),
1489 }),
1490 ..VmLimitsConfig::default()
1491 }),
1492 ..CreateVmConfig::default()
1493 };
1494 assert!(config.validate(1024).is_err());
1495 }
1496
1497 #[test]
1498 fn canonical_reactor_and_protocol_limits_round_trip() {
1499 let config: CreateVmConfig = serde_json::from_value(serde_json::json!({
1500 "limits": {
1501 "resources": {
1502 "maxConnections": 16,
1503 "maxSocketBufferedBytes": 8192,
1504 "maxSocketDatagramQueueLen": 128
1505 },
1506 "reactor": {
1507 "maxCapabilities": 128,
1508 "maxReadyHandles": 256,
1509 "maxTasks": 512,
1510 "workQuantum": 32,
1511 "byteQuantum": 4096,
1512 "maxHandleCommands": 64,
1513 "maxHandleCommandBytes": 1024,
1514 "maxBridgeCalls": 32,
1515 "maxBridgeResponseBytes": 4096,
1516 "maxAsyncCompletions": 64,
1517 "maxAsyncCompletionBytes": 2048,
1518 "maxBlockingJobs": 32,
1519 "maxBlockingBytes": 4096,
1520 "perHandleOperationQuantum": 8,
1521 "acceptQuantum": 16,
1522 "datagramQuantum": 8,
1523 "completionQuantum": 16,
1524 "signalQuantum": 16,
1525 "shutdownDeadlineMs": 5000,
1526 "operationDeadlineMs": 30000
1527 },
1528 "http": { "maxFetchResponseBytes": 4096 },
1529 "udp": {
1530 "maxBufferedDatagrams": 64,
1531 "maxBufferedBytes": 4096
1532 },
1533 "tls": { "maxBufferedBytes": 2048 },
1534 "http2": {
1535 "maxConnections": 8,
1536 "maxStreams": 64,
1537 "maxStreamsPerConnection": 16,
1538 "maxBufferedBytes": 8192,
1539 "maxHeaderBytes": 1024,
1540 "maxDataBytes": 4096,
1541 "maxPendingCommands": 32,
1542 "maxPendingCommandBytes": 1024,
1543 "maxPendingEvents": 32,
1544 "maxPendingEventBytes": 2048
1545 }
1546 }
1547 }))
1548 .expect("decode canonical limits");
1549 config.validate(16 * 1024).expect("valid relationships");
1550
1551 let json = serde_json::to_string(&config).expect("serialize canonical limits");
1552 let decoded: CreateVmConfig = serde_json::from_str(&json).expect("decode round trip");
1553 assert_eq!(decoded, config);
1554 assert!(json.contains("maxHandleCommandBytes"));
1555 assert!(json.contains("maxBufferedDatagrams"));
1556 assert!(json.contains("maxStreamsPerConnection"));
1557 assert!(json.contains("shutdownDeadlineMs"));
1558 }
1559
1560 #[test]
1561 fn canonical_limits_reject_zero_and_invalid_parent_relationships() {
1562 let cases = [
1563 (
1564 serde_json::json!({
1565 "reactor": { "maxHandleCommands": 0 }
1566 }),
1567 "limits.reactor.maxHandleCommands",
1568 ),
1569 (
1570 serde_json::json!({
1571 "reactor": { "maxBlockingJobs": 0 }
1572 }),
1573 "limits.reactor.maxBlockingJobs",
1574 ),
1575 (
1576 serde_json::json!({
1577 "udp": { "maxBufferedBytes": 0 }
1578 }),
1579 "limits.udp.maxBufferedBytes",
1580 ),
1581 (
1582 serde_json::json!({
1583 "reactor": { "maxCapabilities": 8, "maxReadyHandles": 4 }
1584 }),
1585 "limits.reactor.maxCapabilities",
1586 ),
1587 (
1588 serde_json::json!({
1589 "resources": { "maxSocketBufferedBytes": 1024 },
1590 "tls": { "maxBufferedBytes": 2048 }
1591 }),
1592 "limits.tls.maxBufferedBytes",
1593 ),
1594 (
1595 serde_json::json!({
1596 "http2": {
1597 "maxBufferedBytes": 1024,
1598 "maxPendingEventBytes": 2048
1599 }
1600 }),
1601 "limits.http2.maxPendingEventBytes",
1602 ),
1603 (
1604 serde_json::json!({
1605 "resources": { "maxSocketDatagramQueueLen": 16 },
1606 "udp": { "maxBufferedDatagrams": 32 }
1607 }),
1608 "limits.udp.maxBufferedDatagrams",
1609 ),
1610 ];
1611
1612 for (limits, expected_path) in cases {
1613 let config: CreateVmConfig = serde_json::from_value(serde_json::json!({
1614 "limits": limits
1615 }))
1616 .expect("decode invalid relationship fixture");
1617 let error = config
1618 .validate(16 * 1024)
1619 .expect_err("invalid relationship must fail");
1620 assert!(
1621 error.to_string().contains(expected_path),
1622 "expected {expected_path} in {error}"
1623 );
1624 }
1625 }
1626
1627 fn js_runtime_config(value: serde_json::Value) -> Result<CreateVmConfig, serde_json::Error> {
1628 serde_json::from_value(serde_json::json!({ "jsRuntime": value }))
1629 }
1630
1631 #[test]
1632 fn js_runtime_defaults_to_node() {
1633 let config: CreateVmConfig =
1634 serde_json::from_value(serde_json::json!({ "jsRuntime": {} })).expect("decode");
1635 let js = config.js_runtime.expect("jsRuntime present");
1636 assert_eq!(js.platform, JsRuntimePlatform::Node);
1637 assert_eq!(js.module_resolution, JsModuleResolution::Node);
1638 assert!(js.allowed_builtins.is_none());
1639 assert!(js.high_resolution_time.is_none());
1640 }
1641
1642 #[test]
1643 fn js_runtime_high_resolution_time_defaults_off_and_round_trips() {
1644 let defaulted = js_runtime_config(serde_json::json!({})).unwrap();
1645 assert!(defaulted.js_runtime.unwrap().high_resolution_time.is_none());
1646
1647 let enabled = js_runtime_config(serde_json::json!({
1648 "highResolutionTime": true,
1649 }))
1650 .unwrap();
1651 assert_eq!(
1652 enabled.js_runtime.as_ref().unwrap().high_resolution_time,
1653 Some(true)
1654 );
1655 let json = serde_json::to_string(&enabled).expect("serialize");
1656 assert!(json.contains("highResolutionTime"));
1657 let decoded: CreateVmConfig = serde_json::from_str(&json).expect("re-decode");
1658 assert_eq!(decoded, enabled);
1659 }
1660
1661 #[test]
1662 fn js_runtime_all_platform_resolution_combos_round_trip() {
1663 for platform in ["node", "browser", "neutral", "bare"] {
1664 for resolution in ["node", "relative", "none"] {
1665 let config = js_runtime_config(serde_json::json!({
1666 "platform": platform,
1667 "moduleResolution": resolution,
1668 }))
1669 .unwrap_or_else(|err| panic!("decode {platform}/{resolution}: {err}"));
1670 let json = serde_json::to_string(&config).expect("serialize");
1671 let decoded: CreateVmConfig = serde_json::from_str(&json).expect("re-decode");
1672 assert_eq!(decoded, config);
1673 assert!(config.validate(usize::MAX).is_ok());
1674 }
1675 }
1676 }
1677
1678 #[test]
1679 fn js_runtime_allowed_builtins_tri_state() {
1680 let none = js_runtime_config(serde_json::json!({ "platform": "node" })).unwrap();
1682 assert!(none.js_runtime.unwrap().allowed_builtins.is_none());
1683 let empty = js_runtime_config(serde_json::json!({ "allowedBuiltins": [] })).unwrap();
1685 assert_eq!(empty.js_runtime.unwrap().allowed_builtins, Some(Vec::new()));
1686 let some = js_runtime_config(serde_json::json!({ "allowedBuiltins": ["path", "node:fs"] }))
1688 .unwrap();
1689 assert_eq!(
1690 some.js_runtime.unwrap().allowed_builtins,
1691 Some(vec!["path".to_owned(), "node:fs".to_owned()])
1692 );
1693 }
1694
1695 #[test]
1696 fn js_runtime_rejects_allowed_builtins_under_non_node_platform() {
1697 for platform in ["browser", "neutral", "bare"] {
1698 let config = js_runtime_config(serde_json::json!({
1699 "platform": platform,
1700 "allowedBuiltins": ["path"],
1701 }))
1702 .unwrap();
1703 let error = config
1704 .validate(usize::MAX)
1705 .expect_err("allowedBuiltins under non-node must reject");
1706 assert!(error.to_string().contains("allowedBuiltins"));
1707 }
1708 }
1709
1710 #[test]
1711 fn js_runtime_rejects_unknown_builtin_names() {
1712 let config = js_runtime_config(serde_json::json!({
1713 "platform": "node",
1714 "allowedBuiltins": ["path", "totally_not_a_builtin"],
1715 }))
1716 .unwrap();
1717 let error = config
1718 .validate(usize::MAX)
1719 .expect_err("unknown builtin must reject");
1720 assert!(error.to_string().contains("unknown builtin"));
1721 }
1722
1723 #[test]
1724 fn js_runtime_accepts_empty_allow_list_under_node() {
1725 let config =
1726 js_runtime_config(serde_json::json!({ "platform": "node", "allowedBuiltins": [] }))
1727 .unwrap();
1728 assert!(config.validate(usize::MAX).is_ok());
1729 }
1730
1731 #[test]
1732 fn js_runtime_rejects_unknown_fields() {
1733 let error = js_runtime_config(serde_json::json!({ "surprise": true }))
1734 .expect_err("unknown jsRuntime field should fail");
1735 assert!(error.to_string().contains("unknown field"));
1736 }
1737}