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 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 let none = js_runtime_config(serde_json::json!({ "platform": "node" })).unwrap();
1638 assert!(none.js_runtime.unwrap().allowed_builtins.is_none());
1639 let empty = js_runtime_config(serde_json::json!({ "allowedBuiltins": [] })).unwrap();
1641 assert_eq!(empty.js_runtime.unwrap().allowed_builtins, Some(Vec::new()));
1642 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}