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