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