From 54a7fcfaf0f22fa980a6047d18f51ccf842cb741 Mon Sep 17 00:00:00 2001 From: fffonion Date: Mon, 10 Aug 2026 22:51:28 +0800 Subject: [PATCH] feat(host): bind capability profiles and IO limits --- src/builtins/runtime/io.rs | 172 ++++++++++-- src/lib.rs | 13 +- src/vm/capability.rs | 339 ++++++++++++++++++++++++ src/vm/host.rs | 144 +++++++--- src/vm/host_runtime.rs | 3 + src/vm/mod.rs | 6 +- src/vm/tests.rs | 64 +++++ tests/builtins/io_builtin_edge_tests.rs | 196 +++++++++++++- tests/common/mod.rs | 4 +- tests/host_binding_generation_tests.rs | 118 ++++++++- tests/vm/http_host_tests.rs | 27 +- tests/vm/vm_runtime_tests.rs | 114 ++++++-- 12 files changed, 1095 insertions(+), 105 deletions(-) create mode 100644 src/vm/capability.rs diff --git a/src/builtins/runtime/io.rs b/src/builtins/runtime/io.rs index ee9e56c..f7038c8 100644 --- a/src/builtins/runtime/io.rs +++ b/src/builtins/runtime/io.rs @@ -1,6 +1,7 @@ use std::fs::OpenOptions; use std::future::Future; use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; use std::pin::Pin; use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicU32, Ordering}; @@ -204,7 +205,16 @@ pub(super) fn builtin_io_open( path: &str, mode: &str, ) -> VmResult> { - let path = path.to_string(); + let writes = match mode { + "r" => false, + "w" | "a" | "r+" | "w+" | "a+" => true, + other => { + return Err(VmError::HostError(format!( + "unsupported io_open mode '{other}', expected r/w/a/r+/w+/a+" + ))); + } + }; + let path = authorize_io_path(vm, path, writes)?; let mode = mode.to_string(); let op_id = schedule_io_task(vm, None, move || { let mut options = OpenOptions::new(); @@ -260,6 +270,16 @@ pub(super) fn builtin_io_popen( "unsupported io_popen mode '{mode}', expected r or w" ))); } + if vm + .host + .io_policy + .as_ref() + .is_some_and(|policy| !policy.allow_process) + { + return Err(VmError::HostError( + "io_popen requires the process capability".to_string(), + )); + } let command = command.to_string(); let mode = mode.to_string(); let op_id = schedule_io_task(vm, None, move || { @@ -298,23 +318,31 @@ pub(super) fn builtin_io_popen( /// Reads all remaining text from an I/O handle. #[pd_host_function(name = "io::read_all")] pub(super) fn builtin_io_read_all(vm: &mut Vm, handle_id: i64) -> VmResult> { + let max_read_bytes = vm + .host + .io_policy + .as_ref() + .map(|policy| policy.max_read_bytes); let handle = resource_handle(handle_id)?; let resource = io_resource_for_handle(vm, handle)?; let op_id = schedule_io_task(vm, Some(handle), move || { let result = resource.with_handle_mut(|handle| { let mut out = String::new(); match handle { - IoHandle::File(file) => file - .read_to_string(&mut out) - .map_err(|err| VmError::HostError(format!("io_read_all failed: {err}")))?, - IoHandle::PopenRead { child } => child - .stdout - .as_mut() - .ok_or_else(|| { - VmError::HostError("io_read_all popen handle missing stdout".to_string()) - })? - .read_to_string(&mut out) - .map_err(|err| VmError::HostError(format!("io_read_all failed: {err}")))?, + IoHandle::File(file) => { + read_to_string_with_limit(file, max_read_bytes, &mut out)?; + } + IoHandle::PopenRead { child } => { + read_to_string_with_limit( + child.stdout.as_mut().ok_or_else(|| { + VmError::HostError( + "io_read_all popen handle missing stdout".to_string(), + ) + })?, + max_read_bytes, + &mut out, + )?; + } IoHandle::PopenWrite { .. } => { return Err(VmError::HostError( "io_read_all requires a readable handle".to_string(), @@ -334,17 +362,23 @@ pub(super) fn builtin_io_read_line( vm: &mut Vm, handle_id: i64, ) -> VmResult> { + let max_read_bytes = vm + .host + .io_policy + .as_ref() + .map(|policy| policy.max_read_bytes); let handle = resource_handle(handle_id)?; let resource = io_resource_for_handle(vm, handle)?; let op_id = schedule_io_task(vm, Some(handle), move || { let result = resource.with_handle_mut(|handle| { let line = match handle { - IoHandle::File(file) => read_line_from_reader(file)?, - IoHandle::PopenRead { child } => { - read_line_from_reader(child.stdout.as_mut().ok_or_else(|| { + IoHandle::File(file) => read_line_from_reader(file, max_read_bytes)?, + IoHandle::PopenRead { child } => read_line_from_reader( + child.stdout.as_mut().ok_or_else(|| { VmError::HostError("io_read_line popen handle missing stdout".to_string()) - })?)? - } + })?, + max_read_bytes, + )?, IoHandle::PopenWrite { .. } => { return Err(VmError::HostError( "io_read_line requires a readable handle".to_string(), @@ -365,6 +399,14 @@ pub(super) fn builtin_io_write( handle_id: i64, text: &str, ) -> VmResult> { + if let Some(policy) = vm.host.io_policy.as_ref() + && text.len() > policy.max_write_bytes + { + return Err(VmError::HostError(format!( + "io_write exceeds the configured write limit of {} bytes", + policy.max_write_bytes + ))); + } let bytes = text.as_bytes().to_vec(); let handle = resource_handle(handle_id)?; let resource = io_resource_for_handle(vm, handle)?; @@ -445,15 +487,65 @@ pub(super) fn builtin_io_close(vm: &mut Vm, handle_id: i64) -> VmResult VmResult> { - let path = path.to_string(); + let path = authorize_io_path(vm, path, false)?; let op_id = schedule_io_task(vm, None, move || { - IoAsyncCompletion::result(Ok(CallReturn::one(Value::Bool( - std::path::Path::new(path.as_str()).exists(), - )))) + IoAsyncCompletion::result(Ok(CallReturn::one(Value::Bool(path.exists())))) })?; Ok(HostCallResult::Pending(op_id)) } +fn authorize_io_path(vm: &Vm, path: &str, writes: bool) -> VmResult { + let requested = PathBuf::from(path); + let Some(policy) = vm.host.io_policy.as_ref() else { + return Ok(requested); + }; + if writes && !policy.allow_write { + return Err(VmError::HostError( + "io path write requires the write capability".to_string(), + )); + } + let absolute = if requested.is_absolute() { + requested + } else { + std::env::current_dir() + .map_err(|error| VmError::HostError(format!("io path resolution failed: {error}")))? + .join(requested) + }; + let canonical = canonicalize_io_target(&absolute)?; + for root in &policy.allowed_roots { + let root = Path::new(root).canonicalize().map_err(|error| { + VmError::HostError(format!( + "io allowed root '{root}' cannot be resolved: {error}" + )) + })?; + if canonical.starts_with(root) { + return Ok(canonical); + } + } + Err(VmError::HostError(format!( + "io path '{}' is outside the allowed roots", + canonical.display() + ))) +} + +fn canonicalize_io_target(path: &Path) -> VmResult { + if path.exists() { + return path + .canonicalize() + .map_err(|error| VmError::HostError(format!("io path resolution failed: {error}"))); + } + let parent = path + .parent() + .ok_or_else(|| VmError::HostError(format!("io path '{}' has no parent", path.display())))?; + let file_name = path.file_name().ok_or_else(|| { + VmError::HostError(format!("io path '{}' has no file name", path.display())) + })?; + parent + .canonicalize() + .map(|parent| parent.join(file_name)) + .map_err(|error| VmError::HostError(format!("io path resolution failed: {error}"))) +} + fn spawn_shell_command(command: &str, mode: &str) -> VmResult { let mut process = if cfg!(windows) { let mut cmd = Command::new("cmd"); @@ -882,7 +974,37 @@ fn terminate_process_tree(process_id: u32) -> VmResult<()> { ))) } -fn read_line_from_reader(reader: &mut impl Read) -> VmResult { +fn read_to_string_with_limit( + reader: &mut impl Read, + max_read_bytes: Option, + out: &mut String, +) -> VmResult<()> { + match max_read_bytes { + None => { + reader + .read_to_string(out) + .map_err(|err| VmError::HostError(format!("io_read_all failed: {err}")))?; + } + Some(limit) => { + let take_limit = u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1); + reader + .take(take_limit) + .read_to_string(out) + .map_err(|err| VmError::HostError(format!("io_read_all failed: {err}")))?; + if out.len() > limit { + return Err(VmError::HostError(format!( + "io_read_all exceeds the configured read limit of {limit} bytes" + ))); + } + } + } + Ok(()) +} + +fn read_line_from_reader( + reader: &mut impl Read, + max_read_bytes: Option, +) -> VmResult { let mut bytes = Vec::new(); let mut one = [0u8; 1]; loop { @@ -893,6 +1015,12 @@ fn read_line_from_reader(reader: &mut impl Read) -> VmResult { break; } bytes.push(one[0]); + if max_read_bytes.is_some_and(|limit| bytes.len() > limit) { + return Err(VmError::HostError(format!( + "io_read_line exceeds the configured read limit of {} bytes", + max_read_bytes.expect("read limit should be present") + ))); + } if one[0] == b'\n' { break; } diff --git a/src/lib.rs b/src/lib.rs index 6037cd4..bdabbe6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -88,12 +88,13 @@ pub use jit::{ pub use vm::diagnostics::render_vm_error; #[cfg(feature = "runtime")] pub use vm::{ - AotArtifactError, CallOutcome, CallReturn, CancellationReason, DEFAULT_MAX_SCRIPT_CALL_DEPTH, - EpochCheckpoint, EpochHandle, FuelCheckpoint, HostArgsFunction, HostAsyncBridge, - HostBindingPlan, HostFunction, HostFunctionRegistry, HostOpId, HostStackFunction, - IntoScriptValue, QueuedScriptInvocation, ScriptArgs, ScriptCallback, ScriptResult, - StaticHostArgsFunction, StaticHostFunction, StaticHostStackFunction, Store, Vm, VmError, - VmResult, VmStatus, VmYieldReason, + AotArtifactError, CallOutcome, CallReturn, CancellationReason, CapabilityProfile, + CapabilityProfileBuilder, DEFAULT_MAX_SCRIPT_CALL_DEPTH, EpochCheckpoint, EpochHandle, + FuelCheckpoint, HostArgsFunction, HostAsyncBridge, HostBindingPlan, HostFunction, + HostFunctionRegistry, HostOpId, HostStackFunction, IntoScriptValue, IoPolicy, + QueuedScriptInvocation, ScriptArgs, ScriptCallback, ScriptResult, StaticHostArgsFunction, + StaticHostFunction, StaticHostStackFunction, Store, Vm, VmError, VmResult, VmStatus, + VmYieldReason, }; #[cfg(feature = "sqlite")] pub use vm::{SqliteLimits, SqlitePolicy}; diff --git a/src/vm/capability.rs b/src/vm/capability.rs new file mode 100644 index 0000000..2618d2f --- /dev/null +++ b/src/vm/capability.rs @@ -0,0 +1,339 @@ +#[cfg(feature = "sqlite")] +use super::SqlitePolicy; +use crate::builtins::BuiltinFunction; +use crate::builtins::runtime::HttpConfig; + +const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; +const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; +const PROFILE_VERSION: &[u8] = b"rustscript-capability-profile-v1"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IoPolicy { + pub allowed_roots: Vec, + pub allow_write: bool, + pub allow_process: bool, + pub max_read_bytes: usize, + pub max_write_bytes: usize, +} + +impl Default for IoPolicy { + fn default() -> Self { + Self { + allowed_roots: Vec::new(), + allow_write: false, + allow_process: false, + max_read_bytes: 1024 * 1024, + max_write_bytes: 1024 * 1024, + } + } +} + +/// Immutable authorization policy for privileged builtin calls and host imports. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CapabilityProfile { + allow_all_builtins: bool, + allow_all_host_imports: bool, + allowed_builtin_calls: Vec, + allowed_host_imports: Vec, + http_policy: Option, + io_policy: Option, + #[cfg(feature = "sqlite")] + sqlite_policy: Option, + fingerprint: u64, +} + +impl CapabilityProfile { + pub fn builder() -> CapabilityProfileBuilder { + CapabilityProfileBuilder::default() + } + + pub fn deny_all() -> Self { + CapabilityProfileBuilder::default().build() + } + + pub fn allow_all() -> Self { + CapabilityProfileBuilder { + allow_all_builtins: true, + allow_all_host_imports: true, + ..CapabilityProfileBuilder::default() + } + .build() + } + + pub fn fingerprint(&self) -> u64 { + self.fingerprint + } + + pub fn allows_builtin(&self, builtin: BuiltinFunction) -> bool { + self.allow_all_builtins + || self + .allowed_builtin_calls + .binary_search(&builtin.call_index()) + .is_ok() + } + + pub fn allows_host_import(&self, name: &str) -> bool { + self.allow_all_host_imports + || self + .allowed_host_imports + .binary_search_by(|candidate| candidate.as_str().cmp(name)) + .is_ok() + } + + pub fn http_policy(&self) -> Option<&HttpConfig> { + self.http_policy.as_ref() + } + + pub fn io_policy(&self) -> Option<&IoPolicy> { + self.io_policy.as_ref() + } + + #[cfg(feature = "sqlite")] + pub fn sqlite_policy(&self) -> Option<&SqlitePolicy> { + self.sqlite_policy.as_ref() + } + + pub(crate) fn allowed_builtin_calls(&self) -> &[u16] { + &self.allowed_builtin_calls + } + + pub(crate) fn allows_all_builtins(&self) -> bool { + self.allow_all_builtins + } + + pub(crate) fn allows_all_host_imports(&self) -> bool { + self.allow_all_host_imports + } + + pub(crate) fn with_builtin(&self, builtin: BuiltinFunction) -> Self { + let mut builder = CapabilityProfileBuilder { + allow_all_builtins: self.allow_all_builtins, + allow_all_host_imports: self.allow_all_host_imports, + allowed_builtin_calls: self.allowed_builtin_calls.clone(), + allowed_host_imports: self.allowed_host_imports.clone(), + http_policy: self.http_policy.clone(), + io_policy: self.io_policy.clone(), + #[cfg(feature = "sqlite")] + sqlite_policy: self.sqlite_policy.clone(), + }; + builder.allowed_builtin_calls.push(builtin.call_index()); + builder.build() + } + + pub(crate) fn with_host_import(&self, name: &str) -> Self { + let mut builder = CapabilityProfileBuilder { + allow_all_builtins: self.allow_all_builtins, + allow_all_host_imports: self.allow_all_host_imports, + allowed_builtin_calls: self.allowed_builtin_calls.clone(), + allowed_host_imports: self.allowed_host_imports.clone(), + http_policy: self.http_policy.clone(), + io_policy: self.io_policy.clone(), + #[cfg(feature = "sqlite")] + sqlite_policy: self.sqlite_policy.clone(), + }; + builder.allowed_host_imports.push(name.to_string()); + builder.build() + } +} + +impl Default for CapabilityProfile { + fn default() -> Self { + Self::deny_all() + } +} + +#[derive(Clone, Debug, Default)] +pub struct CapabilityProfileBuilder { + allow_all_builtins: bool, + allow_all_host_imports: bool, + allowed_builtin_calls: Vec, + allowed_host_imports: Vec, + http_policy: Option, + io_policy: Option, + #[cfg(feature = "sqlite")] + sqlite_policy: Option, +} + +impl CapabilityProfileBuilder { + pub fn allow_builtin(mut self, builtin: BuiltinFunction) -> Self { + self.allowed_builtin_calls.push(builtin.call_index()); + self + } + + pub fn allow_host_import(mut self, name: impl Into) -> Self { + self.allowed_host_imports.push(name.into()); + self + } + + pub fn http_policy(mut self, policy: HttpConfig) -> Self { + self.http_policy = Some(policy); + self + } + + pub fn io_policy(mut self, policy: IoPolicy) -> Self { + self.io_policy = Some(policy); + self + } + + #[cfg(feature = "sqlite")] + pub fn sqlite_policy(mut self, policy: SqlitePolicy) -> Self { + self.sqlite_policy = Some(policy); + self + } + + pub fn build(mut self) -> CapabilityProfile { + self.allowed_builtin_calls.sort_unstable(); + self.allowed_builtin_calls.dedup(); + self.allowed_host_imports.sort(); + self.allowed_host_imports.dedup(); + if let Some(policy) = self.http_policy.as_mut() { + policy.allowed_schemes.sort(); + policy.allowed_schemes.dedup(); + policy.allowed_hosts.sort(); + policy.allowed_hosts.dedup(); + policy.allowed_ports.sort_unstable(); + policy.allowed_ports.dedup(); + } + if let Some(policy) = self.io_policy.as_mut() { + policy.allowed_roots.sort(); + policy.allowed_roots.dedup(); + } + let fingerprint = fingerprint( + self.allow_all_builtins, + self.allow_all_host_imports, + &self.allowed_builtin_calls, + &self.allowed_host_imports, + self.http_policy.as_ref(), + self.io_policy.as_ref(), + #[cfg(feature = "sqlite")] + self.sqlite_policy.as_ref(), + ); + CapabilityProfile { + allow_all_builtins: self.allow_all_builtins, + allow_all_host_imports: self.allow_all_host_imports, + allowed_builtin_calls: self.allowed_builtin_calls, + allowed_host_imports: self.allowed_host_imports, + http_policy: self.http_policy, + io_policy: self.io_policy, + #[cfg(feature = "sqlite")] + sqlite_policy: self.sqlite_policy, + fingerprint, + } + } +} + +fn fingerprint( + allow_all_builtins: bool, + allow_all_host_imports: bool, + builtin_calls: &[u16], + host_imports: &[String], + http_policy: Option<&HttpConfig>, + io_policy: Option<&IoPolicy>, + #[cfg(feature = "sqlite")] sqlite_policy: Option<&SqlitePolicy>, +) -> u64 { + let mut value = FNV_OFFSET_BASIS; + update_fingerprint(&mut value, PROFILE_VERSION); + update_fingerprint( + &mut value, + &[ + u8::from(allow_all_builtins), + u8::from(allow_all_host_imports), + ], + ); + update_fingerprint(&mut value, &(builtin_calls.len() as u64).to_le_bytes()); + for call in builtin_calls { + update_fingerprint(&mut value, &call.to_le_bytes()); + } + update_fingerprint(&mut value, &(host_imports.len() as u64).to_le_bytes()); + for name in host_imports { + update_fingerprint(&mut value, &(name.len() as u64).to_le_bytes()); + update_fingerprint(&mut value, name.as_bytes()); + } + match http_policy { + None => update_fingerprint(&mut value, &[0]), + Some(policy) => { + update_fingerprint(&mut value, &[1]); + update_string_list(&mut value, &policy.allowed_schemes); + update_string_list(&mut value, &policy.allowed_hosts); + update_fingerprint( + &mut value, + &(policy.allowed_ports.len() as u64).to_le_bytes(), + ); + for port in &policy.allowed_ports { + update_fingerprint(&mut value, &port.to_le_bytes()); + } + for limit in [ + policy.max_redirects as u64, + policy.max_request_body_bytes as u64, + policy.max_response_body_bytes as u64, + policy.connect_timeout.as_secs(), + u64::from(policy.connect_timeout.subsec_nanos()), + policy.request_timeout.as_secs(), + u64::from(policy.request_timeout.subsec_nanos()), + ] { + update_fingerprint(&mut value, &limit.to_le_bytes()); + } + update_fingerprint(&mut value, &[u8::from(policy.allow_private_ips)]); + } + } + match io_policy { + None => update_fingerprint(&mut value, &[0]), + Some(policy) => { + update_fingerprint(&mut value, &[1]); + update_string_list(&mut value, &policy.allowed_roots); + update_fingerprint( + &mut value, + &[u8::from(policy.allow_write), u8::from(policy.allow_process)], + ); + update_fingerprint(&mut value, &(policy.max_read_bytes as u64).to_le_bytes()); + update_fingerprint(&mut value, &(policy.max_write_bytes as u64).to_le_bytes()); + } + } + #[cfg(feature = "sqlite")] + match sqlite_policy { + None => update_fingerprint(&mut value, &[0]), + Some(policy) => { + update_fingerprint(&mut value, &[1]); + match &policy.database_root { + None => update_fingerprint(&mut value, &[0]), + Some(root) => { + update_fingerprint(&mut value, &[1]); + update_fingerprint(&mut value, &(root.len() as u64).to_le_bytes()); + update_fingerprint(&mut value, root.as_bytes()); + } + } + update_fingerprint(&mut value, &[u8::from(policy.allow_unsafe_sql)]); + for limit in [ + policy.limits.max_connections as u64, + policy.limits.max_statements as u64, + policy.limits.max_rows as u64, + policy.limits.max_columns as u64, + policy.limits.max_result_bytes as u64, + policy.limits.max_statement_bytes as u64, + policy.limits.max_parameters as u64, + policy.limits.max_parameter_bytes as u64, + policy.limits.max_pending_operations as u64, + policy.limits.max_transaction_ms, + policy.limits.busy_timeout_ms, + ] { + update_fingerprint(&mut value, &limit.to_le_bytes()); + } + } + } + value +} + +fn update_string_list(fingerprint: &mut u64, values: &[String]) { + update_fingerprint(fingerprint, &(values.len() as u64).to_le_bytes()); + for value in values { + update_fingerprint(fingerprint, &(value.len() as u64).to_le_bytes()); + update_fingerprint(fingerprint, value.as_bytes()); + } +} + +fn update_fingerprint(fingerprint: &mut u64, bytes: &[u8]) { + for byte in bytes { + *fingerprint ^= u64::from(*byte); + *fingerprint = fingerprint.wrapping_mul(FNV_PRIME); + } +} diff --git a/src/vm/host.rs b/src/vm/host.rs index d139079..3af0379 100644 --- a/src/vm/host.rs +++ b/src/vm/host.rs @@ -132,8 +132,8 @@ pub struct HostBindingPlan { allow_default_builtin_capabilities: bool, allowed_host_function_slots: Vec, allow_default_host_capabilities: bool, - capability_profile: Arc<()>, - capability_state: Arc<()>, + capability_profile: Arc, + capability_fingerprint: u64, registry_state: Arc<()>, registry_generation_token: Arc<()>, registry_generation: u64, @@ -146,10 +146,8 @@ pub struct HostFunctionRegistry { plan_cache: Arc, Arc>>>, allowed_builtin_calls: Arc>, allow_default_builtin_capabilities: bool, - allowed_host_registry_slots: Arc>, allow_default_host_capabilities: bool, - capability_profile: Arc<()>, - capability_state: Arc<()>, + capability_profile: Arc, registry_state: Arc<()>, registry_generation_token: Arc<()>, registry_generation: Arc, @@ -169,10 +167,8 @@ impl HostFunctionRegistry { plan_cache: Arc::new(RwLock::new(HashMap::new())), allowed_builtin_calls: Arc::new(Vec::new()), allow_default_builtin_capabilities: true, - allowed_host_registry_slots: Arc::new(Vec::new()), allow_default_host_capabilities: true, - capability_profile: Arc::new(()), - capability_state: Arc::new(()), + capability_profile: Arc::new(CapabilityProfile::allow_all()), registry_state: Arc::new(()), registry_generation_token: Arc::new(()), registry_generation: Arc::new(AtomicU64::new(0)), @@ -192,8 +188,7 @@ impl HostFunctionRegistry { }) .clone(); registry.plan_cache = Arc::new(RwLock::new(HashMap::new())); - registry.capability_profile = Arc::new(()); - registry.capability_state = Arc::new(()); + registry.capability_profile = Arc::new(CapabilityProfile::allow_all()); registry.registry_state = Arc::new(()); registry.registry_generation_token = Arc::new(()); registry.registry_generation = Arc::new(AtomicU64::new(0)); @@ -206,9 +201,7 @@ impl HostFunctionRegistry { let mut registry = Self::new(); registry.allow_default_builtin_capabilities = false; registry.allow_default_host_capabilities = false; - registry.allowed_host_registry_slots = Arc::new(Vec::new()); - registry.capability_profile = Arc::new(()); - registry.capability_state = Arc::new(()); + registry.capability_profile = Arc::new(CapabilityProfile::deny_all()); registry.registry_state = Arc::new(()); registry.registry_generation_token = Arc::new(()); registry.registry_generation = Arc::new(AtomicU64::new(0)); @@ -216,16 +209,20 @@ impl HostFunctionRegistry { registry } + /// Replaces the registry's immutable capability profile. + pub fn set_capability_profile(&mut self, profile: CapabilityProfile) { + self.allowed_builtin_calls = Arc::new(profile.allowed_builtin_calls().to_vec()); + self.allow_default_builtin_capabilities = profile.allows_all_builtins(); + self.allow_default_host_capabilities = profile.allows_all_host_imports(); + self.capability_profile = Arc::new(profile); + self.invalidate_plan_cache(); + } + /// Explicitly permits a namespaced builtin when this registry is used as a capability plan. pub fn allow_builtin(&mut self, name: impl AsRef) -> VmResult<()> { let name = name.as_ref(); - if let Some(®istry_slot) = self.by_name.get(name) { - let slots = Arc::make_mut(&mut self.allowed_host_registry_slots); - if !slots.contains(®istry_slot) { - slots.push(registry_slot); - slots.sort_unstable(); - } - self.capability_state = Arc::new(()); + if self.by_name.contains_key(name) { + self.capability_profile = Arc::new(self.capability_profile.with_host_import(name)); self.invalidate_plan_cache(); return Ok(()); } @@ -236,7 +233,7 @@ impl HostFunctionRegistry { calls.push(builtin.call_index()); calls.sort_unstable(); } - self.capability_state = Arc::new(()); + self.capability_profile = Arc::new(self.capability_profile.with_builtin(builtin)); self.invalidate_plan_cache(); Ok(()) } @@ -455,7 +452,51 @@ impl HostFunctionRegistry { self.invalidate_plan_cache(); } + fn validate_builtin_capability(&self, call_index: u16) -> VmResult<()> { + if let Some(builtin) = BuiltinFunction::from_call_index(call_index) + && builtin.requires_explicit_host_capability() + && !self.allowed_builtin_calls.contains(&call_index) + { + return Err(VmError::HostError(format!( + "capability profile does not allow builtin '{}'", + builtin.name() + ))); + } + Ok(()) + } + + fn validate_program_capabilities(&self, program: &Program) -> VmResult<()> { + if self.allow_default_builtin_capabilities { + return Ok(()); + } + let mut ip = 0usize; + while let Some(&raw_opcode) = program.code.get(ip) { + let opcode = + OpCode::try_from(raw_opcode).map_err(|_| VmError::InvalidOpcode(raw_opcode))?; + let operand_end = ip + .checked_add(1 + opcode.operand_len()) + .ok_or(VmError::BytecodeBounds)?; + if operand_end > program.code.len() { + return Err(VmError::BytecodeBounds); + } + if opcode == OpCode::Call { + let bytes: [u8; 2] = program.code[ip + 1..ip + 3] + .try_into() + .map_err(|_| VmError::BytecodeBounds)?; + self.validate_builtin_capability(u16::from_le_bytes(bytes))?; + } + ip = operand_end; + } + for prototype in &program.callable_prototypes { + if let CallableTarget::HostImport(call_index) = prototype.target { + self.validate_builtin_capability(call_index)?; + } + } + Ok(()) + } + pub fn bind_vm_cached(&self, vm: &mut Vm) -> VmResult<()> { + self.validate_program_capabilities(&vm.program)?; let plan = self.prepare_shared_plan(&vm.program.imports)?; self.bind_vm_with_plan(vm, &plan) } @@ -469,8 +510,8 @@ impl HostFunctionRegistry { } fn plan_matches_current(&self, plan: &HostBindingPlan) -> bool { - Arc::ptr_eq(&self.capability_profile, &plan.capability_profile) - && Arc::ptr_eq(&self.capability_state, &plan.capability_state) + self.capability_profile.fingerprint() == plan.capability_fingerprint + && self.capability_profile.as_ref() == plan.capability_profile.as_ref() && Arc::ptr_eq(&self.registry_state, &plan.registry_state) && Arc::ptr_eq( &self.registry_generation_token, @@ -505,6 +546,14 @@ impl HostFunctionRegistry { .entries .get(registry_slot as usize) .ok_or(VmError::InvalidCall(registry_slot))?; + if !self.allow_default_host_capabilities + && !self.capability_profile.allows_host_import(&import.name) + { + return Err(VmError::HostError(format!( + "capability profile does not allow host import '{}'", + import.name + ))); + } if entry.arity != import.arity { return Err(VmError::InvalidCallArity { import: import.name.clone(), @@ -524,16 +573,17 @@ impl HostFunctionRegistry { resolved_calls.push(vm_slot); } - let allowed_host_function_slots = self - .allowed_host_registry_slots + let mut allowed_host_function_slots = imports .iter() - .filter_map(|registry_slot| { - registry_slots - .iter() - .position(|slot| slot == registry_slot) - .map(|slot| slot as u16) + .zip(resolved_calls.iter().copied()) + .filter_map(|(import, vm_slot)| { + self.capability_profile + .allows_host_import(&import.name) + .then_some(vm_slot) }) - .collect(); + .collect::>(); + allowed_host_function_slots.sort_unstable(); + allowed_host_function_slots.dedup(); let runtime_owned_pending_slots = registry_slots .iter() .enumerate() @@ -555,7 +605,7 @@ impl HostFunctionRegistry { allowed_host_function_slots, allow_default_host_capabilities: self.allow_default_host_capabilities, capability_profile: Arc::clone(&self.capability_profile), - capability_state: Arc::clone(&self.capability_state), + capability_fingerprint: self.capability_profile.fingerprint(), registry_state: Arc::clone(&self.registry_state), registry_generation_token: Arc::clone(&self.registry_generation_token), registry_generation: self.registry_generation.load(Ordering::Relaxed), @@ -569,21 +619,19 @@ impl HostFunctionRegistry { } pub fn bind_vm_with_plan(&self, vm: &mut Vm, plan: &HostBindingPlan) -> VmResult<()> { + self.validate_program_capabilities(&vm.program)?; if vm.program.imports != plan.import_signature { return Err(VmError::HostError( "host binding plan does not match vm import signature".to_string(), )); } - if !Arc::ptr_eq(&self.capability_profile, &plan.capability_profile) { + if self.capability_profile.fingerprint() != plan.capability_fingerprint + || self.capability_profile.as_ref() != plan.capability_profile.as_ref() + { return Err(VmError::HostError( "host binding plan belongs to a different capability profile".to_string(), )); } - if !Arc::ptr_eq(&self.capability_state, &plan.capability_state) { - return Err(VmError::HostError( - "host binding plan belongs to a different capability state".to_string(), - )); - } if !Arc::ptr_eq(&self.registry_state, &plan.registry_state) { return Err(VmError::HostError( "host binding plan belongs to a different registry state".to_string(), @@ -604,6 +652,26 @@ impl HostFunctionRegistry { )); } + if !plan.allow_default_host_capabilities { + match plan.capability_profile.http_policy() { + Some(policy) => vm.host.http_state.configure(policy.clone()), + None => vm.host.http_state.clear_configuration(), + } + vm.host.io_policy = Some( + plan.capability_profile + .io_policy() + .cloned() + .unwrap_or_default(), + ); + #[cfg(feature = "sqlite")] + { + vm.host.sqlite_policy = plan + .capability_profile + .sqlite_policy() + .cloned() + .unwrap_or_default(); + } + } vm.host.host_functions.reserve(plan.registry_slots.len()); for ®istry_slot in &plan.registry_slots { let entry = self diff --git a/src/vm/host_runtime.rs b/src/vm/host_runtime.rs index 8d3712d..e150396 100644 --- a/src/vm/host_runtime.rs +++ b/src/vm/host_runtime.rs @@ -19,6 +19,7 @@ use crate::builtins::runtime::cancellation::{ }; use crate::builtins::runtime::resource::{DEFAULT_MAX_RESOURCES, ResourceArena}; +use crate::vm::IoPolicy; #[cfg(feature = "sqlite")] use crate::vm::SqlitePolicy; use crate::vm::host::{HostAsyncBridge, VmHostFunction}; @@ -46,6 +47,7 @@ pub(crate) struct HostRuntime { pub(crate) resolved_calls_dirty: bool, pub(crate) runtime_resources: ResourceArena, pub(crate) runtime_operations: OperationRegistry, + pub(crate) io_policy: Option, #[cfg(feature = "sqlite")] pub(crate) sqlite_policy: SqlitePolicy, pub(crate) http_state: HttpState, @@ -73,6 +75,7 @@ impl HostRuntime { .expect("default runtime resource limit should be valid"), runtime_operations: OperationRegistry::with_limit(DEFAULT_MAX_PENDING_OPERATIONS) .expect("default runtime operation limit should be valid"), + io_policy: None, #[cfg(feature = "sqlite")] sqlite_policy: SqlitePolicy::default(), http_state: HttpState::default(), diff --git a/src/vm/mod.rs b/src/vm/mod.rs index 0709c6c..3b9fd23 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -4,6 +4,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; pub(crate) mod aot; +mod capability; pub mod diagnostics; mod engine; mod epoch; @@ -21,6 +22,7 @@ mod superinstructions; #[cfg(test)] mod tests; pub use self::aot::AotArtifactError; +pub use self::capability::{CapabilityProfile, CapabilityProfileBuilder, IoPolicy}; use self::engine::Engine; pub use self::epoch::{EpochCheckpoint, EpochHandle}; pub use self::fuel::FuelCheckpoint; @@ -36,7 +38,7 @@ use self::run_context::{InterruptMode, RunContext}; pub use crate::builtins::runtime::cancellation::CancellationReason; #[cfg(feature = "sqlite")] -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct SqliteLimits { pub max_connections: usize, pub max_statements: usize, @@ -71,7 +73,7 @@ impl Default for SqliteLimits { } #[cfg(feature = "sqlite")] -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct SqlitePolicy { pub database_root: Option, pub allow_unsafe_sql: bool, diff --git a/src/vm/tests.rs b/src/vm/tests.rs index b90e275..6003416 100644 --- a/src/vm/tests.rs +++ b/src/vm/tests.rs @@ -90,6 +90,70 @@ fn unused_host_operation_ids_do_not_consume_registry_capacity() { assert_eq!(vm.host.runtime_operations.active_count(), 0); } +#[cfg(feature = "http-client")] +#[test] +fn capability_profile_binding_installs_http_policy() { + let policy = crate::builtins::runtime::HttpConfig { + allowed_hosts: vec!["example.com".to_string()], + max_redirects: 2, + ..crate::builtins::runtime::HttpConfig::default() + }; + let mut registry = HostFunctionRegistry::empty(); + registry.set_capability_profile( + CapabilityProfile::builder() + .http_policy(policy.clone()) + .build(), + ); + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + registry + .bind_vm_cached(&mut vm) + .expect("profile should bind"); + + assert_eq!(vm.host.http_state.configuration(), Some(&policy)); +} + +#[cfg(feature = "sqlite")] +#[test] +fn capability_profile_binding_installs_sqlite_policy() { + let mut policy = crate::vm::SqlitePolicy::default(); + policy.limits.max_rows = 10; + let mut registry = HostFunctionRegistry::empty(); + registry.set_capability_profile( + CapabilityProfile::builder() + .sqlite_policy(policy.clone()) + .build(), + ); + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + registry + .bind_vm_cached(&mut vm) + .expect("profile should bind"); + + assert_eq!(vm.host.sqlite_policy, policy); +} + +#[test] +fn capability_profile_binding_installs_io_policy() { + let policy = crate::vm::IoPolicy { + allowed_roots: vec!["/tmp".to_string()], + allow_write: true, + allow_process: false, + max_read_bytes: 128, + max_write_bytes: 64, + }; + let mut registry = HostFunctionRegistry::empty(); + registry.set_capability_profile( + CapabilityProfile::builder() + .io_policy(policy.clone()) + .build(), + ); + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + registry + .bind_vm_cached(&mut vm) + .expect("profile should bind"); + + assert_eq!(vm.host.io_policy.as_ref(), Some(&policy)); +} + #[test] fn external_host_operations_join_the_shared_registry_without_id_collisions() { use crate::builtins::runtime::cancellation::{OperationId, OperationOwner}; diff --git a/tests/builtins/io_builtin_edge_tests.rs b/tests/builtins/io_builtin_edge_tests.rs index 9348ed4..f49283d 100644 --- a/tests/builtins/io_builtin_edge_tests.rs +++ b/tests/builtins/io_builtin_edge_tests.rs @@ -1,4 +1,7 @@ -use vm::{Value, Vm, VmError, VmStatus, compile_source}; +use vm::{ + BuiltinFunction, CapabilityProfile, HostFunctionRegistry, IoPolicy, Value, Vm, VmError, + VmStatus, compile_source, +}; #[cfg(unix)] use std::path::PathBuf; @@ -33,6 +36,197 @@ fn run_source_host_error(source: &str) -> String { } } +#[test] +fn io_policy_denies_process_launch_when_process_capability_is_disabled() { + let compiled = compile_source( + r#" + use io; + io::popen("exit 0", "r"); + "#, + ) + .expect("source should compile"); + let mut registry = HostFunctionRegistry::restricted(); + registry.set_capability_profile( + CapabilityProfile::builder() + .allow_builtin(BuiltinFunction::IoPopen) + .io_policy(IoPolicy::default()) + .build(), + ); + let mut vm = Vm::new(compiled.program); + registry + .bind_vm_cached(&mut vm) + .expect("profile should bind"); + + let error = vm.run().expect_err("process launch should be denied"); + assert!(matches!(error, VmError::HostError(message) if message.contains("process capability"))); +} + +#[test] +fn io_policy_denies_paths_outside_allowed_roots() { + let compiled = compile_source( + r#" + use io; + io::exists("Cargo.toml"); + "#, + ) + .expect("source should compile"); + let mut registry = HostFunctionRegistry::restricted(); + registry.set_capability_profile( + CapabilityProfile::builder() + .allow_builtin(BuiltinFunction::IoExists) + .io_policy(IoPolicy::default()) + .build(), + ); + let mut vm = Vm::new(compiled.program); + registry + .bind_vm_cached(&mut vm) + .expect("profile should bind"); + + let error = vm.run().expect_err("path should be denied"); + assert!(matches!(error, VmError::HostError(message) if message.contains("allowed roots"))); +} + +#[cfg(unix)] +#[test] +fn io_policy_limits_write_size() { + let path = unique_temp_path("policy-write-limit"); + let compiled = compile_source(&format!( + r#" + use io; + let handle = io::open("{}", "w"); + io::write(handle, "four"); + "#, + path.display() + )) + .expect("source should compile"); + let policy = IoPolicy { + allowed_roots: vec![std::env::temp_dir().display().to_string()], + allow_write: true, + max_write_bytes: 3, + ..IoPolicy::default() + }; + let mut registry = HostFunctionRegistry::restricted(); + registry.set_capability_profile( + CapabilityProfile::builder() + .allow_builtin(BuiltinFunction::IoOpen) + .allow_builtin(BuiltinFunction::IoWrite) + .io_policy(policy) + .build(), + ); + let mut vm = Vm::new(compiled.program); + registry + .bind_vm_cached(&mut vm) + .expect("profile should bind"); + + assert!(matches!( + vm.run().expect("open should start"), + VmStatus::Waiting(_) + )); + vm.wait_for_host_op_blocking() + .expect("open should complete"); + let error = vm.resume().expect_err("oversized write should be denied"); + assert!(matches!(error, VmError::HostError(message) if message.contains("write limit"))); + let _ = std::fs::remove_file(path); +} + +#[cfg(unix)] +#[test] +fn io_policy_limits_read_all_size() { + let path = unique_temp_path("policy-read-limit"); + std::fs::write(&path, "four").expect("fixture should be written"); + let compiled = compile_source(&format!( + r#" + use io; + let handle = io::open("{}", "r"); + io::read_all(handle); + "#, + path.display() + )) + .expect("source should compile"); + let policy = IoPolicy { + allowed_roots: vec![std::env::temp_dir().display().to_string()], + max_read_bytes: 3, + ..IoPolicy::default() + }; + let mut registry = HostFunctionRegistry::restricted(); + registry.set_capability_profile( + CapabilityProfile::builder() + .allow_builtin(BuiltinFunction::IoOpen) + .allow_builtin(BuiltinFunction::IoReadAll) + .io_policy(policy) + .build(), + ); + let mut vm = Vm::new(compiled.program); + registry + .bind_vm_cached(&mut vm) + .expect("profile should bind"); + + assert!(matches!( + vm.run().expect("open should start"), + VmStatus::Waiting(_) + )); + vm.wait_for_host_op_blocking() + .expect("open should complete"); + assert!(matches!( + vm.resume().expect("read should start"), + VmStatus::Waiting(_) + )); + let error = vm + .wait_for_host_op_blocking() + .expect_err("oversized read should be denied"); + assert!(matches!(error, VmError::HostError(message) if message.contains("read limit"))); + let _ = std::fs::remove_file(path); +} + +#[cfg(unix)] +#[test] +fn io_policy_limits_read_line_size() { + let path = unique_temp_path("policy-read-line-limit"); + std::fs::write(&path, "four\n").expect("fixture should be written"); + let compiled = compile_source(&format!( + r#" + use io; + let handle = io::open("{}", "r"); + io::read_line(handle); + "#, + path.display() + )) + .expect("source should compile"); + let policy = IoPolicy { + allowed_roots: vec![std::env::temp_dir().display().to_string()], + max_read_bytes: 3, + ..IoPolicy::default() + }; + let mut registry = HostFunctionRegistry::restricted(); + registry.set_capability_profile( + CapabilityProfile::builder() + .allow_builtin(BuiltinFunction::IoOpen) + .allow_builtin(BuiltinFunction::IoReadLine) + .io_policy(policy) + .build(), + ); + let mut vm = Vm::new(compiled.program); + registry + .bind_vm_cached(&mut vm) + .expect("profile should bind"); + + assert!(matches!( + vm.run().expect("open should start"), + VmStatus::Waiting(_) + )); + vm.wait_for_host_op_blocking() + .expect("open should complete"); + assert!(matches!( + vm.resume().expect("read should start"), + VmStatus::Waiting(_) + )); + let error = vm + .wait_for_host_op_blocking() + .expect_err("oversized line should be denied"); + assert!(matches!(error, VmError::HostError(message) if message.contains("read limit"))); + let _ = std::fs::remove_file(path); +} + #[cfg(unix)] fn unique_temp_path(label: &str) -> PathBuf { let nonce = SystemTime::now() diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 89fc659..335a8d6 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,8 +1,8 @@ #![allow(unused_imports)] pub use vm::{ - Assembler, BytecodeBuilder, CallOutcome, CompileSourceFileOptions, Compiler, Expr, - HostArgsFunction, HostFunction, HostFunctionRegistry, Program, SourceFlavor, + Assembler, BytecodeBuilder, CallOutcome, CapabilityProfile, CompileSourceFileOptions, Compiler, + Expr, HostArgsFunction, HostFunction, HostFunctionRegistry, Program, SourceFlavor, StaticHostArgsFunction, Stmt, Store, Value, Vm, VmStatus, assemble, compile_source, compile_source_file, compile_source_file_with_options, compile_source_with_flavor, }; diff --git a/tests/host_binding_generation_tests.rs b/tests/host_binding_generation_tests.rs index 3e44c1a..ec15acd 100644 --- a/tests/host_binding_generation_tests.rs +++ b/tests/host_binding_generation_tests.rs @@ -6,7 +6,10 @@ use build_script::{ HostBindingKind, HostExecutionKind, classify_host_binding, infer_host_execution, }; use syn::parse_quote; -use vm::{HostFunctionRegistry, JitConfig, JitTraceTerminal, Value, Vm, VmStatus, compile_source}; +use vm::{ + BuiltinFunction, CapabilityProfile, HostFunctionRegistry, JitConfig, JitTraceTerminal, Value, + Vm, VmStatus, compile_source, +}; fn native_jit_supported() -> bool { (cfg!(target_arch = "x86_64") @@ -230,11 +233,15 @@ fn restricted_capabilities_disable_trace_jit_for_host_imports_and_builtins() { hot_loop_threshold: 1, max_trace_len: 512, }); - HostFunctionRegistry::restricted() + let error = HostFunctionRegistry::restricted() .bind_vm_cached(&mut vm) - .expect("restricted registry should bind program imports"); + .expect_err("restricted registry should reject ungranted capability during preflight"); - assert!(matches!(vm.run(), Err(vm::VmError::UnboundImport(_)))); + assert!( + error + .to_string() + .contains("capability profile does not allow") + ); assert_eq!(vm.jit_native_exec_count(), 0); } } @@ -264,3 +271,106 @@ fn runtime_exit_still_halts_for_direct_and_cached_default_bindings() { assert!(vm.stack().is_empty()); } } + +#[test] +fn capability_profile_fingerprint_uses_stable_callable_identities() { + let first = CapabilityProfile::builder() + .allow_builtin(BuiltinFunction::JsonEncode) + .allow_host_import("custom::echo") + .build(); + let reordered = CapabilityProfile::builder() + .allow_host_import("custom::echo") + .allow_builtin(BuiltinFunction::JsonEncode) + .build(); + + assert_eq!(first, reordered); + assert_eq!(first.fingerprint(), reordered.fingerprint()); + assert!(first.allows_builtin(BuiltinFunction::JsonEncode)); + assert!(first.allows_host_import("custom::echo")); + assert!(!first.allows_host_import("custom::other")); + assert_ne!( + first.fingerprint(), + CapabilityProfile::deny_all().fingerprint() + ); + assert_ne!( + CapabilityProfile::allow_all().fingerprint(), + CapabilityProfile::deny_all().fingerprint() + ); +} + +#[test] +fn capability_profile_fingerprint_covers_http_policy() { + let first_policy = vm::HttpConfig { + allowed_hosts: vec!["example.com".to_string()], + max_redirects: 1, + ..vm::HttpConfig::default() + }; + let second_policy = vm::HttpConfig { + allowed_hosts: vec!["example.com".to_string()], + max_redirects: 2, + ..vm::HttpConfig::default() + }; + let first = CapabilityProfile::builder() + .http_policy(first_policy) + .build(); + let second = CapabilityProfile::builder() + .http_policy(second_policy) + .build(); + + assert_eq!(first.http_policy().expect("HTTP policy").max_redirects, 1); + assert_ne!(first.fingerprint(), second.fingerprint()); +} + +#[test] +fn capability_profile_fingerprint_covers_io_policy() { + let first = CapabilityProfile::builder() + .io_policy(vm::IoPolicy { + allowed_roots: vec!["/tmp/b".to_string(), "/tmp/a".to_string()], + max_read_bytes: 10, + ..vm::IoPolicy::default() + }) + .build(); + let reordered = CapabilityProfile::builder() + .io_policy(vm::IoPolicy { + allowed_roots: vec!["/tmp/a".to_string(), "/tmp/b".to_string()], + max_read_bytes: 10, + ..vm::IoPolicy::default() + }) + .build(); + let changed = CapabilityProfile::builder() + .io_policy(vm::IoPolicy { + allowed_roots: vec!["/tmp/a".to_string(), "/tmp/b".to_string()], + max_read_bytes: 11, + ..vm::IoPolicy::default() + }) + .build(); + + assert_eq!(first, reordered); + assert_eq!(first.fingerprint(), reordered.fingerprint()); + assert_ne!(first.fingerprint(), changed.fingerprint()); +} + +#[cfg(feature = "sqlite")] +#[test] +fn capability_profile_fingerprint_covers_sqlite_policy() { + let mut first_policy = vm::SqlitePolicy::default(); + first_policy.limits.max_rows = 10; + let mut second_policy = first_policy.clone(); + second_policy.limits.max_rows = 11; + let first = CapabilityProfile::builder() + .sqlite_policy(first_policy) + .build(); + let second = CapabilityProfile::builder() + .sqlite_policy(second_policy) + .build(); + + assert_eq!( + first + .sqlite_policy() + .expect("SQLite policy") + .limits + .max_rows, + 10 + ); + assert_ne!(first.fingerprint(), second.fingerprint()); +} diff --git a/tests/vm/http_host_tests.rs b/tests/vm/http_host_tests.rs index 2e30abd..9706da3 100644 --- a/tests/vm/http_host_tests.rs +++ b/tests/vm/http_host_tests.rs @@ -135,13 +135,10 @@ fn empty_registry_keeps_language_builtins_but_rejects_http_capability() { ); let mut http_vm = Vm::new(build_request_program("http://127.0.0.1:1/".to_string())); - HostFunctionRegistry::restricted() + let error = HostFunctionRegistry::restricted() .bind_vm_cached(&mut http_vm) - .expect("empty registry should prepare the program"); - let error = http_vm - .run() - .expect_err("unapproved HTTP capability must be rejected"); - assert!(matches!(error, vm::VmError::UnboundImport(name) if name == "http::client::request")); + .expect_err("unapproved HTTP capability must fail during preflight"); + assert!(error.to_string().contains("http::client::request")); } #[test] @@ -152,13 +149,10 @@ io::open("/tmp/rustscript-capability-test", "r");"#, ) .expect("namespaced host builtin should compile"); let mut vm = Vm::new(compiled.program); - HostFunctionRegistry::restricted() + let error = HostFunctionRegistry::restricted() .bind_vm_cached(&mut vm) - .expect("restricted registry should bind namespaced host builtin"); - let error = vm - .run() - .expect_err("restricted registry should reject ungranted namespaced builtin"); - assert!(matches!(error, vm::VmError::UnboundImport(name) if name == "io_open")); + .expect_err("ungranted namespaced builtin must fail during preflight"); + assert!(error.to_string().contains("io_open")); } #[test] @@ -189,13 +183,16 @@ fn capability_binding_plan_cannot_outlive_registry_mutation() { let error = registry .bind_vm_with_plan(&mut vm, &plan) .expect_err("stale capability plan must not bind"); - assert!(error.to_string().contains("different capability state")); + assert!(error.to_string().contains("different capability profile")); } #[test] fn capability_binding_plan_detects_divergent_registry_clone_mutations() { let unchanged_program = build_request_program("http://127.0.0.1:1/".to_string()); - let unchanged_registry = HostFunctionRegistry::restricted(); + let mut unchanged_registry = HostFunctionRegistry::restricted(); + unchanged_registry + .allow_builtin("http::client::request") + .expect("HTTP capability should be known"); let unchanged_plan = unchanged_registry .prepare_plan(&unchanged_program.imports) .expect("restricted registry should prepare HTTP plan"); @@ -222,7 +219,7 @@ fn capability_binding_plan_detects_divergent_registry_clone_mutations() { let error = second_mutation .bind_vm_with_plan(&mut mutated_vm, &plan) .expect_err("divergent capability branches must reject each other's plan"); - assert!(error.to_string().contains("different capability state")); + assert!(error.to_string().contains("different capability profile")); } #[test] diff --git a/tests/vm/vm_runtime_tests.rs b/tests/vm/vm_runtime_tests.rs index 8fa22ef..92536a8 100644 --- a/tests/vm/vm_runtime_tests.rs +++ b/tests/vm/vm_runtime_tests.rs @@ -45,6 +45,39 @@ fn empty_registry_allows_functions_registered_by_the_embedder() { assert_eq!(vm.stack(), &[Value::Int(42)]); } +#[test] +fn explicit_capability_profile_authorizes_host_imports_during_preflight() { + let program = compile_source("fn action() -> int; action();") + .expect("host call source should compile") + .program; + let mut registry = HostFunctionRegistry::empty(); + registry.register_static_args("action", 0, returns_registered_value); + registry.set_capability_profile(CapabilityProfile::deny_all()); + + let mut denied = Vm::new(program.clone()); + let error = registry + .bind_vm_cached(&mut denied) + .expect_err("deny-all profile must reject the host import during binding"); + assert!(error.to_string().contains("capability")); + + let mut allowed_registry = HostFunctionRegistry::empty(); + allowed_registry.set_capability_profile( + CapabilityProfile::builder() + .allow_host_import("action") + .build(), + ); + allowed_registry.register_static_args("action", 0, returns_registered_value); + let mut allowed = Vm::new(program); + allowed_registry + .bind_vm_cached(&mut allowed) + .expect("allowed host import should bind"); + assert_eq!( + allowed.run().expect("host call should run"), + VmStatus::Halted + ); + assert_eq!(allowed.stack(), &[Value::Int(42)]); +} + #[test] fn empty_registry_preserves_default_builtin_capabilities() { let compiled = compile_source("use bytes; bytes::from_array_u8([1, 2, 3]);") @@ -58,29 +91,81 @@ fn empty_registry_preserves_default_builtin_capabilities() { assert_eq!(vm.stack(), &[Value::bytes(vec![1, 2, 3])]); } +#[test] +fn explicit_capability_profile_authorizes_builtin_calls_during_preflight() { + let program = compile_source("use bytes; bytes::from_array_u8([1, 2, 3]);") + .expect("bytes source should compile") + .program; + let mut registry = HostFunctionRegistry::empty(); + registry.set_capability_profile(CapabilityProfile::deny_all()); + + let mut denied = Vm::new(program.clone()); + let error = registry + .bind_vm_cached(&mut denied) + .expect_err("deny-all profile must reject builtin calls during binding"); + assert!(error.to_string().contains("capability")); + + registry.set_capability_profile( + CapabilityProfile::builder() + .allow_builtin(vm::BuiltinFunction::BytesFromArrayU8) + .build(), + ); + let mut allowed = Vm::new(program); + registry + .bind_vm_cached(&mut allowed) + .expect("allowed builtin should bind"); + assert_eq!( + allowed.run().expect("builtin call should run"), + VmStatus::Halted + ); + assert_eq!(allowed.stack(), &[Value::bytes(vec![1, 2, 3])]); +} + +#[test] +fn explicit_capability_profile_rejects_builtin_callable_metadata_during_preflight() { + let mut program = Program::new(Vec::new(), vec![OpCode::Ret as u8]); + program.callable_prototypes.push(vm::CallablePrototype { + kind: vm::CallableKind::HostFunction, + target: vm::CallableTarget::HostImport(vm::BuiltinFunction::BytesFromArrayU8.call_index()), + arity: 1, + frame_local_count: 0, + parameter_slots: Vec::new(), + capture_source_slots: Vec::new(), + capture_slots: Vec::new(), + capture_modes: Vec::new(), + self_slot: None, + schema: None, + }); + let mut vm = Vm::new(program); + let mut registry = HostFunctionRegistry::empty(); + registry.set_capability_profile(CapabilityProfile::deny_all()); + + let error = registry + .bind_vm_cached(&mut vm) + .expect_err("builtin callable metadata must be authorized during binding"); + assert!(error.to_string().contains("capability")); +} + #[cfg(feature = "cranelift-jit")] #[test] -fn restricted_builtin_capabilities_match_between_interpreter_and_aot() { +fn restricted_builtin_capabilities_are_rejected_before_interpreter_or_aot_execution() { let source = "use bytes; bytes::from_array_u8([1, 2, 3]);"; let program = compile_source(source) .expect("bytes source should compile") .program; let mut interpreter = Vm::new(program.clone()); - HostFunctionRegistry::restricted() + let interpreter_error = HostFunctionRegistry::restricted() .bind_vm_cached(&mut interpreter) - .expect("restricted registry should bind"); - assert!(matches!( - interpreter.run(), - Err(vm::VmError::UnboundImport(_)) - )); + .expect_err("restricted profile should reject before interpreter execution"); let mut aot = Vm::new(program); - HostFunctionRegistry::restricted() - .bind_vm_cached(&mut aot) - .expect("restricted registry should bind"); aot.compile_aot().expect("AOT compile should succeed"); - assert!(matches!(aot.run(), Err(vm::VmError::UnboundImport(_)))); + let aot_error = HostFunctionRegistry::restricted() + .bind_vm_cached(&mut aot) + .expect_err("restricted profile should reject before AOT execution"); + assert_eq!(interpreter_error.to_string(), aot_error.to_string()); + assert!(interpreter_error.to_string().contains("capability")); } #[test] @@ -404,11 +489,10 @@ fn builtin_override_does_not_bypass_restricted_capability_profile() { .program; let mut denied = Vm::new(program.clone()); - HostFunctionRegistry::restricted() + let error = HostFunctionRegistry::restricted() .bind_vm_cached(&mut denied) - .expect("restricted registry should bind"); - denied.bind_function("io::exists", Box::new(ExistsOverride)); - assert!(matches!(denied.run(), Err(vm::VmError::UnboundImport(_)))); + .expect_err("restricted profile should reject before override installation"); + assert!(error.to_string().contains("capability")); let mut allowed_registry = HostFunctionRegistry::restricted(); allowed_registry