use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = "../../../packages/core/src/generated/")]
#[derive(Default)]
pub struct CreateVmConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub cwd: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
#[ts(type = "Record<string, string>")]
pub env: BTreeMap<String, String>,
#[serde(default, rename = "rootFilesystem")]
pub root_filesystem: RootFilesystemConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub permissions: Option<PermissionsPolicy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub limits: Option<VmLimitsConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub dns: Option<VmDnsConfig>,
#[serde(
default,
rename = "nativeRoot",
skip_serializing_if = "Option::is_none"
)]
#[ts(optional)]
pub native_root: Option<NativeRootFilesystemConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub listen: Option<VmListenPolicyConfig>,
#[serde(
default,
rename = "loopbackExemptPorts",
skip_serializing_if = "Vec::is_empty"
)]
pub loopback_exempt_ports: Vec<u16>,
#[serde(default, rename = "jsRuntime", skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub js_runtime: Option<JsRuntimeConfig>,
}
impl CreateVmConfig {
pub fn validate(&self, max_frame_bytes: usize) -> Result<(), VmConfigError> {
if let Some(cwd) = self.cwd.as_deref() {
validate_guest_path("cwd", cwd)?;
}
self.root_filesystem.validate()?;
if let Some(native_root) = &self.native_root {
native_root.validate()?;
}
if self.native_root.is_some() && !self.root_filesystem.bootstrap_entries.is_empty() {
return Err(VmConfigError::new(
"nativeRoot does not support rootFilesystem.bootstrapEntries",
));
}
if let Some(dns) = &self.dns {
dns.validate()?;
}
if let Some(listen) = &self.listen {
listen.validate()?;
}
if let Some(limits) = &self.limits {
limits.validate(max_frame_bytes)?;
}
if let Some(js_runtime) = &self.js_runtime {
js_runtime.validate()?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = "../../../packages/core/src/generated/")]
pub struct JsRuntimeConfig {
#[serde(default)]
pub platform: JsRuntimePlatform,
#[serde(default, rename = "moduleResolution")]
pub module_resolution: JsModuleResolution,
#[serde(
default,
rename = "allowedBuiltins",
skip_serializing_if = "Option::is_none"
)]
#[ts(optional)]
pub allowed_builtins: Option<Vec<String>>,
#[serde(
default,
rename = "snapshotUserlandCode",
skip_serializing_if = "Option::is_none"
)]
#[ts(optional)]
pub snapshot_userland_code: Option<String>,
}
impl JsRuntimeConfig {
fn validate(&self) -> Result<(), VmConfigError> {
if let Some(allowed) = &self.allowed_builtins {
if self.platform != JsRuntimePlatform::Node {
return Err(VmConfigError::new(
"jsRuntime.allowedBuiltins is only valid when jsRuntime.platform is \"node\"",
));
}
for name in allowed {
if !is_known_node_builtin(name) {
return Err(VmConfigError::new(format!(
"jsRuntime.allowedBuiltins contains unknown builtin {name:?}"
)));
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../../packages/core/src/generated/")]
#[derive(Default)]
pub enum JsRuntimePlatform {
#[default]
Node,
Browser,
Neutral,
Bare,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../../packages/core/src/generated/")]
#[derive(Default)]
pub enum JsModuleResolution {
#[default]
Node,
Relative,
None,
}
const KNOWN_NODE_BUILTINS: &[&str] = &[
"assert",
"async_hooks",
"buffer",
"child_process",
"cluster",
"console",
"constants",
"crypto",
"dgram",
"diagnostics_channel",
"dns",
"dns/promises",
"domain",
"events",
"fs",
"fs/promises",
"http",
"http2",
"https",
"inspector",
"module",
"net",
"os",
"path",
"path/posix",
"path/win32",
"perf_hooks",
"process",
"punycode",
"querystring",
"readline",
"repl",
"sqlite",
"stream",
"stream/consumers",
"stream/promises",
"stream/web",
"string_decoder",
"sys",
"timers",
"timers/promises",
"tls",
"trace_events",
"tty",
"url",
"util",
"util/types",
"v8",
"vm",
"wasi",
"worker_threads",
"zlib",
];
fn is_known_node_builtin(name: &str) -> bool {
let bare = name.strip_prefix("node:").unwrap_or(name);
KNOWN_NODE_BUILTINS.contains(&bare)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = "../../../packages/core/src/generated/")]
pub struct RootFilesystemConfig {
#[serde(default)]
pub mode: RootFilesystemMode,
#[serde(default, rename = "disableDefaultBaseLayer")]
pub disable_default_base_layer: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lowers: Vec<RootFilesystemLowerDescriptor>,
#[serde(
default,
rename = "bootstrapEntries",
skip_serializing_if = "Vec::is_empty"
)]
pub bootstrap_entries: Vec<RootFilesystemEntry>,
}
impl Default for RootFilesystemConfig {
fn default() -> Self {
Self {
mode: RootFilesystemMode::Ephemeral,
disable_default_base_layer: false,
lowers: Vec::new(),
bootstrap_entries: Vec::new(),
}
}
}
impl RootFilesystemConfig {
fn validate(&self) -> Result<(), VmConfigError> {
for lower in &self.lowers {
if let RootFilesystemLowerDescriptor::Snapshot { entries } = lower {
for entry in entries {
entry.validate()?;
}
}
}
for entry in &self.bootstrap_entries {
entry.validate()?;
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "kebab-case")]
#[ts(export, export_to = "../../../packages/core/src/generated/")]
#[derive(Default)]
pub enum RootFilesystemMode {
#[default]
Ephemeral,
ReadOnly,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(tag = "kind", rename_all = "camelCase")]
#[ts(export, export_to = "../../../packages/core/src/generated/")]
pub enum RootFilesystemLowerDescriptor {
Snapshot {
#[serde(default)]
entries: Vec<RootFilesystemEntry>,
},
BundledBaseFilesystem,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = "../../../packages/core/src/generated/")]
pub struct RootFilesystemEntry {
pub path: String,
pub kind: RootFilesystemEntryKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub mode: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub uid: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub gid: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub encoding: Option<RootFilesystemEntryEncoding>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub target: Option<String>,
#[serde(default)]
pub executable: bool,
}
impl RootFilesystemEntry {
fn validate(&self) -> Result<(), VmConfigError> {
validate_guest_path("root filesystem entry path", &self.path)?;
match self.kind {
RootFilesystemEntryKind::File => {
if self.target.is_some() {
return Err(VmConfigError::new(format!(
"file entry {} must not include target",
self.path
)));
}
}
RootFilesystemEntryKind::Directory => {
if self.content.is_some() || self.encoding.is_some() || self.target.is_some() {
return Err(VmConfigError::new(format!(
"directory entry {} must not include content, encoding, or target",
self.path
)));
}
}
RootFilesystemEntryKind::Symlink => {
if self.target.as_deref().unwrap_or("").is_empty() {
return Err(VmConfigError::new(format!(
"symlink entry {} requires target",
self.path
)));
}
if self.content.is_some() || self.encoding.is_some() {
return Err(VmConfigError::new(format!(
"symlink entry {} must not include content or encoding",
self.path
)));
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../../packages/core/src/generated/")]
pub enum RootFilesystemEntryKind {
File,
Directory,
Symlink,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../../packages/core/src/generated/")]
pub enum RootFilesystemEntryEncoding {
Utf8,
Base64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = "../../../packages/core/src/generated/")]
pub struct NativeRootFilesystemConfig {
pub plugin: MountPluginDescriptor,
#[serde(default, rename = "readOnly")]
pub read_only: bool,
}
impl NativeRootFilesystemConfig {
fn validate(&self) -> Result<(), VmConfigError> {
if self.plugin.id.trim().is_empty() {
return Err(VmConfigError::new("nativeRoot.plugin.id is required"));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = "../../../packages/core/src/generated/")]
pub struct MountPluginDescriptor {
pub id: String,
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
#[ts(type = "import(\"../descriptors.js\").MountConfigJsonValue")]
pub config: serde_json::Value,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../../packages/core/src/generated/")]
pub enum PermissionMode {
Allow,
Ask,
Deny,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(untagged)]
#[ts(export, export_to = "../../../packages/core/src/generated/")]
pub enum FsPermissionScope {
Mode(PermissionMode),
Rules(FsPermissionRuleSet),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(untagged)]
#[ts(export, export_to = "../../../packages/core/src/generated/")]
pub enum PatternPermissionScope {
Mode(PermissionMode),
Rules(PatternPermissionRuleSet),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = "../../../packages/core/src/generated/")]
pub struct FsPermissionRuleSet {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub default: Option<PermissionMode>,
#[serde(default)]
pub rules: Vec<FsPermissionRule>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = "../../../packages/core/src/generated/")]
pub struct PatternPermissionRuleSet {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub default: Option<PermissionMode>,
#[serde(default)]
pub rules: Vec<PatternPermissionRule>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = "../../../packages/core/src/generated/")]
pub struct FsPermissionRule {
pub mode: PermissionMode,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub operations: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub paths: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = "../../../packages/core/src/generated/")]
pub struct PatternPermissionRule {
pub mode: PermissionMode,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub operations: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub patterns: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = "../../../packages/core/src/generated/")]
pub struct PermissionsPolicy {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub fs: Option<FsPermissionScope>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub network: Option<PatternPermissionScope>,
#[serde(
default,
rename = "childProcess",
skip_serializing_if = "Option::is_none"
)]
#[ts(optional)]
pub child_process: Option<PatternPermissionScope>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub process: Option<PatternPermissionScope>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub env: Option<PatternPermissionScope>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub binding: Option<PatternPermissionScope>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = "../../../packages/core/src/generated/")]
pub struct VmLimitsConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub resources: Option<ResourceLimitsConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub http: Option<HttpLimitsConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub tools: Option<ToolLimitsConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub plugins: Option<PluginLimitsConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub acp: Option<AcpLimitsConfig>,
#[serde(default, rename = "jsRuntime", skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub js_runtime: Option<JsRuntimeLimitsConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub python: Option<PythonLimitsConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub wasm: Option<WasmLimitsConfig>,
}
impl VmLimitsConfig {
fn validate(&self, max_frame_bytes: usize) -> Result<(), VmConfigError> {
if let Some(http) = &self.http {
if let Some(max_fetch_response_bytes) = http.max_fetch_response_bytes {
if max_fetch_response_bytes == 0 {
return Err(VmConfigError::new(
"limits.http.maxFetchResponseBytes must be greater than zero",
));
}
if max_fetch_response_bytes as usize > max_frame_bytes {
return Err(VmConfigError::new(format!(
"limits.http.maxFetchResponseBytes ({max_fetch_response_bytes}) must be <= the sidecar wire frame cap ({max_frame_bytes})"
)));
}
}
}
if let Some(tools) = &self.tools {
if let (Some(default), Some(max)) =
(tools.default_tool_timeout_ms, tools.max_tool_timeout_ms)
{
if default > max {
return Err(VmConfigError::new(
"limits.tools.defaultToolTimeoutMs must be <= limits.tools.maxToolTimeoutMs",
));
}
}
}
Ok(())
}
}
macro_rules! limits_struct {
($name:ident { $($field:ident),* $(,)? }) => {
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = "../../../packages/core/src/generated/")]
pub struct $name {
$(
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
#[ts(type = "number")]
pub $field: Option<u64>,
)*
}
};
}
limits_struct!(ResourceLimitsConfig {
cpu_count,
max_processes,
max_open_fds,
max_pipes,
max_ptys,
max_sockets,
max_connections,
max_socket_buffered_bytes,
max_socket_datagram_queue_len,
max_filesystem_bytes,
max_inode_count,
max_blocking_read_ms,
max_pread_bytes,
max_fd_write_bytes,
max_process_argv_bytes,
max_process_env_bytes,
max_readdir_entries,
max_wasm_fuel,
max_wasm_memory_bytes,
max_wasm_stack_bytes,
});
limits_struct!(HttpLimitsConfig {
max_fetch_response_bytes,
});
limits_struct!(ToolLimitsConfig {
default_tool_timeout_ms,
max_tool_timeout_ms,
max_registered_toolkits,
max_registered_tools_per_vm,
max_tools_per_toolkit,
max_tool_schema_bytes,
max_tool_examples_per_tool,
max_tool_example_input_bytes,
});
limits_struct!(PluginLimitsConfig {
max_persisted_manifest_bytes,
max_persisted_manifest_file_bytes,
});
limits_struct!(AcpLimitsConfig {
max_read_line_bytes,
stdout_buffer_byte_limit,
});
limits_struct!(JsRuntimeLimitsConfig {
v8_heap_limit_mb,
sync_rpc_wait_timeout_ms,
captured_output_limit_bytes,
stdin_buffer_limit_bytes,
event_payload_limit_bytes,
v8_ipc_max_frame_bytes,
});
limits_struct!(PythonLimitsConfig {
output_buffer_max_bytes,
execution_timeout_ms,
max_old_space_mb,
vfs_rpc_timeout_ms,
});
limits_struct!(WasmLimitsConfig {
max_module_file_bytes,
captured_output_limit_bytes,
sync_read_limit_bytes,
});
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = "../../../packages/core/src/generated/")]
pub struct VmDnsConfig {
#[serde(default, rename = "nameServers", skip_serializing_if = "Vec::is_empty")]
pub name_servers: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub overrides: BTreeMap<String, Vec<String>>,
}
impl VmDnsConfig {
fn validate(&self) -> Result<(), VmConfigError> {
for entry in &self.name_servers {
if entry.trim().is_empty() {
return Err(VmConfigError::new(
"dns.nameServers entries must not be empty",
));
}
}
for (host, addresses) in &self.overrides {
if host.trim().is_empty() {
return Err(VmConfigError::new("dns.overrides keys must not be empty"));
}
if addresses.is_empty() {
return Err(VmConfigError::new(format!(
"dns.overrides.{host} must contain at least one address"
)));
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = "../../../packages/core/src/generated/")]
pub struct VmListenPolicyConfig {
#[serde(default, rename = "portMin", skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub port_min: Option<u16>,
#[serde(default, rename = "portMax", skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub port_max: Option<u16>,
#[serde(
default,
rename = "allowPrivileged",
skip_serializing_if = "Option::is_none"
)]
#[ts(optional)]
pub allow_privileged: Option<bool>,
}
impl VmListenPolicyConfig {
fn validate(&self) -> Result<(), VmConfigError> {
if self.port_min == Some(0) {
return Err(VmConfigError::new(
"listen.portMin must be between 1 and 65535",
));
}
if self.port_max == Some(0) {
return Err(VmConfigError::new(
"listen.portMax must be between 1 and 65535",
));
}
if let (Some(min), Some(max)) = (self.port_min, self.port_max) {
if min > max {
return Err(VmConfigError::new(
"listen.portMin must be <= listen.portMax",
));
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VmConfigError {
message: String,
}
impl VmConfigError {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl std::fmt::Display for VmConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for VmConfigError {}
fn validate_guest_path(label: &str, path: &str) -> Result<(), VmConfigError> {
if !path.starts_with('/') {
return Err(VmConfigError::new(format!("{label} must be absolute")));
}
if path.split('/').any(|part| part == "..") {
return Err(VmConfigError::new(format!("{label} must not contain '..'")));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_round_trips() {
let config = CreateVmConfig::default();
let json = serde_json::to_string(&config).expect("serialize config");
let decoded: CreateVmConfig = serde_json::from_str(&json).expect("decode config");
assert_eq!(decoded, config);
}
#[test]
fn unknown_fields_are_rejected() {
let error =
serde_json::from_str::<CreateVmConfig>(r#"{"rootFilesystem":{},"surprise":true}"#)
.expect_err("unknown fields should fail");
assert!(error.to_string().contains("unknown field"));
}
#[test]
fn validate_rejects_fetch_limit_above_frame_cap() {
let config = CreateVmConfig {
limits: Some(VmLimitsConfig {
http: Some(HttpLimitsConfig {
max_fetch_response_bytes: Some(2048),
}),
..VmLimitsConfig::default()
}),
..CreateVmConfig::default()
};
assert!(config.validate(1024).is_err());
}
fn js_runtime_config(value: serde_json::Value) -> Result<CreateVmConfig, serde_json::Error> {
serde_json::from_value(serde_json::json!({ "jsRuntime": value }))
}
#[test]
fn js_runtime_defaults_to_node() {
let config: CreateVmConfig =
serde_json::from_value(serde_json::json!({ "jsRuntime": {} })).expect("decode");
let js = config.js_runtime.expect("jsRuntime present");
assert_eq!(js.platform, JsRuntimePlatform::Node);
assert_eq!(js.module_resolution, JsModuleResolution::Node);
assert!(js.allowed_builtins.is_none());
}
#[test]
fn js_runtime_all_platform_resolution_combos_round_trip() {
for platform in ["node", "browser", "neutral", "bare"] {
for resolution in ["node", "relative", "none"] {
let config = js_runtime_config(serde_json::json!({
"platform": platform,
"moduleResolution": resolution,
}))
.unwrap_or_else(|err| panic!("decode {platform}/{resolution}: {err}"));
let json = serde_json::to_string(&config).expect("serialize");
let decoded: CreateVmConfig = serde_json::from_str(&json).expect("re-decode");
assert_eq!(decoded, config);
assert!(config.validate(usize::MAX).is_ok());
}
}
}
#[test]
fn js_runtime_allowed_builtins_tri_state() {
let none = js_runtime_config(serde_json::json!({ "platform": "node" })).unwrap();
assert!(none.js_runtime.unwrap().allowed_builtins.is_none());
let empty = js_runtime_config(serde_json::json!({ "allowedBuiltins": [] })).unwrap();
assert_eq!(empty.js_runtime.unwrap().allowed_builtins, Some(Vec::new()));
let some = js_runtime_config(serde_json::json!({ "allowedBuiltins": ["path", "node:fs"] }))
.unwrap();
assert_eq!(
some.js_runtime.unwrap().allowed_builtins,
Some(vec!["path".to_owned(), "node:fs".to_owned()])
);
}
#[test]
fn js_runtime_rejects_allowed_builtins_under_non_node_platform() {
for platform in ["browser", "neutral", "bare"] {
let config = js_runtime_config(serde_json::json!({
"platform": platform,
"allowedBuiltins": ["path"],
}))
.unwrap();
let error = config
.validate(usize::MAX)
.expect_err("allowedBuiltins under non-node must reject");
assert!(error.to_string().contains("allowedBuiltins"));
}
}
#[test]
fn js_runtime_rejects_unknown_builtin_names() {
let config = js_runtime_config(serde_json::json!({
"platform": "node",
"allowedBuiltins": ["path", "totally_not_a_builtin"],
}))
.unwrap();
let error = config
.validate(usize::MAX)
.expect_err("unknown builtin must reject");
assert!(error.to_string().contains("unknown builtin"));
}
#[test]
fn js_runtime_accepts_empty_allow_list_under_node() {
let config =
js_runtime_config(serde_json::json!({ "platform": "node", "allowedBuiltins": [] }))
.unwrap();
assert!(config.validate(usize::MAX).is_ok());
}
#[test]
fn js_runtime_rejects_unknown_fields() {
let error = js_runtime_config(serde_json::json!({ "surprise": true }))
.expect_err("unknown jsRuntime field should fail");
assert!(error.to_string().contains("unknown field"));
}
}