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