From db543da0f1e92a39540c56dac46df6c96a42a0de Mon Sep 17 00:00:00 2001 From: fffonion Date: Wed, 26 Aug 2026 08:02:02 +0800 Subject: [PATCH] feat(host): add capability profiles and async host execution --- Cargo.lock | 58 + Cargo.toml | 15 +- build.rs | 67 +- crates/pd-host-schema/Cargo.toml | 12 + crates/pd-host-schema/src/lib.rs | 632 ++++++ pd-host-function/Cargo.toml | 1 + pd-host-function/src/lib.rs | 528 ++++- src/builtins/runtime/io/async_io.rs | 611 +++++ .../runtime/{io.rs => io/blocking.rs} | 89 +- src/builtins/runtime/io/mod.rs | 82 + src/builtins/runtime/mod.rs | 18 +- src/builtins/runtime/standard_composition.rs | 75 + src/builtins/runtime/typed.rs | 49 +- src/host_api.rs | 1966 +++++++++++++++++ src/lib.rs | 35 +- src/vm/async_host/mod.rs | 122 + src/vm/capability.rs | 168 ++ src/vm/execution_scope.rs | 7 + src/vm/host.rs | 354 ++- src/vm/host_context.rs | 530 +++++ src/vm/host_extension.rs | 264 +++ src/vm/host_runtime.rs | 81 +- src/vm/mod.rs | 44 + src/vm/resource/close.rs | 15 + src/vm/standard_composition.rs | 77 + src/vm/tests.rs | 61 + tests/builtins/io_async_tests.rs | 110 + tests/builtins/io_builtin_edge_tests.rs | 128 +- tests/builtins/stdlib_tests.rs | 2 + tests/builtins_tests.rs | 10 + tests/compiler/compiler_rustscript_tests.rs | 4 + tests/compiler_tests.rs | 4 + .../external-host-extension/.gitignore | 1 + .../external-host-extension/Cargo.lock | 319 +++ .../external-host-extension/Cargo.toml | 18 + .../external-host-extension/src/lib.rs | 399 ++++ tests/host_binding_generation_tests.rs | 114 +- tests/host_context_arch_tests.rs | 134 ++ tests/host_sdk_tests.rs | 239 ++ tests/support/async_test_bridge.rs | 71 + 40 files changed, 7452 insertions(+), 62 deletions(-) create mode 100644 crates/pd-host-schema/Cargo.toml create mode 100644 crates/pd-host-schema/src/lib.rs create mode 100644 src/builtins/runtime/io/async_io.rs rename src/builtins/runtime/{io.rs => io/blocking.rs} (93%) create mode 100644 src/builtins/runtime/io/mod.rs create mode 100644 src/builtins/runtime/standard_composition.rs create mode 100644 src/host_api.rs create mode 100644 src/vm/async_host/mod.rs create mode 100644 src/vm/capability.rs create mode 100644 src/vm/host_context.rs create mode 100644 src/vm/host_extension.rs create mode 100644 src/vm/standard_composition.rs create mode 100644 tests/builtins/io_async_tests.rs create mode 100644 tests/fixtures/external-host-extension/.gitignore create mode 100644 tests/fixtures/external-host-extension/Cargo.lock create mode 100644 tests/fixtures/external-host-extension/Cargo.toml create mode 100644 tests/fixtures/external-host-extension/src/lib.rs create mode 100644 tests/host_context_arch_tests.rs create mode 100644 tests/host_sdk_tests.rs create mode 100644 tests/support/async_test_bridge.rs diff --git a/Cargo.lock b/Cargo.lock index a2c25e9e..6a3b5704 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -68,6 +68,12 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cc" version = "1.4.4" @@ -481,6 +487,17 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "nibble_vec" version = "0.1.0" @@ -528,6 +545,7 @@ dependencies = [ name = "pd-host-function" version = "0.1.0" dependencies = [ + "pd-host-schema", "proc-macro2", "quote", "syn", @@ -544,6 +562,14 @@ dependencies = [ "syn", ] +[[package]] +name = "pd-host-schema" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "pd-vm" version = "0.1.0" @@ -810,12 +836,32 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -845,8 +891,14 @@ version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ + "bytes", + "libc", + "mio", "pin-project-lite", + "signal-hook-registry", + "socket2", "tokio-macros", + "windows-sys 0.61.2", ] [[package]] @@ -896,6 +948,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasmtime-internal-core" version = "42.0.1" diff --git a/Cargo.toml b/Cargo.toml index c59d0a0e..1a779ea3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "pd-vm-nostd", "pd-vm-wasm", "crates/rustscript", + "crates/pd-host-schema", ] resolver = "2" @@ -27,6 +28,7 @@ name = "vm" [features] default = ["runtime", "cli", "cranelift-jit"] runtime = [] +async = ["runtime", "dep:tokio"] sqlite = ["runtime", "dep:rusqlite"] edge-abi = [ "dep:edge_abi", @@ -62,11 +64,12 @@ cranelift-module = { version = "0.129.1", optional = true } cranelift-native = { version = "0.129.1", optional = true } pd-host-function = { path = "./pd-host-function", version = "0.1.0" } rusqlite = { version = "0.32", default-features = false, features = ["bundled", "hooks", "limits"], optional = true } +tokio = { version = "1", features = ["rt-multi-thread", "net", "time", "sync", "fs", "io-util", "process"], optional = true } edge_abi = { package = "pd-edge-abi", version = "0.1.1", default-features = false, optional = true } futures-channel = "0.3" paste = "1" regex = "1" -serde = "1" +serde = { version = "1", features = ["derive"] } serde_json = "1" rt-format = "0.3.1" self_cell = "1" @@ -87,5 +90,15 @@ name = "host_binding_generation_tests" path = "tests/host_binding_generation_tests.rs" required-features = ["cranelift-jit"] +[[test]] +name = "host_sdk_tests" +path = "tests/host_sdk_tests.rs" +required-features = ["runtime"] + +[[test]] +name = "host_context_arch_tests" +path = "tests/host_context_arch_tests.rs" +required-features = ["runtime"] + [build-dependencies] syn = { version = "2", features = ["full"] } diff --git a/build.rs b/build.rs index 44296509..33dae8b8 100644 --- a/build.rs +++ b/build.rs @@ -246,10 +246,21 @@ fn write_generated_file(path: &Path, contents: &str) { fn builtin_source_specs(namespaces: &[NamespaceDecl]) -> Vec { namespaces .iter() - .map(|namespace| SourceSpec { - path: format!("src/builtins/runtime/{}.rs", namespace.module), - module: namespace.module.clone(), - category: SourceCategory::NamespacedBuiltin, + .map(|namespace| { + let path = if namespace.module == "io" { + if cfg!(feature = "async") { + "src/builtins/runtime/io/async_io.rs".to_string() + } else { + "src/builtins/runtime/io/blocking.rs".to_string() + } + } else { + format!("src/builtins/runtime/{}.rs", namespace.module) + }; + SourceSpec { + path, + module: namespace.module.clone(), + category: SourceCategory::NamespacedBuiltin, + } }) .collect() } @@ -271,6 +282,9 @@ fn parse_sources( } pub(crate) fn classify_host_binding(function: &ItemFn) -> HostBindingKind { + if function.sig.asyncness.is_some() { + return HostBindingKind::StaticStack; + } if function.sig.inputs.iter().any(|input| match input { FnArg::Typed(pat_type) => is_vm_context_type(&pat_type.ty), _ => false, @@ -296,6 +310,9 @@ pub(crate) fn classify_host_binding(function: &ItemFn) -> HostBindingKind { } pub(crate) fn infer_host_execution(function: &ItemFn) -> HostExecutionKind { + if function.sig.asyncness.is_some() { + return HostExecutionKind::MaySuspend; + } let return_type = normalized_return_type(&function.sig.output); if contains_host_call_result(&return_type) { HostExecutionKind::MaySuspend @@ -979,6 +996,7 @@ fn render_builtin_catalog( &actual_builtin_by_variant, ); render_builtin_signature_method(&mut out, &builtin_variant_order); + render_builtin_capability_method(&mut out, builtin_callables); writeln!( &mut out, " pub fn from_namespaced_name(name: &str) -> Option {{" @@ -1553,6 +1571,35 @@ fn required_param_count(params: &[CallableParamDecl]) -> usize { params.iter().take_while(|param| !param.optional).count() } +fn render_builtin_capability_method(out: &mut String, builtin_callables: &[CallableDecl]) { + let mut capability_variants = Vec::new(); + for callable in builtin_callables { + let variant = builtin_variant_name(&callable.name); + if !capability_variants.contains(&variant) { + capability_variants.push(variant); + } + } + capability_variants.sort(); + writeln!(out, " #[cfg(feature = \"runtime\")]").unwrap(); + writeln!( + out, + " pub(crate) const fn requires_explicit_host_capability(self) -> bool {{" + ) + .unwrap(); + if capability_variants.is_empty() { + writeln!(out, " false").unwrap(); + } else { + let patterns = capability_variants + .iter() + .map(|variant| format!("BuiltinFunction::{variant}")) + .collect::>() + .join(" | "); + writeln!(out, " matches!(self, {patterns})").unwrap(); + } + writeln!(out, " }}").unwrap(); + writeln!(out).unwrap(); +} + fn stable_groups(callables: &[CallableDecl], mut key_fn: F) -> Vec> where F: FnMut(&CallableDecl) -> String, @@ -1863,6 +1910,9 @@ fn host_wrapper_adapter_name(callable: &CallableDecl) -> String { fn generated_wrapper_decl(function: &ItemFn) -> WrapperDecl { let mut params = Vec::new(); + if function.sig.asyncness.is_some() { + params.push(WrapperParamKind::Vm); + } for input in &function.sig.inputs { let FnArg::Typed(pat_type) = input else { panic!("methods are not supported in #[pd_host_function] declarations"); @@ -1889,6 +1939,13 @@ fn parse_callable_params(function: &ItemFn) -> Vec { let FnArg::Typed(pat_type) = input else { panic!("methods are not supported in #[pd_host_function] declarations"); }; + if pat_type + .attrs + .iter() + .any(|attr| attr.path().is_ident("pd_host_context")) + { + return None; + } if is_vm_context_type(&pat_type.ty) { return None; } @@ -2055,7 +2112,7 @@ fn type_label(ty: &Type) -> String { }; format!("{} | null", type_label(inner)) } - "VmResult" | "HostCallResult" => { + "VmResult" | "HostCallResult" | "HostFutureOutput" => { let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { panic!("{ident} requires one generic argument"); }; diff --git a/crates/pd-host-schema/Cargo.toml b/crates/pd-host-schema/Cargo.toml new file mode 100644 index 00000000..ee17bb65 --- /dev/null +++ b/crates/pd-host-schema/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "pd-host-schema" +version.workspace = true +edition.workspace = true +description = "Shared host-schema parsing for the pd-host-function proc macro and the pd-vm build script" +license = "MIT" +homepage = "https://rustscript.org/" +repository = "https://github.com/rustscript-lang/rustscript" + +[dependencies] +proc-macro2 = "1" +syn = { version = "2", features = ["full", "extra-traits"] } diff --git a/crates/pd-host-schema/src/lib.rs b/crates/pd-host-schema/src/lib.rs new file mode 100644 index 00000000..88fe9af5 --- /dev/null +++ b/crates/pd-host-schema/src/lib.rs @@ -0,0 +1,632 @@ +//! Canonical host-schema parsing shared by the `pd-host-function` proc macro +//! and the `pd-vm` build script. +//! +//! Both expansion paths must agree on how resource parameters are recognized +//! (the `ResourceRef` / `ResourceMut` / `ResourceOwned` wrappers plus the +//! `#[pd_host_param(passing = ..., key = ...)]` family of attributes), how the +//! resulting schema labels look, and which resource type keys are legal. +//! Centralizing those rules here guarantees that the descriptor generated by +//! the proc macro (ordered label / schema / passing / key) can never drift +//! from the descriptor the build script computes for the same signature. +//! +//! The crate is deliberately runtime-free (only `syn`/`proc-macro2`): it is +//! linked by a `proc-macro` crate and by a `build.rs`, neither of which can +//! depend on the VM. + +use std::fmt; + +use syn::{Attribute, GenericArgument, LitStr, Meta, PathArguments, Type}; + +/// Maximum byte length of a validated resource type key. +/// +/// This mirrors `pd_vm::host_api`'s `MAX_RESOURCE_KEY_LEN`; the proc macro and +/// the build script reject keys at expansion time with the exact same rules +/// the runtime applies, so an invalid key can never reach a runtime +/// `.expect()` panic. +pub const MAX_RESOURCE_KEY_LEN: usize = 128; + +/// Why a resource type key literal is invalid. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ResourceKeyError { + Empty, + TooLong(usize), + InvalidChar { index: usize, ch: char }, + InvalidDotPlacement { index: usize }, +} + +impl fmt::Display for ResourceKeyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => write!(f, "resource type key must not be empty"), + Self::TooLong(len) => write!( + f, + "resource type key is {len} bytes; the maximum is {MAX_RESOURCE_KEY_LEN}" + ), + Self::InvalidChar { index, ch } => write!( + f, + "resource type key contains invalid character {ch:?} at byte offset {index}" + ), + Self::InvalidDotPlacement { index } => write!( + f, + "resource type key contains an empty namespace segment at byte offset {index}" + ), + } + } +} + +impl std::error::Error for ResourceKeyError {} + +/// Validates a resource type key with the same rules as +/// `pd_vm::host_api::ResourceTypeKey::new`. +pub fn validate_resource_key(name: &str) -> Result<(), ResourceKeyError> { + if name.is_empty() { + return Err(ResourceKeyError::Empty); + } + if name.len() > MAX_RESOURCE_KEY_LEN { + return Err(ResourceKeyError::TooLong(name.len())); + } + // Allowed: ASCII lowercase a-z, 0-9, '_' and '-', with '.' used purely as + // a namespace separator between non-empty segments. + for (index, b) in name.bytes().enumerate() { + let valid = b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-' | b'.'); + if !valid { + return Err(ResourceKeyError::InvalidChar { + index, + ch: name[index..].chars().next().unwrap_or('\u{fffd}'), + }); + } + } + // Report the exact byte offset of each empty segment: a '.' that directly + // follows another '.' (or the leading dot) opens an empty segment at that + // dot, and a trailing '.' leaves an empty segment at the end of the name. + let mut segment_start = 0usize; + for (index, b) in name.bytes().enumerate() { + if b == b'.' { + if index == segment_start { + return Err(ResourceKeyError::InvalidDotPlacement { index }); + } + segment_start = index + 1; + } + } + if segment_start == name.len() { + return Err(ResourceKeyError::InvalidDotPlacement { + index: segment_start, + }); + } + Ok(()) +} + +/// The four resource passing modes the adapter layer understands. +/// +/// `to_owned` is **not** a host passing mode: a guest-side `to_owned()` +/// expression is ordinary `Value` passing, and asking the adapter for a +/// resource-containing `to_owned` frame is rejected with an explicit +/// "reserved" error instead of being silently aliased to `Value` or +/// `TakeOwned`. +/// +/// This mirrors `pd_vm::vm::resource::ResourceAccessMode`; it is kept +/// runtime-free here so both the proc macro and the build script can share the +/// parsing rules. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ResourceMode { + Borrow, + BorrowMut, + TakeOwned, + Value, +} + +/// The coarse host-parameter passing categories emitted into catalog metadata. +/// This mirrors `pd_vm::host_api::HostParamPassing`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HostPassing { + Value, + Borrow, + BorrowMut, + TakeOwned, +} + +impl fmt::Display for HostPassing { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Value => "value", + Self::Borrow => "borrow", + Self::BorrowMut => "borrow_mut", + Self::TakeOwned => "take_owned", + }) + } +} + +impl ResourceMode { + /// Normalizes and parses a `passing = "..."` string literal. + /// + /// `to_owned` / `toowned` are explicitly **reserved**: they return an error + /// naming the reserved mode rather than silently aliasing it to `Value` or + /// `TakeOwned`. + pub fn parse(value: &str) -> Result { + let normalized = value.to_ascii_lowercase().replace('-', "_"); + match normalized.as_str() { + "borrow" => Ok(Self::Borrow), + "borrow_mut" | "borrowmut" => Ok(Self::BorrowMut), + "take_owned" | "takeowned" | "owned" => Ok(Self::TakeOwned), + "to_owned" | "toowned" => Err( + "to_owned passing is reserved and unsupported; use take_owned to transfer \ + resource ownership" + .to_string(), + ), + "value" => Ok(Self::Value), + _ => { + Err("resource passing must be borrow, borrow_mut, take_owned, or value".to_string()) + } + } + } + + /// The catalog passing category. There is no host `ToOwned` category: the + /// only non-resource category is `Value`. + pub fn host_passing(self) -> HostPassing { + match self { + Self::Borrow => HostPassing::Borrow, + Self::BorrowMut => HostPassing::BorrowMut, + Self::TakeOwned => HostPassing::TakeOwned, + Self::Value => HostPassing::Value, + } + } + + /// Whether accessing this mode consumes the resource slot. + pub const fn is_consuming(self) -> bool { + matches!(self, Self::TakeOwned) + } +} + +/// Schema label used for a resource parameter or return (mirrors the proc +/// macro's `"resource"` label and the runtime `HostTypeSchema::Resource`). +pub const RESOURCE_SCHEMA_LABEL: &str = "resource"; + +/// Parsed resource parameter/return metadata. +#[derive(Clone, Debug)] +pub struct ResourceSpec { + /// The resolved passing mode (from the canonical wrapper or the attribute). + pub mode: ResourceMode, + /// The concrete resource type. For canonical wrappers this is the wrapper's + /// type argument; for annotation-only declarations it is the declared type. + pub inner: Type, + /// Whether the declaration used a canonical owning wrapper (`ResourceOwned`). + pub owned_wrapper: bool, + /// An explicit `key = "..."` literal if one was declared. Already validated. + pub key: Option, +} + +/// Kind of a resource *return* type. Only the owned `Resource` wrapper may +/// cross the host boundary; borrowed wrappers must be rejected by callers. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ResourceReturnKind { + /// `Resource` — an owned handle token. + Owned, + /// `ResourceRef<'_, T>` — a borrow that must not cross the boundary. + Borrow, + /// `ResourceMut<'_, T>` — a mutable borrow that must not cross the boundary. + BorrowMut, +} + +/// Unwraps grouping/parenthesized surface syntax. +fn unwrap_surface(ty: &Type) -> &Type { + let mut current = ty; + loop { + current = match current { + Type::Group(group) => &group.elem, + Type::Paren(paren) => &paren.elem, + other => return other, + }; + } +} + +/// The final path segment identifier of the surface type, if it is a path. +pub fn path_last_ident(ty: &Type) -> Option { + let ty = unwrap_surface(ty); + let Type::Path(path) = ty else { + return None; + }; + path.path + .segments + .last() + .map(|segment| segment.ident.to_string()) +} + +/// Parses the resource-passing attributes on a parameter into an explicit mode +/// and an explicit key. Mirrors the proc macro's `parse_resource_attrs`. +pub fn parse_resource_attrs( + attrs: &[Attribute], +) -> Result<(Option, Option), String> { + let mut mode = None; + let mut key = None; + for attr in attrs { + let path = attr.path(); + if path.is_ident("pd_borrow") { + mode = Some(ResourceMode::Borrow); + continue; + } + if path.is_ident("pd_borrow_mut") { + mode = Some(ResourceMode::BorrowMut); + continue; + } + if path.is_ident("pd_take_owned") { + mode = Some(ResourceMode::TakeOwned); + continue; + } + if path.is_ident("pd_to_owned") { + return Err( + "pd_to_owned is reserved and unsupported; use pd_take_owned to transfer \ + resource ownership" + .to_string(), + ); + } + if path.is_ident("pd_value") { + mode = Some(ResourceMode::Value); + continue; + } + if !(path.is_ident("pd_host_param") + || path.is_ident("pd_host_resource") + || path.is_ident("pd_host_passing")) + { + continue; + } + match &attr.meta { + Meta::Path(_) => {} + Meta::NameValue(name_value) => { + let syn::Expr::Lit(expr_lit) = &name_value.value else { + return Err("resource passing metadata must be a string literal".to_string()); + }; + let syn::Lit::Str(value) = &expr_lit.lit else { + return Err("resource passing metadata must be a string literal".to_string()); + }; + if name_value.path.is_ident("passing") || path.is_ident("pd_host_passing") { + mode = Some(ResourceMode::parse(value.value().as_str())?); + } else if name_value.path.is_ident("key") { + key = Some(value.value()); + } else { + return Err("expected passing = \"...\" or key = \"...\"".to_string()); + } + } + Meta::List(_) => { + attr.parse_nested_meta(|nested| { + if nested.path.is_ident("borrow") { + mode = Some(ResourceMode::Borrow); + return Ok(()); + } + if nested.path.is_ident("borrow_mut") || nested.path.is_ident("borrowmut") { + mode = Some(ResourceMode::BorrowMut); + return Ok(()); + } + if nested.path.is_ident("take_owned") + || nested.path.is_ident("takeowned") + || nested.path.is_ident("owned") + { + mode = Some(ResourceMode::TakeOwned); + return Ok(()); + } + if nested.path.is_ident("to_owned") || nested.path.is_ident("toowned") { + return Err(nested.error( + "to_owned is reserved and unsupported; use take_owned to transfer \ + resource ownership", + )); + } + if nested.path.is_ident("value") { + mode = Some(ResourceMode::Value); + return Ok(()); + } + if nested.path.is_ident("passing") { + let value: LitStr = nested.value()?.parse()?; + mode = Some( + ResourceMode::parse(value.value().as_str()) + .map_err(|msg| syn::Error::new(value.span(), msg))?, + ); + return Ok(()); + } + if nested.path.is_ident("key") { + key = Some(nested.value()?.parse::()?.value()); + return Ok(()); + } + Err(nested.error( + "expected a resource passing mode, passing = \"...\", or key = \"...\"", + )) + }) + .map_err(|err| err.to_string())?; + } + } + } + Ok((mode, key)) +} + +/// The canonical resource wrapper names that the adapter can expand reliably. +pub const CANONICAL_WRAPPERS: [&str; 3] = ["ResourceRef", "ResourceMut", "ResourceOwned"]; + +/// Whether `ident` names a canonical resource wrapper. +pub fn is_canonical_wrapper(ident: &str) -> bool { + matches!(ident, "ResourceRef" | "ResourceMut" | "ResourceOwned") +} + +/// Extracts the single concrete type argument of a path segment (the last type +/// argument, so a `ResourceRef<'_, T>` lifetime prefix is skipped). +pub fn generic_type_argument(segment: &syn::PathSegment) -> Result { + let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { + return Err("resource wrapper requires one concrete resource type".to_string()); + }; + args.args + .iter() + .rev() + .find_map(|arg| match arg { + GenericArgument::Type(ty) => Some(ty.clone()), + _ => None, + }) + .ok_or_else(|| "resource wrapper requires one concrete resource type".to_string()) +} + +/// Parses one parameter into resource metadata, or `None` for an ordinary +/// parameter. This is the single canonical rule used by both the proc macro +/// and the build script, so their descriptors can never diverge. +/// +/// Errors are plain messages; the proc macro re-spans them onto the parameter +/// type and the build script turns them into build failures. +pub fn resource_spec(ty: &Type, attrs: &[Attribute]) -> Result, String> { + let (explicit_mode, key) = parse_resource_attrs(attrs)?; + let wrapper = path_last_ident(ty); + let Some(wrapper) = wrapper else { + return if explicit_mode.is_some() { + Err("resource passing metadata requires a concrete resource type".to_string()) + } else { + Ok(None) + }; + }; + let inferred = match wrapper.as_str() { + "ResourceRef" => Some((ResourceMode::Borrow, true)), + "ResourceMut" => Some((ResourceMode::BorrowMut, true)), + "ResourceOwned" => Some((ResourceMode::TakeOwned, true)), + _ => None, + }; + let Some((inferred_mode, owned_wrapper)) = + inferred.or_else(|| explicit_mode.map(|mode| (mode, false))) + else { + return Ok(None); + }; + let mode = explicit_mode.unwrap_or(inferred_mode); + if explicit_mode.is_some() && inferred.is_some() && mode != inferred_mode { + return Err("resource wrapper and passing metadata specify different modes".to_string()); + } + if matches!(mode, ResourceMode::Value) { + return Err( + "resource-containing Value parameters are rejected; use Borrow, BorrowMut, or TakeOwned" + .to_string(), + ); + } + // An explicit annotation on a bare identifier is a concrete resource type + // (e.g. `#[pd_host_param(passing = "take_owned")] r: FakeResource`). A + // path that carries generic arguments or a qualified prefix cannot be a + // concrete `HostResource` type and is almost always a type alias to a + // resource wrapper, which the macro cannot resolve reliably. + if explicit_mode.is_some() && inferred.is_none() { + let path = match unwrap_surface(ty) { + Type::Path(path) => path, + _ => unreachable!("path_last_ident only yields for path types"), + }; + let has_suspicious_shape = path.path.segments.len() > 1 + || matches!( + path.path.segments.last().map(|s| &s.arguments), + Some(PathArguments::AngleBracketed(_) | PathArguments::Parenthesized(_)) + ); + if has_suspicious_shape { + return Err( + "resource passing metadata on an alias/unqualified wrapper path is not supported; use a canonical ResourceRef, ResourceMut, or ResourceOwned wrapper or a bare concrete resource type" + .to_string(), + ); + } + } + let inner = if inferred.is_some() { + let Type::Path(path) = unwrap_surface(ty) else { + unreachable!("canonical wrapper is a path type") + }; + generic_type_argument( + path.path + .segments + .last() + .expect("canonical resource wrapper segment"), + )? + } else { + (*ty).clone() + }; + if let Some(key) = &key { + validate_resource_key(key).map_err(|error| error.to_string())?; + } + Ok(Some(ResourceSpec { + mode, + inner, + owned_wrapper, + key, + })) +} + +/// Classifies a *return* type as an owned `Resource` token, a borrowed +/// `ResourceRef<'_, T>`, or a `ResourceMut<'_, T>`. Returns `None` for anything +/// that is not a resource wrapper. +pub fn resource_return_kind(ty: &Type) -> Option { + let ident = path_last_ident(ty)?; + match ident.as_str() { + "Resource" => Some(ResourceReturnKind::Owned), + "ResourceRef" => Some(ResourceReturnKind::Borrow), + "ResourceMut" => Some(ResourceReturnKind::BorrowMut), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use syn::parse_quote; + + #[test] + fn key_validation_matches_expected_rules() { + assert!(validate_resource_key("io.file").is_ok()); + assert!(validate_resource_key("file").is_ok()); + assert!(validate_resource_key("a-b.c_0").is_ok()); + assert_eq!(validate_resource_key(""), Err(ResourceKeyError::Empty)); + assert!(matches!( + validate_resource_key("Io.File").unwrap_err(), + ResourceKeyError::InvalidChar { .. } + )); + assert!(matches!( + validate_resource_key("io..file").unwrap_err(), + ResourceKeyError::InvalidDotPlacement { .. } + )); + assert!(matches!( + validate_resource_key(".io.file").unwrap_err(), + ResourceKeyError::InvalidDotPlacement { .. } + )); + assert!(matches!( + validate_resource_key("io.file.").unwrap_err(), + ResourceKeyError::InvalidDotPlacement { .. } + )); + let overlong = "a".repeat(MAX_RESOURCE_KEY_LEN + 1); + assert!(matches!( + validate_resource_key(&overlong).unwrap_err(), + ResourceKeyError::TooLong(len) if len == MAX_RESOURCE_KEY_LEN + 1 + )); + } + + #[test] + fn canonical_wrappers_infer_modes() { + let ty: Type = parse_quote!(ResourceRef<'_, FakeResource>); + let spec = resource_spec(&ty, &[]).unwrap().expect("resource"); + assert_eq!(spec.mode, ResourceMode::Borrow); + assert!(spec.owned_wrapper); + + let ty: Type = parse_quote!(ResourceMut<'_, FakeResource>); + let spec = resource_spec(&ty, &[]).unwrap().expect("resource"); + assert_eq!(spec.mode, ResourceMode::BorrowMut); + + let ty: Type = parse_quote!(ResourceOwned); + let spec = resource_spec(&ty, &[]).unwrap().expect("resource"); + assert_eq!(spec.mode, ResourceMode::TakeOwned); + assert!(spec.owned_wrapper); + } + + #[test] + fn ordinary_parameters_are_not_resources() { + let ty: Type = parse_quote!(i64); + assert!(resource_spec(&ty, &[]).unwrap().is_none()); + let ty: Type = parse_quote!(String); + assert!(resource_spec(&ty, &[]).unwrap().is_none()); + } + + #[test] + fn explicit_annotation_on_concrete_type_is_supported() { + let ty: Type = parse_quote!(FakeResource); + let attrs: Vec = + parse_quote!(#[pd_host_param(passing = "take_owned", key = "test.fake")]); + let spec = resource_spec(&ty, &attrs).unwrap().expect("resource"); + assert_eq!(spec.mode, ResourceMode::TakeOwned); + assert_eq!(spec.key.as_deref(), Some("test.fake")); + assert!(!spec.owned_wrapper); + } + + #[test] + fn annotation_mode_conflict_is_rejected() { + let ty: Type = parse_quote!(ResourceOwned); + let attrs: Vec = parse_quote!(#[pd_host_param(passing = "borrow")]); + let error = resource_spec(&ty, &attrs).unwrap_err(); + assert!(error.contains("different modes"), "{error}"); + } + + #[test] + fn to_owned_and_value_resource_modes_are_rejected() { + // `to_owned` / `toowned` are reserved at parse time (never aliased to + // Value or TakeOwned): the literal, the attribute, and the nested form + // all fail with an explicit "reserved" error. + for literal in ["to_owned", "toowned", "TO_OWNED"] { + let error = ResourceMode::parse(literal).expect_err("to_owned must be reserved"); + assert!(error.contains("reserved"), "{literal}: {error}"); + } + let ty: Type = parse_quote!(FakeResource); + let attrs: Vec = parse_quote!(#[pd_host_param(passing = "to_owned")]); + let error = resource_spec(&ty, &attrs).unwrap_err(); + assert!(error.contains("reserved"), "{error}"); + let attrs: Vec = parse_quote!(#[pd_to_owned]); + let error = resource_spec(&ty, &attrs).unwrap_err(); + assert!(error.contains("reserved"), "{error}"); + let attrs: Vec = parse_quote!(#[pd_host_passing(to_owned)]); + let error = resource_spec(&ty, &attrs).unwrap_err(); + assert!(error.contains("reserved"), "{error}"); + + // `value` on a resource type is rejected by the spec (not reserved). + let ty: Type = parse_quote!(FakeResource); + let attrs: Vec = parse_quote!(#[pd_host_param(passing = "value")]); + let error = resource_spec(&ty, &attrs).unwrap_err(); + assert!(error.contains("Value"), "{error}"); + assert!(error.contains("rejected"), "{error}"); + } + + #[test] + fn invalid_explicit_keys_are_rejected_at_parse_time() { + let ty: Type = parse_quote!(FakeResource); + for key in ["", "bad key", "io..file", "A.b"] { + let attrs: Vec = + parse_quote!(#[pd_host_param(passing = "take_owned", key = #key)]); + let error = resource_spec(&ty, &attrs).unwrap_err(); + assert!(error.contains("resource type key"), "{error}"); + } + } + + #[test] + fn alias_wrapper_shape_with_annotation_is_rejected() { + // A path whose final segment is a canonical wrapper is a qualified + // (e.g. re-exported) canonical wrapper and is fully supported. + let ty: Type = parse_quote!(my_alias::ResourceRef<'static, FakeResource>); + let attrs: Vec = parse_quote!(#[pd_host_param(passing = "borrow")]); + let spec = resource_spec(&ty, &attrs) + .unwrap() + .expect("qualified wrapper"); + assert_eq!(spec.mode, ResourceMode::Borrow); + + // A non-canonical path that carries a qualified prefix or generic + // arguments cannot be a concrete `HostResource` type and is almost + // always an alias the parser cannot expand reliably. + let ty: Type = parse_quote!(my_alias::Wrapper<'static, FakeResource>); + let error = resource_spec(&ty, &attrs).unwrap_err(); + assert!(error.contains("alias"), "{error}"); + + let ty: Type = parse_quote!(WrapperAlias); + let error = resource_spec(&ty, &attrs).unwrap_err(); + assert!(error.contains("alias"), "{error}"); + } + + #[test] + fn resource_return_kinds_are_classified() { + let ty: Type = parse_quote!(Resource); + assert_eq!(resource_return_kind(&ty), Some(ResourceReturnKind::Owned)); + let ty: Type = parse_quote!(ResourceRef<'_, FakeResource>); + assert_eq!(resource_return_kind(&ty), Some(ResourceReturnKind::Borrow)); + let ty: Type = parse_quote!(ResourceMut<'_, FakeResource>); + assert_eq!( + resource_return_kind(&ty), + Some(ResourceReturnKind::BorrowMut) + ); + let ty: Type = parse_quote!(i64); + assert_eq!(resource_return_kind(&ty), None); + } + + #[test] + fn host_passing_mapping_matches_the_runtime() { + assert_eq!(ResourceMode::Borrow.host_passing(), HostPassing::Borrow); + assert_eq!( + ResourceMode::BorrowMut.host_passing(), + HostPassing::BorrowMut + ); + assert_eq!( + ResourceMode::TakeOwned.host_passing(), + HostPassing::TakeOwned + ); + assert_eq!(ResourceMode::Value.host_passing(), HostPassing::Value); + // There is no ToOwned category to alias: the four variants map 1:1. + assert!(!matches!( + ResourceMode::Value.host_passing(), + HostPassing::TakeOwned + )); + } +} diff --git a/pd-host-function/Cargo.toml b/pd-host-function/Cargo.toml index cb9c15e7..f6b9a406 100644 --- a/pd-host-function/Cargo.toml +++ b/pd-host-function/Cargo.toml @@ -11,6 +11,7 @@ repository = "https://github.com/rustscript-lang/rustscript" proc-macro = true [dependencies] +pd-host-schema = { path = "../crates/pd-host-schema", version = "0.1.0" } proc-macro2 = "1" quote = "1" syn = { version = "2", features = ["full"] } diff --git a/pd-host-function/src/lib.rs b/pd-host-function/src/lib.rs index 4aa1d3bc..aa285559 100644 --- a/pd-host-function/src/lib.rs +++ b/pd-host-function/src/lib.rs @@ -5,6 +5,10 @@ use syn::{ punctuated::Punctuated, }; +use pd_host_schema::{ + ResourceMode, ResourceReturnKind, ResourceSpec, resource_return_kind, resource_spec, +}; + #[proc_macro_attribute] pub fn pd_host_function(attr: TokenStream, item: TokenStream) -> TokenStream { let args = parse_macro_input!(attr with Punctuated::::parse_terminated); @@ -19,9 +23,45 @@ fn expand_pd_host_function( mut item: ItemFn, ) -> Result { parse_name_arg(&attr)?; + let is_async = item.sig.asyncness.is_some(); let docs = doc_string(&item.attrs); + let mut resource_params = Vec::<(String, ResourceSpec)>::new(); for input in &item.sig.inputs { - validate_param(input)?; + let is_host_context = is_host_context_param(input); + if !is_host_context && !is_vm_context_param(input) { + let FnArg::Typed(pat_type) = input else { + return Err(Error::new_spanned(input, "methods are not supported")); + }; + let spec = resource_spec(&pat_type.ty, &pat_type.attrs) + .map_err(|message| Error::new_spanned(&pat_type.ty, message))?; + if let Some(spec) = spec { + if is_async && !matches!(spec.mode, ResourceMode::TakeOwned) { + return Err(Error::new_spanned( + &pat_type.ty, + "resource borrows cannot cross async/yield; only TakeOwned may move into an owned operation", + )); + } + let Pat::Ident(PatIdent { ident, .. }) = pat_type.pat.as_ref() else { + return Err(Error::new_spanned( + &pat_type.pat, + "resource parameters must use identifier patterns", + )); + }; + resource_params.push((ident.to_string(), spec)); + continue; + } + } + if is_async { + validate_async_param(input)?; + } else if is_host_context_param(input) { + return Err(Error::new_spanned( + input, + "#[pd_host_context] is only valid on async host functions", + )); + } + if !is_host_context_param(input) && !is_vm_context_param(input) { + validate_param(input)?; + } } validate_return_type(&item.sig.output)?; @@ -39,13 +79,92 @@ fn expand_pd_host_function( if item.sig.ident != impl_name { item.sig.ident = impl_name.clone(); } - let wrapper = generate_vm_wrapper(&item, &wrapper_name)?; + let wrapper = if is_async { + generate_async_vm_wrapper(&item, &wrapper_name, &resource_params)? + } else { + generate_vm_wrapper(&item, &wrapper_name, &resource_params)? + }; + for input in &mut item.sig.inputs { + if let FnArg::Typed(pat_type) = input { + pat_type + .attrs + .retain(|attr| !attr.path().is_ident("pd_host_context")); + } + } Ok(quote! { #item #wrapper }) } +fn is_vm_context_param(arg: &FnArg) -> bool { + match arg { + FnArg::Typed(pat_type) => is_vm_context_type(&pat_type.ty), + FnArg::Receiver(_) => false, + } +} + +fn validate_async_param(arg: &FnArg) -> Result<(), Error> { + let FnArg::Typed(pat_type) = arg else { + return Err(Error::new_spanned(arg, "methods are not supported")); + }; + if is_vm_context_type(&pat_type.ty) { + return Err(Error::new_spanned( + &pat_type.ty, + "async host functions cannot borrow Vm; capture owned host context before submission", + )); + } + if is_host_context_param(arg) { + return Ok(()); + } + if !is_async_owned_type(&pat_type.ty) { + return Err(Error::new_spanned( + &pat_type.ty, + "async host function parameters must be owned and 'static", + )); + } + Ok(()) +} + +fn is_host_context_param(arg: &FnArg) -> bool { + match arg { + FnArg::Typed(pat_type) => pat_type + .attrs + .iter() + .any(|attr| attr.path().is_ident("pd_host_context")), + FnArg::Receiver(_) => false, + } +} + +fn is_async_owned_type(ty: &Type) -> bool { + match ty { + Type::Group(group) => is_async_owned_type(&group.elem), + Type::Paren(paren) => is_async_owned_type(&paren.elem), + Type::Reference(_) | Type::Slice(_) => false, + Type::Tuple(tuple) => tuple.elems.iter().all(is_async_owned_type), + Type::Path(path) => { + let Some(segment) = path.path.segments.last() else { + return false; + }; + if matches!( + segment.ident.to_string().as_str(), + "str" | "VmStringRef" | "VmBytesRef" | "VmArrayRef" | "VmMapRef" | "VmValueRef" + ) { + return false; + } + match &segment.arguments { + syn::PathArguments::None => true, + syn::PathArguments::AngleBracketed(args) => args.args.iter().all(|arg| match arg { + syn::GenericArgument::Type(inner) => is_async_owned_type(inner), + _ => false, + }), + syn::PathArguments::Parenthesized(_) => false, + } + } + _ => false, + } +} + fn parse_name_arg(args: &Punctuated) -> Result { let Some(Meta::NameValue(name_value)) = args.first() else { return Err(Error::new( @@ -131,6 +250,21 @@ fn validate_return_type(output: &ReturnType) -> Result<(), Error> { match output { ReturnType::Default => Ok(()), ReturnType::Type(_, ty) => { + match resource_return_kind(ty) { + Some(ResourceReturnKind::Borrow) => { + return Err(Error::new_spanned( + ty, + "ResourceRef cannot be a host function return; resource borrows cannot cross the host boundary", + )); + } + Some(ResourceReturnKind::BorrowMut) => { + return Err(Error::new_spanned( + ty, + "ResourceMut cannot be a host function return; mutable resource borrows cannot cross the host boundary", + )); + } + Some(ResourceReturnKind::Owned) | None => {} + } type_label(ty)?; Ok(()) } @@ -153,6 +287,7 @@ fn is_abi_declaration_only(item: &ItemFn) -> bool { fn generate_vm_wrapper( item: &ItemFn, wrapper_name: &syn::Ident, + resource_params: &[(String, ResourceSpec)], ) -> Result { let impl_name = &item.sig.ident; let mut wrapper_params = Vec::::new(); @@ -194,8 +329,19 @@ fn generate_vm_wrapper( )); }; let ty = &pat_type.ty; + if let Some((_, spec)) = resource_params + .iter() + .find(|(name, _)| name == &ident.to_string()) + { + let extract = resource_extract_tokens(&ident.to_string(), spec, arg_index)?; + imm_extract_stmts.push(extract.clone()); + mut_extract_stmts.push(extract); + call_args.push(quote!(#ident)); + arg_index += 1; + continue; + } let label = LitStr::new( - &format!("{} {}", wrapper_name, ident), + &format!("{} {ident}", wrapper_name), proc_macro2::Span::call_site(), ); let index = syn::Index::from(arg_index); @@ -223,19 +369,169 @@ fn generate_vm_wrapper( Ok(quote! { #[allow(dead_code)] - pub(super) fn #wrapper_name(#(#imm_wrapper_params),*) -> #wrapper_output { + pub(crate) fn #wrapper_name(#(#imm_wrapper_params),*) -> #wrapper_output { #(#imm_extract_stmts)* #call_expr } #[allow(dead_code)] - pub(super) fn #mutable_wrapper_name(#(#mut_wrapper_params),*) -> #wrapper_output { + pub(crate) fn #mutable_wrapper_name(#(#mut_wrapper_params),*) -> #wrapper_output { #(#mut_extract_stmts)* #call_expr } }) } +/// Generates the extraction statement for one resource parameter. +/// +/// The guest passes the raw handle as a signed integer; the wrapper decodes +/// it through the public host-context SDK and re-validates it against the +/// current execution scope before handing the typed token / borrow to the +/// impl. `TakeOwned` hands the validated typed `Resource` token (the +/// resource stays table-owned until closed — the rewritten core has no +/// take-out-of-scope); `Borrow`/`BorrowMut` hand call-scoped borrows. +fn resource_extract_tokens( + ident: &str, + spec: &ResourceSpec, + arg_index: usize, +) -> Result { + let ident = syn::Ident::new(ident, proc_macro2::Span::call_site()); + let inner = &spec.inner; + let index = syn::Index::from(arg_index); + let handle_label = LitStr::new("resource handle", proc_macro2::Span::call_site()); + + let decode_handle = quote! { + let raw = super::arg::(args, #index, #handle_label)?; + let handle = super::super::vm::resource::ResourceHandle::from_raw(raw as u64) + .map_err(|error| super::super::VmError::HostError(error.to_string()))?; + }; + let extraction = match spec.mode { + ResourceMode::Borrow => quote! { + #decode_handle + let #ident = vm.host_context().borrow_resource::<#inner>(handle)?; + }, + ResourceMode::BorrowMut => quote! { + #decode_handle + let #ident = vm.host_context().borrow_resource_mut::<#inner>(handle)?; + }, + ResourceMode::TakeOwned => quote! { + #decode_handle + let #ident = vm.host_context().typed_resource::<#inner>(handle)?; + }, + ResourceMode::Value => { + return Err(Error::new( + proc_macro2::Span::call_site(), + "resource-containing Value parameters are rejected; use Borrow, BorrowMut, or TakeOwned", + )); + } + }; + Ok(extraction) +} + +fn generate_async_vm_wrapper( + item: &ItemFn, + wrapper_name: &syn::Ident, + resource_params: &[(String, ResourceSpec)], +) -> Result { + let impl_name = &item.sig.ident; + let mutable_wrapper_name = syn::Ident::new(&format!("{wrapper_name}_mut"), wrapper_name.span()); + let mut extract_stmts = Vec::::new(); + let mut call_args = Vec::::new(); + let mut arg_index = 0usize; + + for input in &item.sig.inputs { + let FnArg::Typed(pat_type) = input else { + return Err(Error::new_spanned(input, "methods are not supported")); + }; + let Pat::Ident(PatIdent { ident, .. }) = pat_type.pat.as_ref() else { + return Err(Error::new_spanned( + &pat_type.pat, + "callable parameters must use identifier patterns", + )); + }; + let ty = &pat_type.ty; + if is_host_context_param(input) { + extract_stmts.push(quote! { + let #ident = <#ty as super::CaptureAsyncHostContext>::capture_with_args(vm, args)?; + }); + call_args.push(quote!(#ident)); + continue; + } + if let Some((_, spec)) = resource_params + .iter() + .find(|(name, _)| name == &ident.to_string()) + { + // Only TakeOwned may move into an owned operation; the typed token + // is captured before the future is submitted. + let extract = resource_extract_tokens(&ident.to_string(), spec, arg_index)?; + extract_stmts.push(extract); + call_args.push(quote!(#ident)); + arg_index += 1; + continue; + } + let label = LitStr::new( + &format!("{} {ident}", wrapper_name), + proc_macro2::Span::call_site(), + ); + let index = syn::Index::from(arg_index); + extract_stmts.push(quote! { + let #ident = super::borrow_arg::<#ty>(args, #index, #label)?; + }); + call_args.push(quote!(#ident)); + arg_index += 1; + } + + let await_value = if return_is_vm_result(&item.sig.output) { + quote!(#impl_name(#(#call_args),*).await?) + } else { + quote!(#impl_name(#(#call_args),*).await) + }; + let future_result = if return_is_host_future_output(&item.sig.output) { + quote!(Ok(value.map(super::return_one))) + } else { + quote! { + match super::IntoHostCallOutcome::into_host_call_outcome(value) { + super::CallOutcome::Return(values) => { + Ok(super::HostFutureOutput::returning(values)) + } + super::CallOutcome::Pending(op_id) => Err(super::VmError::HostError( + format!("async host function returned nested pending operation {op_id}"), + )), + super::CallOutcome::Halt | super::CallOutcome::Yield => Err( + super::VmError::HostError( + "async host function returned a control-flow outcome".to_string(), + ), + ), + } + } + }; + let body = quote! { + #(#extract_stmts)* + vm.submit_host_future(Box::pin(async move { + let value = #await_value; + #future_result + })) + }; + + Ok(quote! { + #[allow(dead_code)] + pub(crate) fn #wrapper_name( + vm: &mut super::super::Vm, + args: &[super::super::Value], + ) -> super::super::VmResult { + #body + } + + #[allow(dead_code)] + pub(crate) fn #mutable_wrapper_name( + vm: &mut super::super::Vm, + args: &mut [super::super::Value], + ) -> super::super::VmResult { + #body + } + }) +} + fn wrapper_and_impl_names(name: &syn::Ident) -> (syn::Ident, syn::Ident) { let original = name.to_string(); match original.strip_suffix("_impl") { @@ -304,6 +600,20 @@ fn return_is_vm_result(output: &ReturnType) -> bool { .is_some() } +fn return_is_host_future_output(output: &ReturnType) -> bool { + vm_result_inner_type(output) + .expect("pd_host_function return type should already be validated") + .and_then(|ty| match ty { + Type::Path(path) => path + .path + .segments + .last() + .map(|segment| segment.ident.clone()), + _ => None, + }) + .is_some_and(|ident| ident == "HostFutureOutput") +} + fn type_label(ty: &Type) -> Result { match ty { Type::Group(group) => type_label(&group.elem), @@ -341,6 +651,8 @@ fn type_label(ty: &Type) -> Result { "Array" | "VmArray" | "VmArrayRef" | "VmArrayHandle" => Ok("array".to_string()), "Map" | "VmMap" | "VmMapRef" | "VmMapHandle" => Ok("map".to_string()), "Number" | "NumberValue" => Ok("number".to_string()), + "Resource" | "ResourceRef" | "ResourceMut" => Ok("resource".to_string()), + "VmCallable" => callable_type_label(segment), "Unknown" | "UnknownValue" => Ok("unknown".to_string()), "CallOutcome" => Ok("unknown".to_string()), "Option" => { @@ -359,7 +671,7 @@ fn type_label(ty: &Type) -> Result { let inner_label = type_label(inner)?; Ok(format!("{inner_label} | null")) } - "VmResult" | "HostCallResult" => { + "VmResult" | "HostCallResult" | "HostFutureOutput" => { let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { return Err(Error::new_spanned( &segment.arguments, @@ -385,6 +697,31 @@ fn type_label(ty: &Type) -> Result { } } +fn callable_type_label(segment: &syn::PathSegment) -> Result { + let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { + return Err(Error::new_spanned( + &segment.arguments, + "VmCallable requires a function signature", + )); + }; + let Some(syn::GenericArgument::Type(Type::BareFn(function))) = args.args.first() else { + return Err(Error::new_spanned( + args, + "VmCallable requires fn(...) -> ...", + )); + }; + let params = function + .inputs + .iter() + .map(|input| type_label(&input.ty)) + .collect::, _>>()?; + let result = match &function.output { + ReturnType::Default => "null".to_string(), + ReturnType::Type(_, ty) => type_label(ty)?, + }; + Ok(format!("fn({}) -> {result}", params.join(", "))) +} + fn type_label_for_vec(segment: &syn::PathSegment) -> Result { let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { return Err(Error::new_spanned( @@ -482,8 +819,8 @@ fn uses_taken_extractor(ty: &Type) -> bool { #[cfg(test)] mod tests { - use super::expand_pd_host_function; - use syn::{ItemFn, Meta, Token, parse_quote, punctuated::Punctuated}; + use super::{expand_pd_host_function, type_label}; + use syn::{ItemFn, Meta, Token, Type, parse_quote, punctuated::Punctuated}; #[test] fn accepts_host_call_result_from_the_function_signature() { @@ -533,4 +870,179 @@ mod tests { .expect_err("the pd-host-function macro must not accept an async attribute"); assert!(error.to_string().contains("only supports name")); } + + #[test] + fn ordinary_async_signature_generates_host_driven_future_submission() { + let attr: Punctuated = parse_quote!(name = "test::async_call"); + let item: ItemFn = parse_quote!( + /// Returns an owned string asynchronously. + async fn async_call( + #[pd_host_context] context: TestContext, + value: String, + ) -> VmResult { + context.run(value).await + } + ); + let expanded = expand_pd_host_function(attr, item) + .expect("ordinary owned async function should use the generic async host contract") + .to_string(); + assert!(expanded.contains("submit_host_future")); + assert!(expanded.contains("async move")); + assert!(expanded.contains("borrow_arg")); + assert!(expanded.contains("CaptureAsyncHostContext")); + assert!(expanded.contains("capture_with_args")); + assert!(!expanded.contains("pd_host_context")); + } + + #[test] + fn async_host_future_output_maps_its_inner_value_to_call_return() { + let attr: Punctuated = parse_quote!(name = "test::completion"); + let item: ItemFn = parse_quote! { + /// Completes after mutating VM-owned state. + async fn completion() -> VmResult> { + todo!() + } + }; + + let expanded = expand_pd_host_function(attr, item) + .expect("host future output should be accepted") + .to_string(); + assert!(expanded.contains("value . map (super :: return_one)")); + } + + #[test] + fn async_signature_rejects_borrowed_parameters() { + let attr: Punctuated = parse_quote!(name = "test::borrowed"); + let item: ItemFn = parse_quote! { + async fn borrowed(value: &str) -> VmResult { + Ok(value.to_string()) + } + }; + + let error = expand_pd_host_function(attr, item).expect_err("borrow should be rejected"); + assert!( + error + .to_string() + .contains("parameters must be owned and 'static") + ); + } + + #[test] + fn callable_wrapper_preserves_parameter_and_result_schema() { + let ty: Type = parse_quote!(VmCallable VmMap>); + assert_eq!(type_label(&ty).unwrap(), "fn(map) -> map"); + let attr: Punctuated = parse_quote!(name = "test::stream"); + let item: ItemFn = parse_quote! { + /// Starts a synthetic callable stream. + fn stream(callback: VmCallable VmMap>) -> VmResult { + todo!() + } + }; + let expanded = expand_pd_host_function(attr, item).unwrap().to_string(); + assert!(expanded.contains("VmCallable < fn (VmMap) -> VmMap >")); + assert!(expanded.contains("borrow_arg")); + + let float_ty: Type = parse_quote!(VmCallable f64>); + assert_eq!(type_label(&float_ty).unwrap(), "fn(float) -> float"); + } + + #[test] + fn take_owned_resource_param_generates_owned_extraction() { + let attr: Punctuated = parse_quote!(name = "test::use_counter"); + let item: ItemFn = parse_quote! { + /// Reads a counter resource by owned token. + fn use_counter( + vm: &mut Vm, + #[pd_host_resource(passing = "take_owned", key = "demo.counter")] + counter: Counter, + ) -> VmResult { + todo!() + } + }; + let expanded = expand_pd_host_function(attr, item).unwrap().to_string(); + assert!(expanded.contains("typed_resource")); + assert!(expanded.contains("ResourceHandle :: from_raw")); + assert!(expanded.contains("host_context")); + } + + #[test] + fn borrow_resource_param_generates_borrow_extraction() { + let attr: Punctuated = parse_quote!(name = "test::peek_counter"); + let item: ItemFn = parse_quote! { + /// Peeks a counter resource by immutable borrow. + fn peek_counter( + #[pd_host_resource(passing = "borrow", key = "demo.counter")] + counter: ResourceRef<'_, Counter>, + ) -> VmResult { + todo!() + } + }; + let expanded = expand_pd_host_function(attr, item).unwrap().to_string(); + assert!(expanded.contains("borrow_resource")); + assert!(expanded.contains("ResourceHandle :: from_raw")); + } + + #[test] + fn borrow_mut_resource_param_generates_mut_borrow_extraction() { + let attr: Punctuated = parse_quote!(name = "test::bump_counter"); + let item: ItemFn = parse_quote! { + /// Bumps a counter resource by mutable borrow. + fn bump_counter( + #[pd_host_resource(passing = "borrow_mut", key = "demo.counter")] + counter: ResourceMut<'_, Counter>, + ) -> VmResult { + todo!() + } + }; + let expanded = expand_pd_host_function(attr, item).unwrap().to_string(); + assert!(expanded.contains("borrow_resource_mut")); + } + + #[test] + fn resource_value_passing_rejected() { + let attr: Punctuated = parse_quote!(name = "test::bad_value"); + let item: ItemFn = parse_quote! { + /// A resource passed by value must be rejected. + fn bad_value( + #[pd_host_resource(passing = "value", key = "demo.counter")] + counter: Resource, + ) -> VmResult { + todo!() + } + }; + let error = expand_pd_host_function(attr, item) + .expect_err("resource Value passing must be rejected"); + assert!(error.to_string().contains("Value")); + } + + #[test] + fn async_borrow_resource_rejected() { + let attr: Punctuated = parse_quote!(name = "test::async_borrow"); + let item: ItemFn = parse_quote! { + /// A borrowed resource cannot cross an async boundary. + async fn async_borrow( + #[pd_host_resource(passing = "borrow", key = "demo.counter")] + counter: ResourceRef<'_, Counter>, + ) -> VmResult { + todo!() + } + }; + let error = expand_pd_host_function(attr, item) + .expect_err("async resource borrows must be rejected"); + assert!(error.to_string().contains("cannot cross async")); + } + + #[test] + fn resource_ref_return_rejected() { + let attr: Punctuated = parse_quote!(name = "test::bad_return"); + let item: ItemFn = parse_quote! { + /// A resource borrow return must be rejected. + fn bad_return(value: i64) -> ResourceRef<'_, Counter> { + todo!() + } + }; + let error = + expand_pd_host_function(attr, item).expect_err("ResourceRef return must be rejected"); + assert!(error.to_string().contains("ResourceRef")); + } } diff --git a/src/builtins/runtime/io/async_io.rs b/src/builtins/runtime/io/async_io.rs new file mode 100644 index 00000000..5547902f --- /dev/null +++ b/src/builtins/runtime/io/async_io.rs @@ -0,0 +1,611 @@ +//! Feature-selected async IO host implementation. +//! +//! This is the `async`-feature counterpart of the worker-thread +//! [`blocking`](super::blocking) implementation. Live handles are typed +//! [`IoResource`]s owned by the VM's execution scope (exactly like the +//! blocking path) and in-flight IO work runs through tokio; the guest-facing +//! builtins are async host functions that capture owned host context and +//! submit a future through the generic async host bridge. +//! +//! The guest-visible handle id is the raw resource token, so handles opened +//! on one path can be closed/read on the other. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; + +use pd_host_function::pd_host_function; +use tokio::fs::{File, OpenOptions}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin, ChildStdout, Command}; +use tokio::sync::Mutex; + +use super::{IoPolicy, io_policy}; +use crate::vm::operation::reason::OperationCancelReason; +use crate::vm::resource::close::{CloseProgress, HostResource}; +use crate::vm::resource::error::ResourceResult; +use crate::vm::resource::{ResourceCloseReason, ResourceHandle}; +use crate::vm::{ + CallReturn, CaptureAsyncHostContext, HostFutureOutput, HostOpId, Value, Vm, VmError, VmResult, +}; + +/// Per-VM IO host state for the async implementation. +/// +/// Async IO builtins submit their work through the async host bridge +/// (`submitted_host_ops`), so no per-op value mailbox is needed here. The +/// empty state exists so the host runtime keeps one uniform `IoState` slot +/// across the blocking and async implementations. +#[derive(Default)] +pub(crate) struct IoState {} + +/// Cancels one pending builtin IO operation through the execution scope. +/// +/// Async IO ops are bridge-submitted; cancellation is delivered by the VM +/// through the bridge's `cancel_op`, so the runtime-owned registry never +/// sees them. This stub keeps the uniform `cancel_builtin_io_op` surface. +pub(crate) fn cancel_pending_op(_vm: &mut Vm, _op_id: HostOpId) {} + +/// Polls one pending builtin IO operation. +/// +/// Async IO ops are polled through the bridge's `poll_submitted_op` (the +/// VM routes them via `submitted_host_ops`), so this uniform surface is +/// never reached; it exists for shape parity with the blocking path. +pub(crate) fn poll_builtin_io_op( + _vm: &mut Vm, + op_id: HostOpId, + _cx: &mut std::task::Context<'_>, +) -> std::task::Poll> { + std::task::Poll::Ready(Err(VmError::HostError(format!( + "async io op {op_id} has no runtime mailbox; expected bridge-driven poll" + )))) +} + +/// A file / child-process backed IO handle. +#[derive(Debug)] +pub(crate) enum IoHandle { + File(BufReader), + PopenRead { + child: Child, + stdout: BufReader, + }, + PopenWrite { + child: Child, + stdin: ChildStdin, + }, +} + +/// The typed resource stored in the execution scope for one async IO handle. +/// +/// Mirrors the blocking path: the handle lives behind an `Arc>` +/// so the async builtin can take/restore it while the resource stays in the +/// scope table. Closing is exact-once. +struct IoResource { + handle: Arc>>, + closed: Arc, + process_id: Arc, +} + +impl IoResource { + fn new(handle: IoHandle) -> Self { + let process_id = match &handle { + IoHandle::PopenRead { child, .. } | IoHandle::PopenWrite { child, .. } => { + child.id().unwrap_or(0) + } + IoHandle::File(_) => 0, + }; + Self { + handle: Arc::new(Mutex::new(Some(handle))), + closed: Arc::new(AtomicBool::new(false)), + process_id: Arc::new(AtomicU32::new(process_id)), + } + } + + fn new_shared(cells: &IoResource) -> Self { + Self { + handle: Arc::clone(&cells.handle), + closed: Arc::clone(&cells.closed), + process_id: Arc::clone(&cells.process_id), + } + } + + async fn take_handle(&self) -> VmResult { + self.handle + .lock() + .await + .take() + .ok_or_else(|| VmError::HostError("io handle is closed".to_string())) + } +} + +impl HostResource for IoResource { + fn begin_close(&mut self, reason: ResourceCloseReason) -> ResourceResult { + self.closed.store(true, Ordering::SeqCst); + // The handle cells are shared with in-flight futures; take the + // handle exactly once (best-effort) and release the OS resource. + if let Some(handle) = self + .handle + .try_lock() + .ok() + .and_then(|mut guard| guard.take()) + { + close_io_handle_now(handle); + } + let pid = self.process_id.swap(0, Ordering::AcqRel); + if pid != 0 { + terminate_process_id(pid, reason); + } + Ok(CloseProgress::Ready) + } +} + +/// Closes an IO handle synchronously (dropping tokio handles releases the +/// underlying OS resources; a child process is terminated by pid). +fn close_io_handle_now(mut handle: IoHandle) { + match &mut handle { + IoHandle::File(file) => { + std::mem::drop(file.get_mut().flush()); + } + IoHandle::PopenRead { child, .. } | IoHandle::PopenWrite { child, .. } => { + let _ = child.start_kill(); + std::mem::drop(child.wait()); + } + } +} + +async fn close_io_handle(handle: IoHandle) -> VmResult<()> { + match handle { + IoHandle::File(mut file) => { + file.get_mut() + .flush() + .await + .map_err(|error| VmError::HostError(format!("io_close flush failed: {error}")))?; + } + IoHandle::PopenRead { mut child, .. } => { + let _ = child.start_kill(); + child.wait().await.map_err(|error| { + VmError::HostError(format!("io_close popen wait failed: {error}")) + })?; + } + IoHandle::PopenWrite { mut child, stdin } => { + drop(stdin); + let _ = child.start_kill(); + child.wait().await.map_err(|error| { + VmError::HostError(format!("io_close popen wait failed: {error}")) + })?; + } + } + Ok(()) +} + +fn terminate_process_id(pid: u32, _reason: ResourceCloseReason) { + if pid == 0 { + return; + } + #[cfg(unix)] + unsafe { + libc::kill(pid as libc::pid_t, libc::SIGKILL); + } + #[cfg(not(unix))] + let _ = pid; +} + +/// The per-call captured policy context. +#[derive(Clone)] +pub(crate) struct IoPolicyContext { + policy: Option, +} + +impl CaptureAsyncHostContext for IoPolicyContext { + fn capture(vm: &mut Vm) -> VmResult { + Ok(Self { + policy: io_policy(vm), + }) + } +} + +/// The per-call captured handle context: shared resource cells plus the +/// policy byte limits, captured before the future is submitted. +pub(crate) struct IoHandleContext { + handle: ResourceHandle, + resource: IoResource, + max_read_bytes: Option, + max_write_bytes: Option, +} + +impl CaptureAsyncHostContext for IoHandleContext { + fn capture(_vm: &mut Vm) -> VmResult { + Err(VmError::HostError( + "io handle context requires call arguments".to_string(), + )) + } + + fn capture_with_args(vm: &mut Vm, args: &[Value]) -> VmResult { + let handle_id = match args.first() { + Some(Value::Int(value)) => *value, + Some(_) => return Err(VmError::TypeMismatch("int")), + None => return Err(VmError::HostError("missing io handle argument".to_string())), + }; + let handle = io_parse_handle(handle_id)?; + let resource = io_resource_for_handle(vm, handle)?; + Ok(Self { + handle, + resource, + max_read_bytes: io_policy(vm).map(|policy| policy.max_read_bytes), + max_write_bytes: io_policy(vm).map(|policy| policy.max_write_bytes), + }) + } +} + +/// Opens a file handle for runtime I/O. +#[pd_host_function(name = "io::open")] +pub(crate) async fn builtin_io_open( + #[pd_host_context] context: IoPolicyContext, + path: String, + mode: String, +) -> VmResult> { + let writes = match mode.as_str() { + "r" => false, + "w" | "a" | "r+" | "w+" | "a+" => true, + other => { + return Err(VmError::HostError(format!( + "io_open unsupported mode '{other}'" + ))); + } + }; + let path = authorize_io_path(context.policy.as_ref(), &path, writes).await?; + let mut options = OpenOptions::new(); + match mode.as_str() { + "r" => { + options.read(true); + } + "w" => { + options.write(true).create(true).truncate(true); + } + "a" => { + options.write(true).create(true).append(true); + } + "r+" => { + options.read(true).write(true); + } + "w+" => { + options.read(true).write(true).create(true).truncate(true); + } + "a+" => { + options.read(true).write(true).create(true).append(true); + } + _ => unreachable!(), + } + let file = options + .open(path) + .await + .map_err(|error| VmError::HostError(format!("io_open failed: {error}")))?; + let handle = IoHandle::File(BufReader::new(file)); + Ok(HostFutureOutput::complete(move |vm| { + let token = vm + .execution_scope() + .push_resource(IoResource::new(handle)) + .map_err(|error| VmError::HostError(format!("io resource insert failed: {error}")))?; + Ok(token.into_handle().raw() as i64) + })) +} + +/// Starts a child process and returns a process-backed handle. +#[pd_host_function(name = "io::popen")] +pub(crate) async fn builtin_io_popen( + #[pd_host_context] context: IoPolicyContext, + command: String, + mode: String, +) -> VmResult> { + if mode != "r" && mode != "w" { + return Err(VmError::HostError(format!( + "io_popen unsupported mode '{mode}'" + ))); + } + if !context + .policy + .as_ref() + .is_none_or(|policy| policy.allow_process) + { + return Err(VmError::HostError( + "io_popen requires the command capability".to_string(), + )); + } + let handle = spawn_shell_command(&command, &mode)?; + Ok(HostFutureOutput::complete(move |vm| { + let token = vm + .execution_scope() + .push_resource(IoResource::new(handle)) + .map_err(|error| VmError::HostError(format!("io resource insert failed: {error}")))?; + Ok(token.into_handle().raw() as i64) + })) +} + +/// Reads all remaining text from an I/O handle. +#[pd_host_function(name = "io::read_all")] +pub(crate) async fn builtin_io_read_all( + #[pd_host_context] context: IoHandleContext, + _handle_id: i64, +) -> VmResult> { + let mut guard = context.resource.handle.lock().await; + let handle = guard + .as_mut() + .ok_or_else(|| VmError::HostError("io handle is closed".to_string()))?; + let mut out = String::new(); + match handle { + IoHandle::File(file) => file.read_to_string(&mut out).await, + IoHandle::PopenRead { stdout, .. } => stdout.read_to_string(&mut out).await, + IoHandle::PopenWrite { .. } => { + return Err(VmError::HostError( + "io_read_all cannot read from a write handle".to_string(), + )); + } + } + .map_err(|error| VmError::HostError(format!("io_read_all failed: {error}")))?; + if context + .max_read_bytes + .is_some_and(|limit| out.len() > limit) + { + return Err(VmError::HostError( + "io_read_all exceeded read limit".to_string(), + )); + } + Ok(HostFutureOutput::returning(out)) +} + +/// Reads a single line of text from an I/O handle. +#[pd_host_function(name = "io::read_line")] +pub(crate) async fn builtin_io_read_line( + #[pd_host_context] context: IoHandleContext, + _handle_id: i64, +) -> VmResult> { + let mut guard = context.resource.handle.lock().await; + let handle = guard + .as_mut() + .ok_or_else(|| VmError::HostError("io handle is closed".to_string()))?; + let mut line = String::new(); + match handle { + IoHandle::File(file) => file.read_line(&mut line).await, + IoHandle::PopenRead { stdout, .. } => stdout.read_line(&mut line).await, + IoHandle::PopenWrite { .. } => { + return Err(VmError::HostError( + "io_read_line cannot read from a write handle".to_string(), + )); + } + } + .map_err(|error| VmError::HostError(format!("io_read_line failed: {error}")))?; + if context + .max_read_bytes + .is_some_and(|limit| line.len() > limit) + { + return Err(VmError::HostError( + "io_read_line exceeded read limit".to_string(), + )); + } + Ok(HostFutureOutput::returning(line)) +} + +/// Writes text to an I/O handle. +#[pd_host_function(name = "io::write")] +pub(crate) async fn builtin_io_write( + #[pd_host_context] context: IoHandleContext, + _handle_id: i64, + text: String, +) -> VmResult> { + if context + .max_write_bytes + .is_some_and(|limit| text.len() > limit) + { + return Err(VmError::HostError( + "io_write exceeded write limit".to_string(), + )); + } + let mut guard = context.resource.handle.lock().await; + let handle = guard + .as_mut() + .ok_or_else(|| VmError::HostError("io handle is closed".to_string()))?; + let written = match handle { + IoHandle::File(file) => file.get_mut().write(text.as_bytes()).await, + IoHandle::PopenWrite { stdin, .. } => stdin.write(text.as_bytes()).await, + IoHandle::PopenRead { .. } => { + return Err(VmError::HostError( + "io_write cannot write to a read handle".to_string(), + )); + } + } + .map_err(|error| VmError::HostError(format!("io_write failed: {error}")))?; + Ok(HostFutureOutput::returning(written as i64)) +} + +/// Flushes buffered output for an I/O handle. +#[pd_host_function(name = "io::flush")] +pub(crate) async fn builtin_io_flush( + #[pd_host_context] context: IoHandleContext, + _handle_id: i64, +) -> VmResult> { + let mut guard = context.resource.handle.lock().await; + let handle = guard + .as_mut() + .ok_or_else(|| VmError::HostError("io handle is closed".to_string()))?; + match handle { + IoHandle::File(file) => file.get_mut().flush().await, + IoHandle::PopenWrite { stdin, .. } => stdin.flush().await, + IoHandle::PopenRead { .. } => Ok(()), + } + .map_err(|error| VmError::HostError(format!("io_flush failed: {error}")))?; + Ok(HostFutureOutput::returning(true)) +} + +/// Closes an I/O handle. +#[pd_host_function(name = "io::close")] +pub(crate) async fn builtin_io_close( + #[pd_host_context] context: IoHandleContext, + _handle_id: i64, +) -> VmResult> { + let resource = IoResource::new_shared(&context.resource); + let handle = context.handle; + let close_result = match resource.take_handle().await { + Ok(handle) => close_io_handle(handle).await, + Err(_) => Ok(()), + }; + Ok(HostFutureOutput::complete(move |vm| { + let _ = vm + .execution_scope() + .close_resource::(handle, ResourceCloseReason::Requested); + close_result?; + Ok(true) + })) +} + +/// Returns whether a file system path exists. +#[pd_host_function(name = "io::exists")] +pub(crate) async fn builtin_io_exists( + #[pd_host_context] context: IoPolicyContext, + path: String, +) -> VmResult> { + let path = authorize_io_path(context.policy.as_ref(), &path, false).await?; + let exists = tokio::fs::try_exists(path) + .await + .map_err(|error| VmError::HostError(format!("io_exists failed: {error}")))?; + Ok(HostFutureOutput::returning(exists)) +} + +#[allow(dead_code)] +pub(crate) fn cancel_builtin_io_op_with_reason( + _vm: &mut Vm, + _op_id: HostOpId, + _reason: OperationCancelReason, +) { +} + +async fn authorize_io_path( + policy: Option<&IoPolicy>, + path: &str, + writes: bool, +) -> VmResult { + let requested = PathBuf::from(path); + let Some(policy) = policy 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).await?; + for root in &policy.allowed_roots { + let root = tokio::fs::canonicalize(Path::new(root)) + .await + .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() + ))) +} + +async fn canonicalize_io_target(path: &Path) -> VmResult { + if tokio::fs::try_exists(path) + .await + .map_err(|error| VmError::HostError(format!("io path resolution failed: {error}")))? + { + return tokio::fs::canonicalize(path) + .await + .map_err(|error| VmError::HostError(format!("io path resolution failed: {error}"))); + } + // The target does not exist yet (e.g. a create-mode open): canonicalize + // the parent and append the final component. + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let canonical_parent = tokio::fs::canonicalize(parent) + .await + .map_err(|error| VmError::HostError(format!("io path resolution failed: {error}")))?; + let name = path + .file_name() + .ok_or_else(|| VmError::HostError("io path has no file name".to_string()))?; + Ok(canonical_parent.join(name)) +} + +/// Looks up the shared cells of a live IO handle resource in the execution +/// scope, cloning them so the async builtin can take/restore the handle +/// while the resource stays in the scope table. +fn io_resource_for_handle(vm: &mut Vm, handle: ResourceHandle) -> VmResult { + let token = vm + .execution_scope() + .resources() + .typed::(handle) + .map_err(|error| { + VmError::HostError(format!( + "io handle {:?} is not a live IO handle: {error}", + handle.raw() + )) + })?; + let resource = vm + .execution_scope() + .resources() + .get::(&token) + .map_err(|error| { + VmError::HostError(format!( + "io handle {:?} borrow failed: {error}", + handle.raw() + )) + })?; + Ok(IoResource::new_shared(&resource)) +} + +fn io_parse_handle(handle_id: i64) -> VmResult { + if handle_id <= 0 { + return Err(VmError::HostError(format!( + "invalid io handle id {handle_id}; expected positive handle id" + ))); + } + ResourceHandle::from_raw(handle_id as u64) + .map_err(|error| VmError::HostError(format!("invalid io handle id {handle_id}: {error}"))) +} + +fn spawn_shell_command(command: &str, mode: &str) -> VmResult { + let mut child = if mode == "r" { + Command::new("/bin/sh") + .arg("-c") + .arg(command) + .stdout(Stdio::piped()) + .stdin(Stdio::null()) + .spawn() + } else { + Command::new("/bin/sh") + .arg("-c") + .arg(command) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .spawn() + } + .map_err(|error| VmError::HostError(format!("io_popen spawn failed: {error}")))?; + + if mode == "r" { + let stdout = child.stdout.take().ok_or_else(|| { + VmError::HostError("io_popen('r') did not provide stdout pipe".to_string()) + })?; + Ok(IoHandle::PopenRead { + child, + stdout: BufReader::new(stdout), + }) + } else { + let stdin = child.stdin.take().ok_or_else(|| { + VmError::HostError("io_popen('w') did not provide stdin pipe".to_string()) + })?; + Ok(IoHandle::PopenWrite { child, stdin }) + } +} diff --git a/src/builtins/runtime/io.rs b/src/builtins/runtime/io/blocking.rs similarity index 93% rename from src/builtins/runtime/io.rs rename to src/builtins/runtime/io/blocking.rs index 1ec36119..8ed7007a 100644 --- a/src/builtins/runtime/io.rs +++ b/src/builtins/runtime/io/blocking.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::fs::OpenOptions; use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; @@ -28,19 +29,12 @@ use crate::vm::{CallReturn, HostOpId, Value, Vm, VmError, VmResult}; /// worker thread back to [`poll_builtin_io_op`]. Polling and cancellation /// of the operations themselves go directly through the scope's operation /// registry — this map is a value mailbox, not a poller table. +#[derive(Default)] pub(crate) struct IoState { /// Packed [`OperationId::raw`] -> completion mailbox for pending IO ops. pending_results: HashMap>, } -impl Default for IoState { - fn default() -> Self { - Self { - pending_results: HashMap::new(), - } - } -} - /// A file / child-process backed IO handle. pub(super) enum IoHandle { File(std::fs::File), @@ -359,7 +353,7 @@ impl Drop for IoOpDriver { } /// Cancels one pending builtin IO operation through the execution scope. -pub(super) fn cancel_pending_op(vm: &mut Vm, op_id: HostOpId) { +pub(crate) fn cancel_pending_op(vm: &mut Vm, op_id: HostOpId) { let Ok(id) = OperationId::from_raw(op_id) else { return; }; @@ -373,7 +367,7 @@ pub(super) fn cancel_pending_op(vm: &mut Vm, op_id: HostOpId) { /// Polls one pending builtin IO operation through the execution scope's /// operation registry, delivering the worker's guest-visible value. -pub(super) fn poll_builtin_io_op( +pub(crate) fn poll_builtin_io_op( vm: &mut Vm, op_id: HostOpId, cx: &mut Context<'_>, @@ -557,7 +551,10 @@ pub(super) fn builtin_io_open( path: &str, mode: &str, ) -> VmResult> { - let path = path.to_string(); + let writes = matches!(mode, "w" | "a" | "r+" | "w+" | "a+"); + let path = authorize_blocking_io_path(vm, path, writes)? + .display() + .to_string(); let mode = mode.to_string(); let op_id = schedule_io_task(vm, "io::open", move |shared| { let mut options = OpenOptions::new(); @@ -616,6 +613,14 @@ pub(super) fn builtin_io_popen( "unsupported io_popen mode '{mode}', expected r or w" ))); } + if super::io_policy(vm) + .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, "io::popen", move |shared| { @@ -771,6 +776,14 @@ pub(super) fn builtin_io_write( handle_id: i64, text: &str, ) -> VmResult> { + if super::io_policy(vm) + .as_ref() + .is_some_and(|policy| text.len() > policy.max_write_bytes) + { + return Err(VmError::HostError( + "io_write exceeded write limit".to_string(), + )); + } let bytes = text.as_bytes().to_vec(); let (_handle, resource) = io_resource_for_handle(vm, handle_id)?; let op_id = schedule_io_task(vm, "io::write", move |shared| { @@ -905,7 +918,9 @@ pub(super) fn builtin_io_close(vm: &mut Vm, handle_id: i64) -> VmResult VmResult> { - let path = path.to_string(); + let path = authorize_blocking_io_path(vm, path, false)? + .display() + .to_string(); let op_id = schedule_io_task(vm, "io::exists", move |shared| { shared.succeed(CallReturn::one(Value::Bool( std::path::Path::new(path.as_str()).exists(), @@ -1024,6 +1039,56 @@ fn io_parse_handle(handle_id: i64) -> VmResult { .map_err(|error| VmError::HostError(format!("invalid io handle id {handle_id}: {error}"))) } +/// Authorizes one IO path against the configured policy, mirroring the +/// async path: a policy with no matching allowed root denies the path. +fn authorize_blocking_io_path(vm: &Vm, path: &str, writes: bool) -> VmResult { + let requested = PathBuf::from(path); + let Some(policy) = super::io_policy(vm) 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_blocking_target(&absolute)?; + for root in &policy.allowed_roots { + let root = std::fs::canonicalize(Path::new(root)).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_blocking_target(path: &Path) -> VmResult { + if path.exists() { + return std::fs::canonicalize(path) + .map_err(|error| VmError::HostError(format!("io path resolution failed: {error}"))); + } + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let canonical_parent = std::fs::canonicalize(parent) + .map_err(|error| VmError::HostError(format!("io path resolution failed: {error}")))?; + let name = path + .file_name() + .ok_or_else(|| VmError::HostError("io path has no file name".to_string()))?; + Ok(canonical_parent.join(name)) +} + fn close_io_handle(mut handle: IoHandle) -> VmResult<()> { match &mut handle { IoHandle::File(file) => { diff --git a/src/builtins/runtime/io/mod.rs b/src/builtins/runtime/io/mod.rs new file mode 100644 index 00000000..55976947 --- /dev/null +++ b/src/builtins/runtime/io/mod.rs @@ -0,0 +1,82 @@ +//! IO builtin host implementation, selected by feature: +//! +//! - `async` (non-wasm32): [`async_io`] drives IO through tokio and submits +//! async host functions via the generic async host bridge. +//! - default (non-wasm32): [`blocking`] drives IO through worker threads +//! registered as concrete [`HostOperation`] drivers in the execution scope. +//! - wasm32: the wasm stub implementation. +//! +//! Both non-wasm32 implementations share the same execution-scope resource +//! model: live handles are [`IoResource`]s owned by the VM's execution scope +//! and in-flight IO work is driven by concrete operation drivers registered +//! in the same scope. Only the concurrency mechanism differs. + +use super::borrow_arg; +#[cfg(all(feature = "async", not(target_arch = "wasm32")))] +use super::{CallOutcome, CaptureAsyncHostContext, return_one}; +use crate::vm::Vm; + +#[cfg(all(not(feature = "async"), not(target_arch = "wasm32")))] +pub(super) use super::HostCallResult; + +#[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, + } + } +} + +struct IoHostState { + policy: IoPolicy, +} + +/// I/O host configuration owned by the I/O host implementation. +pub trait IoHostExt { + fn configure_io(&mut self, policy: IoPolicy); + fn clear_io_configuration(&mut self); +} + +impl IoHostExt for Vm { + fn configure_io(&mut self, mut policy: IoPolicy) { + policy.allowed_roots.sort(); + policy.allowed_roots.dedup(); + self.host.set_host_function_state(IoHostState { policy }); + } + + fn clear_io_configuration(&mut self) { + self.host.remove_host_function_state::(); + } +} + +pub(super) fn io_policy(vm: &Vm) -> Option { + vm.host + .host_function_state::() + .map(|state| state.policy.clone()) + .or_else(|| (!vm.host.default_builtin_capabilities_enabled()).then(IoPolicy::default)) +} + +#[cfg(all(feature = "async", not(target_arch = "wasm32")))] +mod async_io; +#[cfg(all(not(feature = "async"), not(target_arch = "wasm32")))] +mod blocking; + +#[cfg(target_arch = "wasm32")] +pub(super) use super::io_wasm::*; +#[cfg(all(feature = "async", not(target_arch = "wasm32")))] +pub(crate) use async_io::*; +#[cfg(all(not(feature = "async"), not(target_arch = "wasm32")))] +pub(crate) use blocking::*; diff --git a/src/builtins/runtime/mod.rs b/src/builtins/runtime/mod.rs index 29920e2d..29c27e21 100644 --- a/src/builtins/runtime/mod.rs +++ b/src/builtins/runtime/mod.rs @@ -3,7 +3,10 @@ use std::task::{Context, Poll}; use crate::builtins::BuiltinFunction; -use crate::vm::{CallOutcome, CallReturn, HostOpId, Value, Vm, VmResult}; +#[cfg(feature = "async")] +use crate::vm::CaptureAsyncHostContext; +#[allow(unused_imports)] +use crate::vm::{CallOutcome, CallReturn, HostOpId, Value, Vm, VmError, VmResult}; mod aot; mod bytes; @@ -21,18 +24,27 @@ pub(crate) mod print; pub(crate) mod regex; #[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))] pub(crate) mod sqlite; +pub(crate) mod standard_composition; mod typed; #[cfg(target_arch = "wasm32")] use io_wasm as io; pub(crate) use io::IoState; +#[cfg(not(target_arch = "wasm32"))] +pub use io::{IoHostExt, IoPolicy}; #[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))] pub(crate) use sqlite::SqliteState; +pub use standard_composition::standard_composition; pub use typed::HostCallResult; use typed::{ - AnyValue, IntoBuiltinCallOutcome, IntoHostCallOutcome, NumberValue, UnknownValue, VmArray, - VmBytes, VmMap, arg, borrow_arg, return_none, return_one, take_arg, + AnyValue, IntoBuiltinCallOutcome, IntoVmValue, NumberValue, UnknownValue, VmArray, VmBytes, + VmMap, +}; +#[allow(unused_imports)] +pub use typed::{ + BorrowVmValue, FromVmValue, IntoHostCallOutcome, TakeVmValue, arg, borrow_arg, return_none, + return_one, take_arg, }; pub(crate) enum BuiltinCallOutcome { diff --git a/src/builtins/runtime/standard_composition.rs b/src/builtins/runtime/standard_composition.rs new file mode 100644 index 00000000..97fa1817 --- /dev/null +++ b/src/builtins/runtime/standard_composition.rs @@ -0,0 +1,75 @@ +//! Concrete standard-surface composition for the host-agnostic VM core. +//! +//! This module implements [`StandardSurfaceComposition`] for the same-crate +//! standard builtin layer. It is the *only* place that knows which concrete +//! standard domains exist (`io::`, `http::`, `sqlite::`) and which builtin +//! modules implement them. `src/vm` consumes it through the generic trait and +//! never names a domain, namespace prefix, or feature. +//! +//! The implementation is *caller-provided per-instance state*: the outer +//! standard-runtime constructor installs one instance on the standard +//! [`HostFunctionRegistry`] (and on the `Vm` for the legacy fallback paths) +//! through [`standard_composition`]. There is no process-global slot and no +//! hidden installation from `HostRuntime::new()`. + +use std::sync::Arc; + +use crate::bytecode::HostImport; +use crate::vm::standard_composition::StandardSurfaceComposition; +use crate::vm::{HostFunctionRegistry, Vm, VmResult}; + +use super::register_default_host_functions; +use crate::builtins::default_host_callable; + +/// The concrete standard-surface composition for this build. +/// +/// Feature-gated composition happens through the existing standard builtin +/// helpers: IO is always present under `runtime`, HTTP under `http-client`, +/// SQLite under `sqlite`. Required/present/stage is one opaque operation; +/// the VM core never sees a surface mask or count. +#[derive(Debug)] +pub(crate) struct StandardSurfaceCompositionImpl; + +impl StandardSurfaceComposition for StandardSurfaceCompositionImpl { + fn import_in_standard(&self, import: &HostImport) -> bool { + default_host_callable(&import.name).is_some() + } + + fn ensure_surfaces( + &self, + imports: &[HostImport], + registry: &mut HostFunctionRegistry, + ) -> VmResult { + let mut staged = false; + for import in imports { + if default_host_callable(&import.name).is_none() { + continue; + } + // The default host callable is the surface: stage it if the + // registry does not already carry the name. + if !registry.contains_name(&import.name) { + register_default_host_functions(registry); + staged = true; + break; + } + } + Ok(staged) + } + + fn build_default_registry(&self) -> VmResult { + Ok(HostFunctionRegistry::new()) + } + + fn bind_default_name(&self, vm: &mut Vm, name: &str) -> bool { + super::bind_default_host_function(vm, name) + } +} + +/// Returns a fresh concrete standard-surface composition instance. +/// +/// The outer standard-runtime constructor installs this on the standard +/// registry and on a `Vm` when it wants default standard composition +/// behavior. Each call returns a new instance; there is no shared global. +pub fn standard_composition() -> Arc { + Arc::new(StandardSurfaceCompositionImpl) +} diff --git a/src/builtins/runtime/typed.rs b/src/builtins/runtime/typed.rs index 55612e0e..414b869a 100644 --- a/src/builtins/runtime/typed.rs +++ b/src/builtins/runtime/typed.rs @@ -45,7 +45,7 @@ pub(super) fn missing_arg(label: &str) -> VmError { VmError::HostError(format!("missing argument: {label}")) } -pub(super) trait BorrowVmValue<'a>: Sized { +pub trait BorrowVmValue<'a>: Sized { fn borrow_vm_value(value: &'a Value, label: &str) -> VmResult; fn from_missing_arg(label: &str) -> VmResult { @@ -53,7 +53,7 @@ pub(super) trait BorrowVmValue<'a>: Sized { } } -pub(super) trait FromVmValue<'a>: Sized { +pub trait FromVmValue<'a>: Sized { fn from_vm_value(value: &'a Value, label: &str) -> VmResult; fn from_missing_arg(label: &str) -> VmResult { @@ -74,7 +74,7 @@ where } } -pub(super) trait TakeVmValue: Sized { +pub trait TakeVmValue: Sized { fn take_vm_value(slot: &mut Value, label: &str) -> VmResult; fn from_missing_arg(label: &str) -> VmResult { @@ -82,7 +82,7 @@ pub(super) trait TakeVmValue: Sized { } } -pub(super) fn borrow_arg<'a, T>(args: &'a [Value], index: usize, label: &str) -> VmResult +pub fn borrow_arg<'a, T>(args: &'a [Value], index: usize, label: &str) -> VmResult where T: BorrowVmValue<'a>, { @@ -92,14 +92,14 @@ where } } -pub(super) fn arg<'a, T>(args: &'a [Value], index: usize, label: &str) -> VmResult +pub fn arg<'a, T>(args: &'a [Value], index: usize, label: &str) -> VmResult where T: BorrowVmValue<'a>, { borrow_arg(args, index, label) } -pub(super) fn take_arg(args: &mut [Value], index: usize, label: &str) -> VmResult +pub fn take_arg(args: &mut [Value], index: usize, label: &str) -> VmResult where T: TakeVmValue, { @@ -130,6 +130,15 @@ impl<'a> FromVmValue<'a> for &'a str { } } +impl FromVmValue<'_> for String { + fn from_vm_value(value: &Value, _label: &str) -> VmResult { + match value { + Value::String(text) => Ok(text.to_string()), + _ => Err(VmError::TypeMismatch("string")), + } + } +} + impl<'a> FromVmValue<'a> for &'a [u8] { fn from_vm_value(value: &'a Value, _label: &str) -> VmResult { match value { @@ -157,6 +166,15 @@ impl<'a> FromVmValue<'a> for &'a VmMap { } } +impl FromVmValue<'_> for VmMap { + fn from_vm_value(value: &Value, _label: &str) -> VmResult { + match value { + Value::Map(entries) => Ok(entries.as_ref().clone()), + _ => Err(VmError::TypeMismatch("map")), + } + } +} + impl FromVmValue<'_> for SharedArray { fn from_vm_value(value: &Value, _label: &str) -> VmResult { match value { @@ -308,15 +326,15 @@ where } } -pub(super) trait IntoVmValue { +pub trait IntoVmValue { fn into_vm_value(self) -> Value; } -pub(super) fn return_none() -> CallReturn { +pub fn return_none() -> CallReturn { CallReturn::none() } -pub(super) fn return_one(value: T) -> CallReturn +pub fn return_one(value: T) -> CallReturn where T: IntoVmValue, { @@ -465,7 +483,18 @@ where } } -pub(super) trait IntoHostCallOutcome { +impl IntoBuiltinCallOutcome for CallOutcome { + fn into_builtin_call_outcome(self) -> BuiltinCallOutcome { + match self { + CallOutcome::Return(values) => BuiltinCallOutcome::Return(values), + CallOutcome::Halt => BuiltinCallOutcome::Halt, + CallOutcome::Pending(op_id) => BuiltinCallOutcome::Pending(op_id), + CallOutcome::Yield => unreachable!("async builtin wrappers cannot return Yield"), + } + } +} + +pub trait IntoHostCallOutcome { fn into_host_call_outcome(self) -> CallOutcome; } diff --git a/src/host_api.rs b/src/host_api.rs new file mode 100644 index 00000000..e2e6e858 --- /dev/null +++ b/src/host_api.rs @@ -0,0 +1,1966 @@ +//! Shared, host-agnostic semantic model of the host API. +//! +//! This module defines an ordinary, owned, serializable-friendly description of +//! the functions and resource types a host exposes to scripts. It is deliberately +//! independent of the compiler's inference types, the VM's runtime +//! [`crate::vm`] resource table, the wire format ([`crate::vmbc`]) and the +//! generated builtin catalog ([`crate::builtins`]), so all of those can be +//! consumed without introducing a reverse dependency. +//! +//! ## Design invariants +//! +//! * **Host-agnostic.** The catalog carries only semantic signatures: scalar, +//! collection, callable and unknown schemas plus typed resource references. +//! It does not talk about handles, bytecode or VM state. +//! * **Owned and serializable-friendly.** Every type owns its data (`String` / +//! `Vec`) and derives or implements [`serde::Serialize`] / +//! [`serde::Deserialize`]. No lifetimes, no `&'static` slices, no +//! [`std::any::TypeId`]. +//! * **Validated at every boundary.** `ResourceTypeKey` and `HostApiCatalog` +//! implement *validating* deserialization, so malformed keys, duplicate +//! signatures, undeclared resource references and invalid passing modes +//! cannot enter through serde — the same rules the builder enforces. +//! * **Explicit resource ownership.** A parameter whose type **contains any +//! resource**, directly or recursively (`Optional`, `Array`, `Map`, +//! `Callable`), must use an explicit borrow/ownership passing mode; `Value` +//! is forbidden. A parameter whose type contains **no** resource must use +//! `Value`; a borrow/ownership mode is forbidden. +//! * **Overloading.** Host functions may legally share a name with distinct +//! argument signatures (standard builtins such as `len` dispatch for string, +//! array, bytes and map). Overloads must differ in their **argument type / +//! passing-mode sequence**: two functions sharing a name and an identical +//! argument type + passing sequence are ambiguous — parameter names and the +//! return type do not disambiguate call sites — so they are rejected even +//! when those fields differ. +//! * **Deterministic fingerprint.** [`HostApiCatalog::fingerprint`] produces a +//! stable digest over *semantic* fields only, prefixed by a domain magic and +//! a format version. Functions are sorted by their full canonical signature +//! bytes, so overloaded registration order is irrelevant. Documentation is +//! excluded. +//! +//! ## Fingerprint security note +//! +//! The 64-bit FNV-1a fingerprint is **not** a cryptographic digest. It is an +//! equality / change-detection fingerprint only: it is deterministic and +//! collision-resistant *enough* for detecting when two catalogs differ, but it +//! must **never** be used for authentication, integrity, or any context where +//! an attacker can influence catalog bytes. Treat `HostApiFingerprint` as a +//! convenience equality key, not a MAC. + +use std::fmt; + +use serde::Deserialize; + +/// Max byte length of a validated [`ResourceTypeKey`] name. +const MAX_RESOURCE_KEY_LEN: usize = 128; + +/// Max byte length of a validated host function name. +const MAX_FUNCTION_NAME_LEN: usize = 128; + +/// 8-byte domain magic prepended to every fingerprint so digest bytes in one +/// domain (host API catalogs) cannot be confused with unrelated FNV digests +/// produced by other tooling. +const FINGERPRINT_DOMAIN_MAGIC: &[u8; 8] = b"rss-hapi"; + +/// The fingerprint wire/format version. Bump whenever the canonical byte +/// encoding or semantic interpretation changes so old and new digests are +/// never compared across versions. +const FINGERPRINT_FORMAT_VERSION: u8 = 1; + +/// Error returned when a [`ResourceTypeKey`] cannot be constructed. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ResourceTypeKeyError { + Empty, + TooLong(usize), + InvalidChar { index: usize, ch: char }, + InvalidDotPlacement { index: usize }, +} + +impl fmt::Display for ResourceTypeKeyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => write!(f, "resource type key must not be empty"), + Self::TooLong(len) => write!( + f, + "resource type key is {len} bytes; the maximum is {MAX_RESOURCE_KEY_LEN}" + ), + Self::InvalidChar { index, ch } => write!( + f, + "resource type key contains invalid character {ch:?} at byte offset {index}" + ), + Self::InvalidDotPlacement { index } => write!( + f, + "resource type key contains an empty namespace segment at byte offset {index}" + ), + } + } +} + +impl std::error::Error for ResourceTypeKeyError {} + +/// A validated, stable identifier for a host resource type. +/// +/// The key is an ordinary lowercase dot-namespaced name such as `io.file` or +/// `sqlite.connection`. Each segment is a non-empty run of lowercase ASCII +/// letters (`a`-`z`), digits (`0`-`9`), `_` or `-`; no segment-leading-letter +/// requirement exists, so a lone-segment key such as `file` or `0host` is +/// legal. A single-segment key (e.g. `file`) is allowed and simply carries no +/// namespace. `.` is reserved purely as the separator between non-empty +/// segments, so a key may not start or end with a dot and may not contain an +/// empty segment. +/// +/// Validation rejects empty, over-long, non-ASCII and malformed-namespace +/// names so the value can serve as a stable map key, a fingerprint input and +/// a serialized identifier without further laundering. +/// +/// This deliberately replaces any reliance on [`std::any::TypeId`]: resource +/// identity is a value, not a type reflection. +/// +/// Deserialization is validating: a serialized key that fails [`Self::new`] +/// validation is rejected at the serde boundary. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize)] +pub struct ResourceTypeKey(String); + +impl ResourceTypeKey { + /// Validates and builds a resource type key. + pub fn new(name: impl Into) -> Result { + let name = name.into(); + validate_resource_key(&name)?; + Ok(Self(name)) + } + + /// The key text, e.g. `io.file`. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for ResourceTypeKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for ResourceTypeKey { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let text = String::deserialize(deserializer)?; + Self::new(text).map_err(serde::de::Error::custom) + } +} + +fn validate_resource_key(name: &str) -> Result<(), ResourceTypeKeyError> { + if name.is_empty() { + return Err(ResourceTypeKeyError::Empty); + } + if name.len() > MAX_RESOURCE_KEY_LEN { + return Err(ResourceTypeKeyError::TooLong(name.len())); + } + // Allowed: ASCII lowercase a-z, 0-9, '_' and '-', with '.' used purely as a + // namespace separator between non-empty segments. + for (index, b) in name.bytes().enumerate() { + let valid = b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-' | b'.'); + if !valid { + return Err(ResourceTypeKeyError::InvalidChar { + index, + ch: name[index..].chars().next().unwrap_or('\u{fffd}'), + }); + } + } + // Report the exact byte offset of each empty segment: a `.` that directly + // follows another `.` (or the leading dot) opens an empty segment at that + // dot, and a trailing `.` leaves an empty segment at the end of the name. + let mut segment_start = 0usize; + for (index, b) in name.bytes().enumerate() { + if b == b'.' { + if index == segment_start { + return Err(ResourceTypeKeyError::InvalidDotPlacement { index }); + } + segment_start = index + 1; + } + } + if segment_start == name.len() { + return Err(ResourceTypeKeyError::InvalidDotPlacement { + index: segment_start, + }); + } + Ok(()) +} + +/// How a host function receives a parameter. +/// +/// When a parameter's type [`contains`][HostTypeSchema::contains_resource] a +/// resource, **`Value` is forbidden** and the caller must chose one of +/// `Borrow`, `BorrowMut` or `TakeOwned`. When it contains no resource, `Value` +/// is required and a borrow/ownership mode is forbidden. +/// +/// Ownership modes compose with a parameter's *aggregate* resource content: +/// +/// * `Borrow` / `BorrowMut` apply **call-scoped, recursively** to every +/// resource contained anywhere in the type — direct, `Optional`, `Array`, +/// `Map`, or nested inside a `Callable` — so the callee may read (or +/// exclusively mutate) the whole aggregate for the duration of the call +/// without the caller losing the outer value. +/// * `TakeOwned` **transfers ownership of all contained resources** (and of +/// the value itself) to the callee; the caller no longer holds them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum HostParamPassing { + /// The parameter is a plain value with no contained resource; the callee + /// may copy or drop it freely. + Value, + /// An immutable borrow of the argument value; borrows every contained + /// resource call-scoped and recursively. + Borrow, + /// An exclusive mutable borrow of the argument value; mutably borrows + /// every contained resource call-scoped and recursively. + BorrowMut, + /// Ownership of the argument value and all contained owned resources is + /// transferred to the callee. + TakeOwned, +} + +impl HostParamPassing { + /// Whether the mode borrows, mutates or transfers rather than copying. + pub fn is_reference_mode(self) -> bool { + !matches!(self, Self::Value) + } +} + +/// Semantic schema of a single host value type. +/// +/// Covers the same scalar / collection / callable / unknown surface used by +/// the compiler's inference pass, and adds an explicit [`Self::Resource`] +/// variant that references a declared [`ResourceTypeKey`]. +#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum HostTypeSchema { + Unknown, + Null, + Int, + Float, + Number, + Bool, + String, + Bytes, + Array(Box), + Map(Box), + Optional(Box), + Callable { + params: Vec, + result: Box, + }, + /// A host resource identified by a declared [`ResourceTypeKey`]. + Resource(ResourceTypeKey), +} + +impl HostTypeSchema { + /// Returns the resource key when this schema (directly, or wrapped in a + /// single optional layer) denotes a host resource. This is a shallow + /// helper; use [`Self::contains_resource`] for the full recursive test. + pub fn resource_key(&self) -> Option<&ResourceTypeKey> { + match self { + Self::Resource(key) => Some(key), + Self::Optional(inner) => inner.resource_key(), + _ => None, + } + } + + /// Whether this schema references at least one resource, anywhere in the + /// tree (direct, `Optional`, `Array`, `Map` value, or inside a `Callable` + /// parameter/result). + pub fn contains_resource(&self) -> bool { + match self { + Self::Resource(_) => true, + Self::Array(inner) | Self::Map(inner) | Self::Optional(inner) => { + inner.contains_resource() + } + Self::Callable { params, result } => { + params.iter().any(|param| param.contains_resource()) || result.contains_resource() + } + Self::Unknown + | Self::Null + | Self::Int + | Self::Float + | Self::Number + | Self::Bool + | Self::String + | Self::Bytes => false, + } + } + + /// Collects every resource key referenced anywhere in this schema tree. + pub fn collect_resource_keys<'a>(&'a self, out: &mut Vec<&'a ResourceTypeKey>) { + match self { + Self::Resource(key) => out.push(key), + Self::Array(inner) | Self::Map(inner) | Self::Optional(inner) => { + inner.collect_resource_keys(out); + } + Self::Callable { params, result } => { + for param in params { + param.collect_resource_keys(out); + } + result.collect_resource_keys(out); + } + Self::Unknown + | Self::Null + | Self::Int + | Self::Float + | Self::Number + | Self::Bool + | Self::String + | Self::Bytes => {} + } + } +} + +impl fmt::Display for HostTypeSchema { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unknown => write!(f, "unknown"), + Self::Null => write!(f, "null"), + Self::Int => write!(f, "int"), + Self::Float => write!(f, "float"), + Self::Number => write!(f, "number"), + Self::Bool => write!(f, "bool"), + Self::String => write!(f, "string"), + Self::Bytes => write!(f, "bytes"), + Self::Array(inner) => write!(f, "array<{inner}>"), + Self::Map(inner) => write!(f, "map<{inner}>"), + Self::Optional(inner) => write!(f, "optional<{inner}>"), + Self::Callable { params, result } => { + write!(f, "fn(")?; + for (index, param) in params.iter().enumerate() { + if index > 0 { + write!(f, ", ")?; + } + write!(f, "{param}")?; + } + write!(f, ") -> {result}") + } + Self::Resource(key) => write!(f, "resource<{key}>"), + } + } +} + +/// Semantic description of one declared host resource type. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ResourceTypeSchema { + /// The stable, validated resource type key. + pub key: ResourceTypeKey, + /// Human-readable documentation; excluded from the fingerprint. + pub description: String, +} + +impl ResourceTypeSchema { + pub fn new(key: ResourceTypeKey, description: impl Into) -> Self { + Self { + key, + description: description.into(), + } + } +} + +/// Semantic description of one host function parameter. +#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct HostParamSchema { + /// Parameter name, unique within its function. + pub name: String, + pub ty: HostTypeSchema, + pub passing: HostParamPassing, +} + +impl HostParamSchema { + /// Builds a `Value`-passing parameter. Use this only when `ty` contains no + /// resource; a containing resource requires [`Self::with_passing`] with an + /// explicit borrow/ownership mode. + pub fn value(name: impl Into, ty: HostTypeSchema) -> Self { + Self { + name: name.into(), + ty, + passing: HostParamPassing::Value, + } + } + + pub fn with_passing( + name: impl Into, + ty: HostTypeSchema, + passing: HostParamPassing, + ) -> Self { + Self { + name: name.into(), + ty, + passing, + } + } +} + +/// Semantic description of one host function's signature. +/// +/// Only semantic fields (name, parameters, passing modes, return type) feed +/// the catalog fingerprint; `description` is documentation and is excluded. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct HostFunctionSchema { + pub name: String, + pub params: Vec, + pub return_type: HostTypeSchema, + /// Human-readable documentation, excluded from the fingerprint. + pub description: String, +} + +impl HostFunctionSchema { + pub fn new(name: impl Into, params: Vec) -> Self { + Self { + name: name.into(), + params, + return_type: HostTypeSchema::Unknown, + description: String::new(), + } + } + + pub fn with_return( + name: impl Into, + params: Vec, + return_type: HostTypeSchema, + ) -> Self { + Self { + name: name.into(), + params, + return_type, + description: String::new(), + } + } + + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = description.into(); + self + } + + /// Canonical semantic bytes for this function: name, then the parameter + /// list (each parameter’s name, type and passing mode), then the return + /// type. This is the full semantic encoding used by the catalog + /// fingerprint, so any semantic change (including a parameter-label or + /// return-type change) alters the digest. It is **not** used for overload + /// identity — see [`Self::overload_identity_bytes`]. + fn semantic_bytes(&self) -> Vec { + let mut bytes = Vec::new(); + push_len_str(&mut bytes, &self.name); + push_len(&mut bytes, self.params.len()); + for param in &self.params { + push_len_str(&mut bytes, ¶m.name); + push_type(&mut bytes, ¶m.ty); + push_tag(&mut bytes, passing_tag(param.passing)); + } + push_type(&mut bytes, &self.return_type); + bytes + } + + /// Canonical overload-identity bytes: the function name plus the ordered + /// parameter type schemas and passing modes only. Parameter names, the + /// return schema and documentation are deliberately excluded, so two + /// functions have the same identity precisely when their name and argument + /// type/passing sequence match. Because argument shape is what dispatch + /// and call sites resolve on, that identity being shared makes the + /// overload set ambiguous regardless of labels or return type. + /// + /// This key feeds overload duplicate detection only — never the catalog + /// fingerprint, which keeps using [`Self::semantic_bytes`]. + fn overload_identity_bytes(&self) -> Vec { + let mut bytes = Vec::new(); + push_len_str(&mut bytes, &self.name); + push_len(&mut bytes, self.params.len()); + for param in &self.params { + push_type(&mut bytes, ¶m.ty); + push_tag(&mut bytes, passing_tag(param.passing)); + } + bytes + } +} + +/// Why a host function name is invalid. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FunctionNameError { + Empty, + TooLong(usize), + InvalidChar { index: usize, ch: char }, + EmptySegment { index: usize }, +} + +impl fmt::Display for FunctionNameError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => write!(f, "host function name must not be empty"), + Self::TooLong(len) => write!( + f, + "host function name is {len} bytes; the maximum is {MAX_FUNCTION_NAME_LEN}" + ), + Self::InvalidChar { index, ch } => write!( + f, + "host function name contains invalid control/whitespace/symbol character \ + {ch:?} at byte offset {index}" + ), + Self::EmptySegment { index } => write!( + f, + "host function name contains an empty `::` path segment at byte offset {index}" + ), + } + } +} + +impl std::error::Error for FunctionNameError {} + +/// Validate a host function name against the grammar used by the standard +/// catalog, e.g. `len`, `__bind_callable`, `bytes::from_utf8`, `io::open`, +/// `jit::set_hot_loop_threshold`. +/// +/// Grammar: one or more path segments joined by the exact `::` separator, each +/// segment being a non-empty ASCII identifier (`[A-Za-z_][A-Za-z0-9_]*`). +/// Named functions must not start or end with `::`, must not contain empty +/// segments (`a::b`, `::`, `a::::b` are rejected), must not contain a lone +/// `:` and must not contain any control/whitespace/symbol outside the segment +/// alphabet. +fn validate_function_name(name: &str) -> Result<(), FunctionNameError> { + if name.is_empty() { + return Err(FunctionNameError::Empty); + } + if name.len() > MAX_FUNCTION_NAME_LEN { + return Err(FunctionNameError::TooLong(name.len())); + } + // Iterate raw bytes; allowed characters are ASCII (alphanumeric, `_`, + // `:`). Any control, whitespace, symbol (`.`, `-`, `@`, …) or non-ASCII + // byte is rejected here; the `::` separator, empty segments and any lone + // `:` are handled by the segment pass below. + for (index, b) in name.bytes().enumerate() { + if !(b.is_ascii_alphanumeric() || b == b'_' || b == b':') { + return Err(FunctionNameError::InvalidChar { + index, + ch: name[index..].chars().next().unwrap_or('\u{fffd}'), + }); + } + } + // Walk the `::`-separated segments tracking each segment's exact byte + // offset, so an empty segment is reported at the offset of the separator + // that opens it rather than at the first separator found in the name. + let mut cursor = 0usize; + for segment in name.split("::") { + if segment.is_empty() { + // `cursor` is the byte offset at which this empty segment begins: + // the start of a `::` separator, or the end of the name when the + // name ends in `::`. + return Err(FunctionNameError::EmptySegment { index: cursor }); + } + let mut chars = segment.chars(); + let first = chars.next().expect("segment is non-empty"); + let valid_start = first.is_ascii_alphabetic() || first == '_'; + if !valid_start { + return Err(FunctionNameError::InvalidChar { + index: cursor, + ch: first, + }); + } + for (offset, c) in segment.char_indices() { + if !(c.is_ascii_alphanumeric() || c == '_') { + return Err(FunctionNameError::InvalidChar { + index: cursor + offset, + ch: c, + }); + } + } + cursor += segment.len() + 2; // skip this segment and the `::` separator + } + Ok(()) +} + +/// Errors produced while building a [`HostApiCatalog`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum HostApiCatalogError { + DuplicateResourceKey(ResourceTypeKey), + /// Two registered functions share a name and an identical ordered argument + /// type/passing sequence, making the overload set ambiguous. Parameter + /// names, return type and documentation do not disambiguate call sites. + DuplicateFunctionSignature { + name: String, + }, + InvalidFunctionName { + name: String, + reason: FunctionNameError, + }, + DuplicateParameterName { + function: String, + parameter: String, + }, + UnknownResourceReference { + function: String, + key: ResourceTypeKey, + }, + /// A borrow/ownership passing mode was used on a non-resource parameter. + NonResourcePassingMode { + function: String, + parameter: String, + passing: HostParamPassing, + }, + /// A resource-containing parameter was declared with `Value`; an explicit + /// `Borrow`/`BorrowMut`/`TakeOwned` is required. + ResourceValuePassing { + function: String, + parameter: String, + }, +} + +impl fmt::Display for HostApiCatalogError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DuplicateResourceKey(key) => write!(f, "duplicate resource type key `{key}`"), + Self::DuplicateFunctionSignature { name } => write!( + f, + "duplicate host function overload `{name}`: identical name and identical \ + argument type/passing sequence (parameter names and return type cannot \ + disambiguate overloads)" + ), + Self::InvalidFunctionName { name, reason } => { + write!(f, "invalid host function name `{name}`: {reason}") + } + Self::DuplicateParameterName { + function, + parameter, + } => write!( + f, + "host function `{function}` declares duplicate parameter name `{parameter}`" + ), + Self::UnknownResourceReference { function, key } => write!( + f, + "host function `{function}` references undeclared resource type `{key}`" + ), + Self::NonResourcePassingMode { + function, + parameter, + passing, + } => write!( + f, + "host function `{function}` uses passing mode {passing:?} on non-resource \ + parameter `{parameter}`; value types must use `Value`", + ), + Self::ResourceValuePassing { + function, + parameter, + } => write!( + f, + "host function `{function}` passes resource-containing parameter `{parameter}` \ + by `Value`; an explicit Borrow/BorrowMut/TakeOwned is required", + ), + } + } +} + +impl std::error::Error for HostApiCatalogError {} + +/// An immutable, validated catalog of the host API surface. +/// +/// Construction is done via the builder ([`HostApiCatalog::builder`]) or via +/// serde; both routes run the same validation, so a catalog is only exposed +/// once all cross-references, passing-mode and name/overload invariants hold. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct HostApiCatalog { + resources: Vec, + functions: Vec, +} + +/// A deterministic 64-bit fingerprint of a [`HostApiCatalog`]. +/// +/// Computed by FNV-1a over a canonical encoding of the semantic fields only, +/// prefixed by a domain magic and a format version. This is an equality / +/// change-detection digest only — **never** an authentication or integrity +/// value. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct HostApiFingerprint(u64); + +impl HostApiFingerprint { + #[allow(dead_code)] + pub(crate) const fn from_wire(value: u64) -> Self { + Self(value) + } + + pub const fn as_u64(self) -> u64 { + self.0 + } +} + +impl serde::Serialize for HostApiFingerprint { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_u64(self.0) + } +} + +impl<'de> serde::Deserialize<'de> for HostApiFingerprint { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Ok(HostApiFingerprint(u64::deserialize(deserializer)?)) + } +} + +impl fmt::Display for HostApiFingerprint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:016x}", self.0) + } +} + +/// Mirror of [`HostApiCatalog`]’s serialized shape so `Deserialize` can parse +/// it and then re-validate, keeping serde as safe as the builder. +#[derive(serde::Deserialize)] +struct HostApiCatalogRepr { + resources: Vec, + functions: Vec, +} + +impl<'de> Deserialize<'de> for HostApiCatalog { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let repr = HostApiCatalogRepr::deserialize(deserializer)?; + let builder = HostApiBuilder { + resources: repr.resources, + functions: repr.functions, + }; + builder.build().map_err(serde::de::Error::custom) + } +} + +/// Stage-one, mutable builder for a [`HostApiCatalog`]. +/// +/// Cross-function invariants (referenced resource keys being declared, +/// reference/ownership passing modes, overload signatures, name grammar, +/// duplicate parameter names) are enforced in [`HostApiBuilder::build`], which +/// is what makes construction order independent. +#[derive(Clone, Debug, Default)] +pub struct HostApiBuilder { + resources: Vec, + functions: Vec, +} + +impl Default for HostApiCatalog { + fn default() -> Self { + Self::builder() + .build() + .expect("empty catalog is always valid") + } +} + +impl HostApiCatalog { + /// Starts an empty, validated-construction catalog builder. + pub fn builder() -> HostApiBuilder { + HostApiBuilder::default() + } + + /// Looks up a host function by exact name, returning it **only when it is + /// unambiguous** (exactly one registered function matches). If none match, + /// or the name is legally overloaded, this returns `None` — use + /// [`Self::functions_named`] to resolve overloads. + pub fn function(&self, name: &str) -> Option<&HostFunctionSchema> { + match self.functions_named(name)[..] { + [single] => Some(single), + _ => None, + } + } + + /// All host functions registered under the given name, preserving + /// registration order. An empty slice means the name is not declared; a + /// non-empty slice of length > 1 means the name is overloaded. + pub fn functions_named(&self, name: &str) -> Vec<&HostFunctionSchema> { + self.functions + .iter() + .filter(|function| function.name == name) + .collect() + } + + /// Looks up a declared resource type by key text. + pub fn resource(&self, key: &str) -> Option<&ResourceTypeSchema> { + self.resources + .iter() + .find(|resource| resource.key.as_str() == key) + } + + /// Whether the catalog declares the given resource type key. + pub fn has_resource(&self, key: &ResourceTypeKey) -> bool { + self.resources.iter().any(|resource| &resource.key == key) + } + + /// All declared resource types (in registration order). + pub fn resources(&self) -> &[ResourceTypeSchema] { + &self.resources + } + + /// All host functions (in registration order). + pub fn functions(&self) -> &[HostFunctionSchema] { + &self.functions + } + + /// Canonical semantic bytes for the whole catalog: `FINGERPRINT_DOMAIN_MAGIC` + /// ++ `FINGERPRINT_FORMAT_VERSION` ++ resources (sorted by key) ++ + /// functions (sorted by full semantic signature bytes). + fn canonical_bytes(&self) -> Vec { + let mut bytes = Vec::new(); + + bytes.extend_from_slice(FINGERPRINT_DOMAIN_MAGIC); + bytes.push(FINGERPRINT_FORMAT_VERSION); + + // Resources sorted by key text. + let mut resources: Vec<&ResourceTypeSchema> = self.resources.iter().collect(); + resources.sort_by(|a, b| a.key.cmp(&b.key)); + push_tag(&mut bytes, b'R'); + push_len(&mut bytes, resources.len()); + for resource in &resources { + push_len_str(&mut bytes, resource.key.as_str()); + } + + // Functions sorted by their full canonical semantic signature bytes so + // overloaded registration order is irrelevant (exact duplicates are + // already rejected at build time). + let mut functions: Vec<&HostFunctionSchema> = self.functions.iter().collect(); + functions.sort_by_key(|a| a.semantic_bytes()); + push_tag(&mut bytes, b'F'); + push_len(&mut bytes, functions.len()); + for function in &functions { + bytes.extend(function.semantic_bytes()); + } + + bytes + } + + /// Deterministic, order-independent fingerprint of the semantic contents. + /// + /// The fingerprint covers resource keys and every function’s name, + /// parameter (name, type, passing mode) and return type. It excludes + /// documentation and registration order. See the module doc for the + /// security caveat: this 64-bit FNV digest is equality / change-detection + /// only, never authentication. + pub fn fingerprint(&self) -> HostApiFingerprint { + HostApiFingerprint(fnv1a(&self.canonical_bytes())) + } +} + +/// Validate the caller-supplied resource/function collections. Shared by the +/// builder and the serde path so both reject the same malformed inputs. +fn validate_surface( + resources: &[ResourceTypeSchema], + functions: &[HostFunctionSchema], +) -> Result<(), HostApiCatalogError> { + // Duplicate resource keys. + for (i, resource) in resources.iter().enumerate() { + if resources[..i].iter().any(|prior| prior.key == resource.key) { + return Err(HostApiCatalogError::DuplicateResourceKey( + resource.key.clone(), + )); + } + } + + // Per-function invariants. + for function in functions { + // Valid function name. + if let Err(reason) = validate_function_name(&function.name) { + return Err(HostApiCatalogError::InvalidFunctionName { + name: function.name.clone(), + reason, + }); + } + + // Unique parameter names. + for (i, param) in function.params.iter().enumerate() { + if function.params[..i] + .iter() + .any(|prior| prior.name == param.name) + { + return Err(HostApiCatalogError::DuplicateParameterName { + function: function.name.clone(), + parameter: param.name.clone(), + }); + } + } + + // Passing-mode and resource-reference invariants. + for param in &function.params { + let contains_resource = param.ty.contains_resource(); + if contains_resource { + // A resource-containing parameter must use an explicit mode. + if param.passing == HostParamPassing::Value { + return Err(HostApiCatalogError::ResourceValuePassing { + function: function.name.clone(), + parameter: param.name.clone(), + }); + } + } else if param.passing.is_reference_mode() { + // A non-resource parameter must use `Value`. + return Err(HostApiCatalogError::NonResourcePassingMode { + function: function.name.clone(), + parameter: param.name.clone(), + passing: param.passing, + }); + } + + // Every referenced resource key must be declared. + let mut keys = Vec::new(); + param.ty.collect_resource_keys(&mut keys); + for key in keys { + if !resources.iter().any(|resource| &resource.key == key) { + return Err(HostApiCatalogError::UnknownResourceReference { + function: function.name.clone(), + key: key.clone(), + }); + } + } + } + + // Return references must be declared too. + let mut keys = Vec::new(); + function.return_type.collect_resource_keys(&mut keys); + for key in keys { + if !resources.iter().any(|resource| &resource.key == key) { + return Err(HostApiCatalogError::UnknownResourceReference { + function: function.name.clone(), + key: key.clone(), + }); + } + } + } + + // Reject ambiguous overloads: two functions sharing a name and an identical + // ordered argument type/passing sequence. Parameter names and the return + // type do not disambiguate call sites, so same-name overloads that differ + // only in labels or return schema are rejected. Legal overloads (same name, + // distinct argument schema) are allowed. + for (i, function) in functions.iter().enumerate() { + let identity = function.overload_identity_bytes(); + for prior in &functions[..i] { + if prior.overload_identity_bytes() == identity { + return Err(HostApiCatalogError::DuplicateFunctionSignature { + name: function.name.clone(), + }); + } + } + } + + Ok(()) +} + +impl HostApiBuilder { + /// Starts an empty catalog builder. + pub fn new() -> Self { + Self::default() + } + + /// Registers a resource type. + pub fn resource(&mut self, resource: ResourceTypeSchema) { + self.resources.push(resource); + } + + /// Registers a host function signature. Same-name functions with distinct + /// signatures (overloads) are allowed. + pub fn function(&mut self, function: HostFunctionSchema) { + self.functions.push(function); + } + + /// Returns the number of resource types registered so far. + pub fn resource_count(&self) -> usize { + self.resources.len() + } + + /// Returns the number of functions registered so far. + pub fn function_count(&self) -> usize { + self.functions.len() + } + + /// Validates and freezes the catalog. + pub fn build(self) -> Result { + validate_surface(&self.resources, &self.functions)?; + Ok(HostApiCatalog { + resources: self.resources, + functions: self.functions, + }) + } +} + +fn push_tag(bytes: &mut Vec, tag: u8) { + bytes.push(tag); +} + +fn push_len(bytes: &mut Vec, value: usize) { + // Fixed 8-byte little-endian length so encodings are unambiguous, and any + // structural field write is order-independent in aggregate. + bytes.extend_from_slice(&(value as u64).to_le_bytes()); +} + +fn push_len_str(bytes: &mut Vec, value: &str) { + push_len(bytes, value.len()); + bytes.extend_from_slice(value.as_bytes()); +} + +fn push_type(bytes: &mut Vec, schema: &HostTypeSchema) { + match schema { + HostTypeSchema::Unknown => push_tag(bytes, b'U'), + HostTypeSchema::Null => push_tag(bytes, b'N'), + HostTypeSchema::Int => push_tag(bytes, b'I'), + HostTypeSchema::Float => push_tag(bytes, b'F'), + HostTypeSchema::Number => push_tag(bytes, b'#'), + HostTypeSchema::Bool => push_tag(bytes, b'B'), + HostTypeSchema::String => push_tag(bytes, b'S'), + HostTypeSchema::Bytes => push_tag(bytes, b'Y'), + HostTypeSchema::Array(inner) => { + push_tag(bytes, b'['); + push_type(bytes, inner); + } + HostTypeSchema::Map(inner) => { + push_tag(bytes, b'{'); + push_type(bytes, inner); + } + HostTypeSchema::Optional(inner) => { + push_tag(bytes, b'?'); + push_type(bytes, inner); + } + HostTypeSchema::Callable { params, result } => { + push_tag(bytes, b'c'); + push_len(bytes, params.len()); + for param in params { + push_type(bytes, param); + } + push_type(bytes, result); + } + HostTypeSchema::Resource(key) => { + push_tag(bytes, b'r'); + push_len_str(bytes, key.as_str()); + } + } +} + +fn passing_tag(passing: HostParamPassing) -> u8 { + match passing { + HostParamPassing::Value => b'v', + // Distinct tags so Borrow and BorrowMut are semantically different. + HostParamPassing::Borrow => b'b', + HostParamPassing::BorrowMut => b'm', + HostParamPassing::TakeOwned => b'o', + } +} + +const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; +const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; + +fn fnv1a(bytes: &[u8]) -> u64 { + let mut hash = FNV_OFFSET_BASIS; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(FNV_PRIME); + } + hash +} +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn io_file_key() -> ResourceTypeKey { + ResourceTypeKey::new("io.file").expect("valid key") + } + + fn sqlite_connection_key() -> ResourceTypeKey { + ResourceTypeKey::new("sqlite.connection").expect("valid key") + } + + fn io_file_resource() -> ResourceTypeSchema { + ResourceTypeSchema::new(io_file_key(), "An open file handle") + } + + fn sqlite_connection_resource() -> ResourceTypeSchema { + ResourceTypeSchema::new(sqlite_connection_key(), "An open SQLite connection") + } + + fn fn_io_open(docs: &str) -> HostFunctionSchema { + HostFunctionSchema::with_return( + "io::open", + vec![ + HostParamSchema::value("path", HostTypeSchema::String), + HostParamSchema::value("mode", HostTypeSchema::String), + ], + HostTypeSchema::Resource(io_file_key()), + ) + .with_description(docs) + } + + fn fn_io_read_all(passing: HostParamPassing) -> HostFunctionSchema { + HostFunctionSchema::with_return( + "io::read_all", + vec![HostParamSchema::with_passing( + "handle", + HostTypeSchema::Resource(io_file_key()), + passing, + )], + HostTypeSchema::String, + ) + } + + fn fn_sqlite_open() -> HostFunctionSchema { + HostFunctionSchema::with_return( + "sqlite::open", + vec![HostParamSchema::value("path", HostTypeSchema::String)], + HostTypeSchema::Resource(sqlite_connection_key()), + ) + } + + fn catalog_with_io_and_sqlite() -> HostApiCatalog { + let mut builder = HostApiCatalog::builder(); + builder.resource(io_file_resource()); + builder.resource(sqlite_connection_resource()); + builder.function(fn_io_open("docs")); + builder.function(fn_io_read_all(HostParamPassing::Borrow)); + builder.function(fn_sqlite_open()); + builder.build().expect("valid catalog") + } + + // --- ResourceTypeKey validation --- + + #[test] + fn resource_type_key_validation() { + assert!(ResourceTypeKey::new("io.file").is_ok()); + assert!(ResourceTypeKey::new("sqlite.connection").is_ok()); + assert!(ResourceTypeKey::new("a-b_c.0").is_ok()); + assert_eq!(ResourceTypeKey::new(""), Err(ResourceTypeKeyError::Empty)); + assert!(ResourceTypeKey::new("A").is_err()); + assert!(ResourceTypeKey::new("has space").is_err()); + assert!(ResourceTypeKey::new(".leading").is_err()); + assert!(ResourceTypeKey::new("trailing.").is_err()); + assert!(ResourceTypeKey::new("double..dot").is_err()); + assert!(ResourceTypeKey::new("a".repeat(129)).is_err()); + } + + #[test] + fn resource_type_key_deduplicates_by_value() { + assert_eq!( + ResourceTypeKey::new("io.file").unwrap(), + ResourceTypeKey::new("io.file").unwrap() + ); + assert_ne!( + ResourceTypeKey::new("io.file").unwrap(), + ResourceTypeKey::new("io.file2").unwrap() + ); + } + + // --- Catalog construction validation --- + + #[test] + fn duplicate_resource_key_rejected() { + let mut builder = HostApiCatalog::builder(); + builder.resource(io_file_resource()); + builder.resource(ResourceTypeSchema::new(io_file_key(), "duplicate")); + assert_eq!( + builder.build(), + Err(HostApiCatalogError::DuplicateResourceKey(io_file_key())) + ); + } + + // --- Overloading --- + + #[test] + fn legal_overloads_allowed() { + // Standard builtins legally overload `len` for multiple value shapes. + let mut builder = HostApiCatalog::builder(); + builder.function(HostFunctionSchema::with_return( + "len", + vec![HostParamSchema::value("value", HostTypeSchema::String)], + HostTypeSchema::Int, + )); + builder.function(HostFunctionSchema::with_return( + "len", + vec![HostParamSchema::value( + "value", + HostTypeSchema::Map(Box::new(HostTypeSchema::String)), + )], + HostTypeSchema::Int, + )); + builder.function(HostFunctionSchema::with_return( + "len", + vec![HostParamSchema::value("value", HostTypeSchema::Bytes)], + HostTypeSchema::Int, + )); + let catalog = builder.build().expect("legal overloads must build"); + assert_eq!(catalog.functions_named("len").len(), 3); + // Ambiguous name => `function` returns None, `functions_named` returns all. + assert!(catalog.function("len").is_none()); + } + + #[test] + fn exact_duplicate_overload_rejected() { + let mut builder = HostApiCatalog::builder(); + builder.resource(io_file_resource()); + builder.function(fn_io_open("one")); + // Identical name, identical params, identical return => exact duplicate. + let duplicate = HostFunctionSchema::with_return( + "io::open", + vec![ + HostParamSchema::value("path", HostTypeSchema::String), + HostParamSchema::value("mode", HostTypeSchema::String), + ], + HostTypeSchema::Resource(io_file_key()), + ); + builder.function(duplicate); + assert_eq!( + builder.build(), + Err(HostApiCatalogError::DuplicateFunctionSignature { + name: "io::open".to_string() + }) + ); + } + + #[test] + fn same_signature_different_return_rejected() { + // Same name, same argument type/passing sequence, but a differing + // return type: still ambiguous at call sites, so rejected. + let mut builder = HostApiCatalog::builder(); + builder.function(HostFunctionSchema::with_return( + "convert", + vec![HostParamSchema::value("value", HostTypeSchema::Int)], + HostTypeSchema::Int, + )); + builder.function(HostFunctionSchema::with_return( + "convert", + vec![HostParamSchema::value("value", HostTypeSchema::Int)], + HostTypeSchema::String, + )); + assert_eq!( + builder.build(), + Err(HostApiCatalogError::DuplicateFunctionSignature { + name: "convert".to_string() + }) + ); + } + + #[test] + fn same_signature_different_parameter_labels_rejected() { + // Same name, same argument types+passing, but different parameter + // labels => identical overload identity, so rejected. + let mut builder = HostApiCatalog::builder(); + builder.function(HostFunctionSchema::with_return( + "get", + vec![ + HostParamSchema::value("a", HostTypeSchema::Int), + HostParamSchema::value("b", HostTypeSchema::String), + ], + HostTypeSchema::Int, + )); + builder.function(HostFunctionSchema::with_return( + "get", + vec![ + HostParamSchema::value("x", HostTypeSchema::Int), + HostParamSchema::value("y", HostTypeSchema::String), + ], + HostTypeSchema::Int, + )); + assert_eq!( + builder.build(), + Err(HostApiCatalogError::DuplicateFunctionSignature { + name: "get".to_string() + }) + ); + } + + #[test] + fn ambiguous_argument_identity_with_resource_same_passing_rejected() { + // Same resource argument and borrowing mode in both overloads, differing + // only in the return resource: argument identity is the same => rejected. + let mut builder = HostApiCatalog::builder(); + builder.resource(io_file_resource()); + builder.resource(sqlite_connection_resource()); + builder.function(HostFunctionSchema::with_return( + "open", + vec![HostParamSchema::with_passing( + "path", + HostTypeSchema::String, + HostParamPassing::Value, + )], + HostTypeSchema::Resource(io_file_key()), + )); + builder.function(HostFunctionSchema::with_return( + "open", + vec![HostParamSchema::with_passing( + "loc", + HostTypeSchema::String, + HostParamPassing::Value, + )], + HostTypeSchema::Resource(sqlite_connection_key()), + )); + assert_eq!( + builder.build(), + Err(HostApiCatalogError::DuplicateFunctionSignature { + name: "open".to_string() + }) + ); + } + + #[test] + fn ambiguous_function_lookup_returns_none() { + let mut builder = HostApiCatalog::builder(); + builder.function(HostFunctionSchema::with_return( + "len", + vec![HostParamSchema::value("value", HostTypeSchema::String)], + HostTypeSchema::Int, + )); + builder.function(HostFunctionSchema::with_return( + "len", + vec![HostParamSchema::value("value", HostTypeSchema::Int)], + HostTypeSchema::Int, + )); + let catalog = builder.build().expect("valid"); + assert!(catalog.function("len").is_none()); + assert_eq!(catalog.functions_named("len").len(), 2); + assert!(catalog.function("absent").is_none()); + assert!(catalog.functions_named("absent").is_empty()); + } + + #[test] + fn unambiguous_function_lookup_returns_it() { + let catalog = catalog_with_io_and_sqlite(); + assert_eq!( + catalog.function("io::open").expect("unique").name, + "io::open" + ); + assert!(catalog.function("io::read_all").is_some()); + } + + // --- Ownership mode enforcement --- + + #[test] + fn non_resource_borrow_rejected() { + let mut builder = HostApiCatalog::builder(); + builder.resource(io_file_resource()); + builder.function(HostFunctionSchema::with_return( + "write", + vec![HostParamSchema::with_passing( + "text", + HostTypeSchema::String, + HostParamPassing::Borrow, + )], + HostTypeSchema::Null, + )); + assert_eq!( + builder.build(), + Err(HostApiCatalogError::NonResourcePassingMode { + function: "write".to_string(), + parameter: "text".to_string(), + passing: HostParamPassing::Borrow, + }) + ); + } + + #[test] + fn non_resource_take_owned_rejected() { + let mut builder = HostApiCatalog::builder(); + builder.resource(io_file_resource()); + builder.function(HostFunctionSchema::with_return( + "consume", + vec![HostParamSchema::with_passing( + "value", + HostTypeSchema::Int, + HostParamPassing::TakeOwned, + )], + HostTypeSchema::Int, + )); + assert_eq!( + builder.build(), + Err(HostApiCatalogError::NonResourcePassingMode { + function: "consume".to_string(), + parameter: "value".to_string(), + passing: HostParamPassing::TakeOwned, + }) + ); + } + + #[test] + fn non_resource_deeply_nested_borrow_rejected() { + // An Array contains no resource, so Borrow is forbidden even + // though `resource_key()` (shallow) would say None too. + let mut builder = HostApiCatalog::builder(); + let array_of_strings = HostTypeSchema::Array(Box::new(HostTypeSchema::String)); + assert!(!array_of_strings.contains_resource()); + builder.function(HostFunctionSchema::with_return( + "join", + vec![HostParamSchema::with_passing( + "parts", + array_of_strings, + HostParamPassing::Borrow, + )], + HostTypeSchema::String, + )); + assert!(matches!( + builder.build(), + Err(HostApiCatalogError::NonResourcePassingMode { .. }) + )); + } + + #[test] + fn resource_value_passing_rejected() { + for ty in [ + HostTypeSchema::Resource(io_file_key()), + HostTypeSchema::Optional(Box::new(HostTypeSchema::Resource(io_file_key()))), + HostTypeSchema::Array(Box::new(HostTypeSchema::Resource(io_file_key()))), + HostTypeSchema::Map(Box::new(HostTypeSchema::Resource(io_file_key()))), + HostTypeSchema::Callable { + params: vec![HostTypeSchema::Resource(io_file_key())], + result: Box::new(HostTypeSchema::String), + }, + ] { + assert!(ty.contains_resource(), "schema must carry a resource"); + let mut builder = HostApiCatalog::builder(); + builder.resource(io_file_resource()); + builder.function(HostFunctionSchema::with_return( + "takes_resource", + vec![HostParamSchema::value("value", ty)], + HostTypeSchema::Null, + )); + assert_eq!( + builder.build(), + Err(HostApiCatalogError::ResourceValuePassing { + function: "takes_resource".to_string(), + parameter: "value".to_string(), + }) + ); + } + } + + #[test] + fn resource_in_container_with_explicit_pass_allowed() { + // An Array may be passed with an explicit mode (Borrow), + // which applies call-scoped to the contained resources. + let mut builder = HostApiCatalog::builder(); + builder.resource(io_file_resource()); + builder.function(HostFunctionSchema::with_return( + "close_all", + vec![HostParamSchema::with_passing( + "handles", + HostTypeSchema::Array(Box::new(HostTypeSchema::Resource(io_file_key()))), + HostParamPassing::Borrow, + )], + HostTypeSchema::Null, + )); + builder.function(HostFunctionSchema::with_return( + "reap", + vec![HostParamSchema::with_passing( + "handles", + HostTypeSchema::Map(Box::new(HostTypeSchema::Resource(io_file_key()))), + HostParamPassing::TakeOwned, + )], + HostTypeSchema::Null, + )); + builder + .build() + .expect("explicit aggregate passing is valid"); + } + + #[test] + fn undeclared_resource_in_param_rejected() { + let mut builder = HostApiCatalog::builder(); + builder.resource(io_file_resource()); + builder.function(HostFunctionSchema::with_return( + "use_missing", + vec![HostParamSchema::with_passing( + "h", + HostTypeSchema::Resource(ResourceTypeKey::new("missing.file").unwrap()), + HostParamPassing::Borrow, + )], + HostTypeSchema::Null, + )); + assert!(matches!( + builder.build(), + Err(HostApiCatalogError::UnknownResourceReference { .. }) + )); + } + + #[test] + fn undeclared_resource_return_rejected() { + let mut builder = HostApiCatalog::builder(); + builder.function(HostFunctionSchema::with_return( + "open", + vec![], + HostTypeSchema::Resource(ResourceTypeKey::new("missing.file").unwrap()), + )); + assert!(matches!( + builder.build(), + Err(HostApiCatalogError::UnknownResourceReference { .. }) + )); + } + + #[test] + fn undeclared_resource_inside_container_rejected() { + let mut builder = HostApiCatalog::builder(); + builder.resource(io_file_resource()); + builder.function(HostFunctionSchema::with_return( + "get_files", + vec![], + HostTypeSchema::Map(Box::new(HostTypeSchema::Resource( + ResourceTypeKey::new("db.files").unwrap(), + ))), + )); + assert!(matches!( + builder.build(), + Err(HostApiCatalogError::UnknownResourceReference { .. }) + )); + } + + // --- Function name grammar --- + + #[test] + fn function_name_grammar_accepts_standard_names() { + for name in [ + "len", + "__bind_callable", + "bytes::from_utf8", + "io::open", + "io::read_all", + "jit::set_hot_loop_threshold", + "math::atan2", + "bytes::from_array_u8", + "_private", + ] { + assert!( + validate_function_name(name).is_ok(), + "`{name}` must be a valid host function name" + ); + } + } + + #[test] + fn function_name_grammar_rejects_malformed() { + let invalid: &[&str] = &[ + "", + "::leading", + "trailing::", + "double::::colon", + "a:b", // lone single colon, not `::` + "a a", // whitespace + "1abc", // segment starts with digit + "a-b", // hyphen is a symbol + "a.b", // dot is a resource-key separator, not a function separator + "-x", // leading symbol + "a\nb", // control/whitespace + "a\\tb", // tab + "caf\u{e9}", // non-ASCII (é) + "a\"b", // quote symbol + ]; + for name in invalid { + assert!( + validate_function_name(name).is_err(), + "`{name}` should be rejected as a host function name" + ); + } + } + + #[test] + fn function_name_too_long_rejected() { + let too_long = "a".repeat(MAX_FUNCTION_NAME_LEN + 1); + assert_eq!( + validate_function_name(&too_long), + Err(FunctionNameError::TooLong(too_long.len())) + ); + } + + #[test] + fn empty_function_name_rejected() { + assert_eq!(validate_function_name(""), Err(FunctionNameError::Empty)); + } + + #[test] + fn invalid_function_name_rejected_at_build() { + let mut builder = HostApiCatalog::builder(); + builder.function(HostFunctionSchema::new("bad name", vec![])); + assert!(matches!( + builder.build(), + Err(HostApiCatalogError::InvalidFunctionName { .. }) + )); + } + + // --- Duplicate parameter names --- + + #[test] + fn duplicate_parameter_name_rejected() { + let mut builder = HostApiCatalog::builder(); + builder.function(HostFunctionSchema::with_return( + "dup", + vec![ + HostParamSchema::value("a", HostTypeSchema::Int), + HostParamSchema::value("a", HostTypeSchema::String), + ], + HostTypeSchema::Null, + )); + assert_eq!( + builder.build(), + Err(HostApiCatalogError::DuplicateParameterName { + function: "dup".to_string(), + parameter: "a".to_string(), + }) + ); + } + + // --- Display --- + + #[test] + fn resource_displays_as_resource_angle_brackets() { + let s = HostTypeSchema::Resource(io_file_key()); + assert_eq!(format!("{s}"), "resource"); + let opt = HostTypeSchema::Optional(Box::new(s)); + assert_eq!(format!("{opt}"), "optional>"); + } + + // --- Fingerprint semantics --- + + #[test] + fn fingerprint_has_domain_magic_and_version() { + let catalog = catalog_with_io_and_sqlite(); + let bytes = catalog.canonical_bytes(); + assert_eq!( + &bytes[..FINGERPRINT_DOMAIN_MAGIC.len()], + FINGERPRINT_DOMAIN_MAGIC + ); + assert_eq!( + bytes[FINGERPRINT_DOMAIN_MAGIC.len()], + FINGERPRINT_FORMAT_VERSION + ); + assert_ne!(catalog.fingerprint().as_u64(), 0); + } + + #[test] + fn fingerprint_version_is_one() { + assert_eq!(FINGERPRINT_FORMAT_VERSION, 1); + } + + #[test] + fn order_independent_fingerprint() { + let mut builder_a = HostApiCatalog::builder(); + builder_a.resource(io_file_resource()); + builder_a.resource(sqlite_connection_resource()); + builder_a.function(fn_io_read_all(HostParamPassing::Borrow)); + builder_a.function(fn_sqlite_open()); + builder_a.function(fn_io_open("docs")); + let catalog_a = builder_a.build().expect("valid"); + + let mut builder_b = HostApiCatalog::builder(); + builder_b.function(fn_io_open("other docs")); + builder_b.resource(sqlite_connection_resource()); + builder_b.function(fn_sqlite_open()); + builder_b.resource(io_file_resource()); + builder_b.function(fn_io_read_all(HostParamPassing::Borrow)); + let catalog_b = builder_b.build().expect("valid"); + + assert_eq!(catalog_a.fingerprint(), catalog_b.fingerprint()); + } + + /// Two catalogs exposing the same overloaded `len` set but registered in + /// different orders must fingerprint identically. + #[test] + fn overload_order_independent_fingerprint() { + let mut builder_a = HostApiCatalog::builder(); + builder_a.function(len_overload(HostTypeSchema::String)); + builder_a.function(len_overload(HostTypeSchema::Array(Box::new( + HostTypeSchema::Int, + )))); + builder_a.function(len_overload(HostTypeSchema::Bytes)); + let a = builder_a.build().expect("valid"); + + let mut builder_b = HostApiCatalog::builder(); + builder_b.function(len_overload(HostTypeSchema::Bytes)); + builder_b.function(len_overload(HostTypeSchema::String)); + builder_b.function(len_overload(HostTypeSchema::Array(Box::new( + HostTypeSchema::Int, + )))); + let b = builder_b.build().expect("valid"); + + assert_eq!(a.fingerprint(), b.fingerprint()); + assert_eq!(a.fingerprint(), a.fingerprint()); + + // Adding a distinct overload changes the fingerprint (semantic change). + let mut builder_c = HostApiCatalog::builder(); + builder_c.function(len_overload(HostTypeSchema::String)); + builder_c.function(len_overload(HostTypeSchema::Array(Box::new( + HostTypeSchema::Int, + )))); + builder_c.function(len_overload(HostTypeSchema::Map(Box::new( + HostTypeSchema::String, + )))); + let c = builder_c.build().expect("valid"); + assert_ne!(a.fingerprint(), c.fingerprint()); + } + + #[test] + fn semantic_change_alters_fingerprint() { + let base = catalog_with_io_and_sqlite(); + + let mut builder = HostApiCatalog::builder(); + builder.resource(io_file_resource()); + builder.resource(sqlite_connection_resource()); + builder.function(HostFunctionSchema::with_return( + "io::open", + vec![ + HostParamSchema::value("path", HostTypeSchema::String), + HostParamSchema::value("mode", HostTypeSchema::String), + ], + HostTypeSchema::String, + )); + builder.function(fn_io_read_all(HostParamPassing::Borrow)); + builder.function(fn_sqlite_open()); + let changed = builder.build().expect("valid"); + + assert_ne!(base.fingerprint(), changed.fingerprint()); + } + + #[test] + fn param_label_change_alters_fingerprint() { + // Overload identity ignores labels, but the fingerprint must still see + // them (semantic_bytes is unchanged and label-full). + let mut a = HostApiCatalog::builder(); + a.function(HostFunctionSchema::with_return( + "f", + vec![HostParamSchema::value("a", HostTypeSchema::Int)], + HostTypeSchema::Int, + )); + let catalog_a = a.build().expect("valid"); + + let mut b = HostApiCatalog::builder(); + b.function(HostFunctionSchema::with_return( + "f", + vec![HostParamSchema::value("renamed", HostTypeSchema::Int)], + HostTypeSchema::Int, + )); + let catalog_b = b.build().expect("valid"); + + assert_ne!(catalog_a.fingerprint(), catalog_b.fingerprint()); + } + + #[test] + fn return_type_change_alters_fingerprint() { + // Two catalogs whose only difference is a return type must have + // distinct fingerprints. + let mut a = HostApiCatalog::builder(); + a.function(HostFunctionSchema::with_return( + "convert", + vec![HostParamSchema::value("value", HostTypeSchema::Int)], + HostTypeSchema::Int, + )); + let catalog_a = a.build().expect("valid"); + + let mut b = HostApiCatalog::builder(); + b.function(HostFunctionSchema::with_return( + "convert", + vec![HostParamSchema::value("value", HostTypeSchema::Int)], + HostTypeSchema::String, + )); + let catalog_b = b.build().expect("valid"); + + assert_ne!(catalog_a.fingerprint(), catalog_b.fingerprint()); + } + + #[test] + fn passing_mode_change_alters_fingerprint() { + let base = catalog_with_io_and_sqlite(); + + let mut builder = HostApiCatalog::builder(); + builder.resource(io_file_resource()); + builder.resource(sqlite_connection_resource()); + builder.function(fn_io_open("docs")); + builder.function(fn_io_read_all(HostParamPassing::TakeOwned)); + builder.function(fn_sqlite_open()); + let changed = builder.build().expect("valid"); + + assert_ne!(base.fingerprint(), changed.fingerprint()); + assert_ne!( + passing_tag(HostParamPassing::Borrow), + passing_tag(HostParamPassing::BorrowMut) + ); + } + + #[test] + fn docs_change_does_not_alter_fingerprint() { + let mut a = HostApiCatalog::builder(); + a.resource(io_file_resource()); + a.function(fn_io_open("first description")); + a.function(fn_io_read_all(HostParamPassing::Borrow)); + let catalog_a = a.build().expect("valid"); + + let mut b = HostApiCatalog::builder(); + b.resource(ResourceTypeSchema::new( + io_file_key(), + "completely different docs", + )); + b.function(fn_io_open("second description")); + b.function(fn_io_read_all(HostParamPassing::Borrow)); + let catalog_b = b.build().expect("valid"); + + assert_eq!(catalog_a.fingerprint(), catalog_b.fingerprint()); + assert_ne!(catalog_a, catalog_b); + } + + #[test] + fn fingerprint_is_stable() { + let catalog = catalog_with_io_and_sqlite(); + assert_eq!(catalog.fingerprint(), catalog.fingerprint()); + } + + // --- Lookups --- + + #[test] + fn lookup_function_and_resource() { + let catalog = catalog_with_io_and_sqlite(); + + let open = catalog.function("io::open").expect("io::open present"); + assert_eq!(open.params.len(), 2); + assert_eq!(open.return_type, HostTypeSchema::Resource(io_file_key())); + + let read = catalog.function("io::read_all").expect("present"); + assert_eq!(read.params[0].passing, HostParamPassing::Borrow); + + let sqlite = catalog.function("sqlite::open").expect("present"); + assert_eq!( + sqlite.return_type, + HostTypeSchema::Resource(sqlite_connection_key()) + ); + + assert!(catalog.resource("io.file").is_some()); + assert!(catalog.resource("sqlite.connection").is_some()); + assert!(catalog.has_resource(&io_file_key())); + assert!(catalog.has_resource(&sqlite_connection_key())); + assert!(catalog.resource("does.not.exist").is_none()); + assert!(catalog.function("io::nope").is_none()); + } + + // --- Serde / validating deserialization --- + + fn valid_catalog_json() -> serde_json::Value { + json!({ + "resources": [{ "key": "io.file", "description": "file" }], + "functions": [{ + "name": "io::read_all", + "params": [ + { "name": "handle", "ty": { "Resource": "io.file" }, "passing": "Borrow" } + ], + "return_type": "String", + "description": "" + }] + }) + } + + #[test] + fn serde_round_trip_valid_catalog() { + let catalog: HostApiCatalog = + serde_json::from_value(valid_catalog_json()).expect("valid JSON should deserialize"); + assert_eq!(catalog.fingerprint(), catalog.fingerprint()); + assert_eq!(catalog.functions_named("io::read_all").len(), 1); + } + + #[test] + fn serde_rejects_malformed_resource_key() { + // A bare malformed key must fail ResourceTypeKey's own Deserialize. + assert!(serde_json::from_str::("\"bad key\"").is_err()); + assert!(serde_json::from_str::("\"a..b\"").is_err()); + + // And a malformed key hiding inside a catalog's resources must fail. + let mut v = valid_catalog_json(); + v["resources"][0]["key"] = json!("has space"); + assert!(serde_json::from_value::(v).is_err()); + } + + #[test] + fn serde_rejects_duplicate_overload() { + // Same name, identical params, identical return -> duplicate overload. + let mut v = valid_catalog_json(); + let dup = v["functions"][0].clone(); + v["functions"].as_array_mut().unwrap().push(dup); + assert!(serde_json::from_value::(v).is_err()); + } + + #[test] + fn serde_rejects_ambiguous_overload_by_arg_identity() { + // The serde path runs the same validate_surface as the builder: two + // functions sharing a name and argument type/passing sequence are + // rejected even when only the return type differs. + let hostile = r#"{ + "resources": [], + "functions": [ + { + "name": "convert", + "params": [ + { "name": "value", "ty": "Int", "passing": "Value" } + ], + "return_type": "Int", + "description": "" + }, + { + "name": "convert", + "params": [ + { "name": "value", "ty": "Int", "passing": "Value" } + ], + "return_type": "String", + "description": "" + } + ] + }"#; + assert!(serde_json::from_str::(hostile).is_err()); + } + + #[test] + fn serde_rejects_undeclared_resource_reference() { + let mut v = valid_catalog_json(); + v["functions"][0]["params"][0]["ty"] = json!({ "Resource": "missing.file" }); + assert!(serde_json::from_value::(v).is_err()); + } + + #[test] + fn serde_rejects_invalid_passing_modes() { + // Value on a resource-containing param. + let mut v = valid_catalog_json(); + v["functions"][0]["params"][0]["passing"] = json!("Value"); + assert!(serde_json::from_value::(v).is_err()); + + // A borrow on a non-resource (String) param. + let mut v = valid_catalog_json(); + v["functions"][0]["params"][0]["ty"] = json!("String"); + v["functions"][0]["params"][0]["passing"] = json!("Borrow"); + assert!(serde_json::from_value::(v).is_err()); + } + + #[test] + fn serde_rejects_invalid_function_name() { + let mut v = valid_catalog_json(); + v["functions"][0]["name"] = json!("bad name"); + assert!(serde_json::from_value::(v).is_err()); + } + + #[test] + fn serde_rejects_duplicate_parameter_name() { + let mut v = valid_catalog_json(); + v["functions"][0]["params"] = json!([ + { "name": "x", "ty": "String", "passing": "Value" }, + { "name": "x", "ty": "Int", "passing": "Value" } + ]); + assert!(serde_json::from_value::(v).is_err()); + } + + #[test] + fn fingerprint_serde_round_trip_and_value() { + let fp = HostApiFingerprint(0xdead_beef); + let s = serde_json::to_string(&fp).unwrap(); + assert_eq!(s, "3735928559"); // u64 numeric via transparent + let back: HostApiFingerprint = serde_json::from_str(&s).unwrap(); + assert_eq!(back, fp); + assert_eq!(back.as_u64(), fp.as_u64()); + } + + // --- helpers used by tests above --- + + fn len_overload(ty: HostTypeSchema) -> HostFunctionSchema { + HostFunctionSchema::with_return( + "len", + vec![HostParamSchema::value("value", ty)], + HostTypeSchema::Int, + ) + } +} diff --git a/src/lib.rs b/src/lib.rs index ae16ca5c..5abe5d2c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub mod compiler; pub mod debug_info; #[cfg(feature = "runtime")] pub mod debugger; +pub mod host_api; #[cfg(feature = "runtime")] pub mod jit { pub use crate::vm::jit::{ @@ -22,11 +23,18 @@ pub mod vmbc; pub use assembler::{AsmParseError, Assembler, AssemblerError, BytecodeBuilder, assemble}; #[cfg(feature = "runtime")] -pub use builtins::runtime::HostCallResult; -#[cfg(feature = "runtime")] pub use builtins::runtime::print::{PrintHostFunction, PrintlnHostFunction, format_value}; #[cfg(all(feature = "runtime", feature = "sqlite", not(target_arch = "wasm32")))] pub use builtins::runtime::sqlite::{SqliteLimits, SqlitePolicy}; +#[cfg(feature = "runtime")] +pub use builtins::runtime::standard_composition; +#[cfg(feature = "runtime")] +pub use builtins::runtime::{ + BorrowVmValue, FromVmValue, HostCallResult, IntoHostCallOutcome, TakeVmValue, arg, borrow_arg, + return_one, take_arg, +}; +#[cfg(all(feature = "runtime", not(target_arch = "wasm32")))] +pub use builtins::runtime::{IoHostExt, IoPolicy}; pub use builtins::{ BUILTIN_CATALOG, BuiltinFunction, BuiltinNamespaceMemberSpec, BuiltinNamespaceSpec, CallableDef, CallableParam, CallableParamType, CallableSignature, HostExecution, @@ -39,6 +47,11 @@ pub use bytecode::{ CaptureBindingMode, ExportedCallable, FunctionRegion, HostImport, OpCode, Program, RootCallableBinding, ScriptFunction, TypeMap, Value, ValueType, }; +pub use host_api::{ + FunctionNameError, HostApiBuilder, HostApiCatalog, HostApiCatalogError, HostApiFingerprint, + HostFunctionSchema, HostParamPassing, HostParamSchema, HostTypeSchema, ResourceTypeKey, + ResourceTypeKeyError, ResourceTypeSchema, +}; pub fn builtin_call_index(name: &str) -> Option { use builtins::BuiltinFunction; @@ -84,12 +97,18 @@ pub use jit::{ pub use vm::diagnostics::render_vm_error; #[cfg(feature = "runtime")] pub use vm::{ - AotArtifactError, CallOutcome, CallReturn, DEFAULT_MAX_SCRIPT_CALL_DEPTH, EpochCheckpoint, - EpochHandle, FuelCheckpoint, HostArgsFunction, HostAsyncBridge, HostBindingPlan, HostFunction, - HostFunctionRegistry, HostOpId, HostStackFunction, IntoScriptValue, QueuedScriptInvocation, - ResourceCloseReason, ScriptArgs, ScriptCallback, ScriptResult, StaticHostArgsFunction, - StaticHostFunction, StaticHostStackFunction, Store, Vm, VmError, VmResult, VmStatus, - VmYieldReason, execution_scope, operation, resource, + AotArtifactError, CallOutcome, CallReturn, CapabilityProfile, CapabilityProfileBuilder, + CaptureAsyncHostContext, DEFAULT_MAX_SCRIPT_CALL_DEPTH, EpochCheckpoint, EpochHandle, + FuelCheckpoint, HostArgsFunction, HostAsyncBridge, HostBindingPlan, HostContext, + HostContextError, HostContextErrorKind, HostContextResult, HostExtension, HostFunction, + HostFunctionRegistry, HostFuture, HostFutureOutput, HostImportParam, HostImportSchema, + HostModule, HostModuleState, HostOpId, HostStackFunction, IntoScriptValue, + QueuedScriptInvocation, ResourceCloseReason, ScriptArgs, ScriptCallback, ScriptResult, + StandardSurfaceComposition, StaticHostArgsFunction, StaticHostFunction, + StaticHostStackFunction, Store, Vm, VmError, VmResult, VmStatus, VmYieldReason, async_host, + catalog_import_schemas, execution_scope, host_context, host_extension, operation, + register_catalog_function, resource, validate_catalog_import_schemas, + validate_catalog_import_schemas_with_fingerprints, }; #[cfg(feature = "runtime")] pub use vmbc::{ diff --git a/src/vm/async_host/mod.rs b/src/vm/async_host/mod.rs new file mode 100644 index 00000000..8dda97ee --- /dev/null +++ b/src/vm/async_host/mod.rs @@ -0,0 +1,122 @@ +//! Generic async host execution SDK. +//! +//! This module provides the public surface for submitting async host +//! functions that do not borrow the VM across a poll. The two pieces are: +//! +//! * [`HostFutureOutput`] — the terminal result of an async host call, +//! either an already-produced [`CallReturn`] or a [`VmCompletion`] closure +//! that must run against the VM (e.g. to insert a resource into the +//! execution scope) before the call can return to the guest. +//! * [`CaptureAsyncHostContext`] — the trait async host functions use to +//! capture owned, `'static` host context from the VM before submission. +//! +//! Submitted futures are handed to the configured [`HostAsyncBridge`], which +//! owns their polling on its own executor. The returned host-operation id is +//! tracked in the host runtime's `submitted_host_ops` set so the VM routes +//! the waiting dispatch to the bridge's [`poll_submitted_op`] instead of a +//! runtime-owned operation driver. Async host operations therefore go +//! through the bridge (the concrete driver of the submitted future); the VM +//! never builds a second cancellation framework or static poller table. + +use std::future::Future; +use std::pin::Pin; + +use super::*; + +/// A completion closure that runs against the VM after the async call's +/// future has resolved. +pub type HostVmCompletion = Box VmResult + Send + 'static>; + +/// The terminal result of a submitted async host call. +/// +/// `T` is the value produced without further VM access (`Return`), or the +/// value produced by a completion closure that borrows the VM once +/// (`VmCompletion`). +pub enum HostFutureOutput { + Return(T), + VmCompletion(HostVmCompletion), +} + +impl HostFutureOutput { + /// Wraps an already-produced value. + pub fn returning(value: T) -> Self { + Self::Return(value) + } + + /// Wraps a completion closure that must run against the VM to produce + /// the value. + pub fn complete(completion: impl FnOnce(&mut Vm) -> VmResult + Send + 'static) -> Self { + Self::VmCompletion(Box::new(completion)) + } + + /// Maps the produced value through `map`, deferring the mapping until + /// the completion closure (if any) has run against the VM. + pub fn map( + self, + map: impl FnOnce(T) -> U + Send + 'static, + ) -> HostFutureOutput + where + T: Send + 'static, + { + match self { + Self::Return(value) => HostFutureOutput::Return(map(value)), + Self::VmCompletion(completion) => { + HostFutureOutput::VmCompletion(Box::new(move |vm| completion(vm).map(map))) + } + } + } +} + +impl HostFutureOutput { + /// Resolves the terminal output against the VM: a `Return` value is + /// returned directly; a `VmCompletion` closure runs with `&mut Vm`. + pub(crate) fn finish(self, vm: &mut Vm) -> VmResult { + match self { + Self::Return(values) => Ok(values), + Self::VmCompletion(completion) => completion(vm), + } + } +} + +impl From for HostFutureOutput { + fn from(values: CallReturn) -> Self { + Self::Return(values) + } +} + +/// A boxed, owned future produced by an async host function submission. +pub type HostFuture = Pin> + Send + 'static>>; + +/// Allows an async host function to capture owned host context from the VM +/// before its future is submitted. +/// +/// Async host functions cannot borrow the VM across a poll; they must +/// capture everything they need as owned, `'static` values. Implementors +/// run in the VM thread during the originating host call. +pub trait CaptureAsyncHostContext: Send + 'static + Sized { + fn capture(vm: &mut Vm) -> VmResult; + + fn capture_with_args(vm: &mut Vm, _args: &[Value]) -> VmResult { + Self::capture(vm) + } +} + +impl Vm { + /// Submits an async host future to the configured async host bridge. + /// + /// The future is handed to the bridge, which owns its polling on its own + /// executor. A fresh host-operation id is allocated, recorded in the + /// bridge's submitted set, and returned as a `Pending` call outcome. + /// + /// Requires a configured [`HostAsyncBridge`] that accepts submitted + /// futures; otherwise a host error is returned. + pub fn submit_host_future(&mut self, future: HostFuture) -> VmResult { + let op_id = self.allocate_host_op_id(); + let bridge = self.host.async_bridge.as_mut().ok_or_else(|| { + VmError::HostError("async host function requires a host async bridge".to_string()) + })?; + bridge.submit_op(op_id, future)?; + self.host.submitted_host_ops.insert(op_id); + Ok(CallOutcome::Pending(op_id)) + } +} diff --git a/src/vm/capability.rs b/src/vm/capability.rs new file mode 100644 index 00000000..9c60be11 --- /dev/null +++ b/src/vm/capability.rs @@ -0,0 +1,168 @@ +use crate::builtins::BuiltinFunction; + +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-v2"; + +/// 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, + 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(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(), + }; + 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(), + }; + 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, +} + +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 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(); + let fingerprint = fingerprint( + self.allow_all_builtins, + self.allow_all_host_imports, + &self.allowed_builtin_calls, + &self.allowed_host_imports, + ); + 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, + fingerprint, + } + } +} + +fn fingerprint( + allow_all_builtins: bool, + allow_all_host_imports: bool, + builtin_calls: &[u16], + host_imports: &[String], +) -> 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()); + } + value +} + +fn update_fingerprint(state: &mut u64, bytes: &[u8]) { + for byte in bytes { + *state ^= u64::from(*byte); + *state = state.wrapping_mul(FNV_PRIME); + } +} diff --git a/src/vm/execution_scope.rs b/src/vm/execution_scope.rs index 000dabc0..f3512153 100644 --- a/src/vm/execution_scope.rs +++ b/src/vm/execution_scope.rs @@ -245,6 +245,13 @@ impl ExecutionScope { &self.resources } + /// Mutable access to the owned resource table (typed borrows for the + /// duration of a host call). New inserts must still go through the guarded + /// scope API. + pub fn resources_mut(&mut self) -> &mut ResourceTable { + &mut self.resources + } + /// Read access to the owned operation registry (observe counts/status). /// New starts must go through the guarded scope API. pub fn operations(&self) -> &OperationRegistry { diff --git a/src/vm/host.rs b/src/vm/host.rs index 4e70840d..bc160fe2 100644 --- a/src/vm/host.rs +++ b/src/vm/host.rs @@ -1,8 +1,12 @@ +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, OnceLock, RwLock}; use std::task::{Context, Poll, Wake, Waker}; use crate::builtins::BuiltinFunction; +use crate::vm::operation::OperationCancelReason; +use super::async_host::{HostFuture, HostFutureOutput}; +use super::capability::CapabilityProfile; use super::*; pub type HostOpId = u64; @@ -86,9 +90,28 @@ pub trait HostArgsFunction: Send { } pub trait HostAsyncBridge: Send { + fn submit_op(&mut self, _op_id: HostOpId, _future: HostFuture) -> VmResult<()> { + Err(VmError::HostError( + "async host bridge does not accept submitted futures".to_string(), + )) + } + fn poll_op(&mut self, op_id: HostOpId, cx: &mut Context<'_>) -> Poll>; + fn poll_submitted_op( + &mut self, + op_id: HostOpId, + cx: &mut Context<'_>, + ) -> Poll> { + self.poll_op(op_id, cx) + .map(|result| result.map(HostFutureOutput::Return)) + } + fn cancel_op(&mut self, _op_id: HostOpId) {} + + fn cancel_op_with_reason(&mut self, op_id: HostOpId, _reason: OperationCancelReason) { + self.cancel_op(op_id); + } } pub type StaticHostFunction = fn(&mut Vm, &[Value]) -> VmResult; @@ -121,6 +144,15 @@ pub struct HostBindingPlan { import_signature: Vec, registry_slots: Vec, resolved_calls: Vec, + allowed_builtin_calls: Vec, + allow_default_builtin_capabilities: bool, + allowed_host_function_slots: Vec, + allow_default_host_capabilities: bool, + capability_profile: Arc, + capability_fingerprint: u64, + registry_state: Arc<()>, + registry_generation_token: Arc<()>, + registry_generation: u64, } #[derive(Clone)] @@ -128,6 +160,18 @@ pub struct HostFunctionRegistry { entries: Arc>, by_name: Arc>, plan_cache: Arc, Arc>>>, + allowed_builtin_calls: Arc>, + allow_default_builtin_capabilities: bool, + allow_default_host_capabilities: bool, + capability_profile: Arc, + registry_state: Arc<()>, + registry_generation_token: Arc<()>, + registry_generation: Arc, + /// Caller-provided standard-surface composition strategy, if installed. + /// + /// This is explicit per-instance state: the outer standard-runtime + /// constructor installs it; `src/vm` never names a concrete domain. + standard_composition: Option>, } impl Default for HostFunctionRegistry { @@ -137,27 +181,111 @@ impl Default for HostFunctionRegistry { } impl HostFunctionRegistry { - fn empty() -> Self { + pub fn empty() -> Self { Self { entries: Arc::new(Vec::new()), by_name: Arc::new(HashMap::new()), plan_cache: Arc::new(RwLock::new(HashMap::new())), + allowed_builtin_calls: Arc::new(Vec::new()), + allow_default_builtin_capabilities: true, + allow_default_host_capabilities: true, + capability_profile: Arc::new(CapabilityProfile::allow_all()), + registry_state: Arc::new(()), + registry_generation_token: Arc::new(()), + registry_generation: Arc::new(AtomicU64::new(0)), + standard_composition: None, } } pub fn new() -> Self { static DEFAULT_REGISTRY: OnceLock = OnceLock::new(); - DEFAULT_REGISTRY + let mut registry = DEFAULT_REGISTRY .get_or_init(|| { let mut registry = Self::empty(); crate::builtins::runtime::register_default_host_functions(&mut registry); + registry.allow_default_builtin_capabilities = true; + registry.allow_default_host_capabilities = true; registry }) - .clone() + .clone(); + registry.plan_cache = Arc::new(RwLock::new(HashMap::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)); + registry + } + + /// Returns the standard host registry with every registered host function present but + /// requiring an explicit capability grant before execution. + pub fn restricted() -> Self { + let mut registry = Self::new(); + registry.allow_default_builtin_capabilities = false; + registry.allow_default_host_capabilities = false; + 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)); + registry.invalidate_plan_cache(); + 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(); + } + + /// Installs the caller-provided standard-surface composition strategy. + /// + /// Explicit per-instance state: the outer standard-runtime constructor + /// installs it; `src/vm` never names a concrete domain module or feature. + pub fn set_standard_composition( + &mut self, + composition: Arc, + ) { + self.standard_composition = Some(composition); + } + + /// The installed standard-surface composition strategy, if any. + pub fn standard_composition( + &self, + ) -> Option<&Arc> { + self.standard_composition.as_ref() + } + + /// Whether a host function with the given name is currently registered. + pub fn contains_name(&self, name: &str) -> bool { + self.by_name.contains_key(name) + } + + /// 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 self.by_name.contains_key(name) { + self.capability_profile = Arc::new(self.capability_profile.with_host_import(name)); + self.invalidate_plan_cache(); + return Ok(()); + } + let builtin = BuiltinFunction::from_namespaced_name(name) + .ok_or_else(|| VmError::HostError(format!("unknown namespaced builtin '{name}'")))?; + let calls = Arc::make_mut(&mut self.allowed_builtin_calls); + if !calls.contains(&builtin.call_index()) { + calls.push(builtin.call_index()); + calls.sort_unstable(); + } + self.capability_profile = Arc::new(self.capability_profile.with_builtin(builtin)); + self.invalidate_plan_cache(); + Ok(()) } fn invalidate_plan_cache(&mut self) { + self.registry_state = Arc::new(()); + self.registry_generation.fetch_add(1, Ordering::Relaxed); self.plan_cache = Arc::new(RwLock::new(HashMap::new())); } @@ -343,7 +471,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) } @@ -356,6 +528,17 @@ impl HostFunctionRegistry { self.plan_for_imports(imports) } + fn plan_matches_current(&self, plan: &HostBindingPlan) -> bool { + 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, + &plan.registry_generation_token, + ) + && self.registry_generation.load(Ordering::Relaxed) == plan.registry_generation + } + fn plan_for_imports(&self, imports: &[HostImport]) -> VmResult> { if let Some(plan) = self .plan_cache @@ -363,6 +546,7 @@ impl HostFunctionRegistry { .expect("host binding plan cache read lock should not be poisoned") .get(imports) .cloned() + && self.plan_matches_current(&plan) { return Ok(plan); } @@ -381,6 +565,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(), @@ -400,25 +592,66 @@ impl HostFunctionRegistry { resolved_calls.push(vm_slot); } + let allowed_host_function_slots = imports + .iter() + .zip(resolved_calls.iter().copied()) + .filter_map(|(import, vm_slot)| { + self.capability_profile + .allows_host_import(&import.name) + .then_some(vm_slot) + }) + .collect::>(); let import_key = imports.to_vec(); let computed = Arc::new(HostBindingPlan { import_signature: import_key.clone(), registry_slots, resolved_calls, + allowed_builtin_calls: self.allowed_builtin_calls.as_ref().clone(), + allow_default_builtin_capabilities: self.allow_default_builtin_capabilities, + allowed_host_function_slots, + allow_default_host_capabilities: self.allow_default_host_capabilities, + capability_profile: Arc::clone(&self.capability_profile), + 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), }); let mut cache = self .plan_cache .write() .expect("host binding plan cache write lock should not be poisoned"); - Ok(cache.entry(import_key).or_insert_with(|| computed).clone()) + cache.insert(import_key, Arc::clone(&computed)); + Ok(computed) } 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 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.registry_state, &plan.registry_state) { + return Err(VmError::HostError( + "host binding plan belongs to a different registry state".to_string(), + )); + } + if !Arc::ptr_eq( + &self.registry_generation_token, + &plan.registry_generation_token, + ) || self.registry_generation.load(Ordering::Relaxed) != plan.registry_generation + { + return Err(VmError::HostError( + "host binding plan is stale for this registry".to_string(), + )); + } if !vm.host.host_functions.is_empty() || !vm.host.host_function_symbols.is_empty() { return Err(VmError::HostError( "host binding cache requires an unbound vm".to_string(), @@ -455,6 +688,11 @@ impl HostFunctionRegistry { } } } + vm.set_default_host_fallback_enabled(false); + vm.host.allowed_builtin_calls = plan.allowed_builtin_calls.clone(); + vm.host.allow_default_builtin_capabilities = plan.allow_default_builtin_capabilities; + vm.host.allowed_host_function_slots = plan.allowed_host_function_slots.clone(); + vm.host.allow_default_host_capabilities = plan.allow_default_host_capabilities; vm.install_resolved_calls(plan.resolved_calls.clone())?; Ok(()) } @@ -896,6 +1134,21 @@ impl Vm { Ok(()) } + /// Enables or disables implicit binding of built-in host functions. + /// + /// Disabling this makes the VM use only explicitly registered host + /// functions. The default remains enabled for backwards compatibility + /// until a registry is bound. + pub fn set_default_host_fallback_enabled(&mut self, enabled: bool) { + self.host.allow_default_host_fallback = enabled; + self.host.resolved_calls_dirty = true; + } + + /// Whether unbound host imports fall back to the default host functions. + pub fn default_host_fallback_enabled(&self) -> bool { + self.host.allow_default_host_fallback + } + pub fn allocate_host_op_id(&mut self) -> HostOpId { let op_id = self.host.next_host_op_id; self.host.next_host_op_id = self.host.next_host_op_id.wrapping_add(1).max(1); @@ -912,6 +1165,7 @@ impl Vm { }; match waiting.source { WaitingHostOpSource::HostBridge => { + self.host.submitted_host_ops.remove(&waiting.op_id); if let Some(bridge) = self.host.async_bridge.as_mut() { bridge.cancel_op(waiting.op_id); } @@ -939,7 +1193,10 @@ impl Vm { return Poll::Ready(Ok(())); }; - let poll_result = match waiting.source { + // The HostBridge arm produces a `HostFutureOutput` (so a submitted + // future's completion closure can run against the VM); the runtime + // builtin arms produce an already-finished `CallReturn`. + let poll_result: Poll> = match waiting.source { WaitingHostOpSource::HostBridge => { let bridge_ptr = match self.host.async_bridge.as_mut() { Some(bridge) => bridge.as_mut() as *mut dyn HostAsyncBridge, @@ -950,25 +1207,61 @@ impl Vm { )))); } }; - - unsafe { (&mut *bridge_ptr).poll_op(waiting.op_id, cx) } + let submitted = self.host.submitted_host_ops.contains(&waiting.op_id); + // SAFETY: `bridge_ptr` was derived from the unique mutable borrow of + // `self.host.async_bridge` above. The bridge methods receive only the + // pointer's `&mut` reborrow, not `self`, so they cannot move or replace + // the owning `Box`; the pointer is used only for this synchronous call. + unsafe { + if submitted { + (&mut *bridge_ptr).poll_submitted_op(waiting.op_id, cx) + } else { + (&mut *bridge_ptr) + .poll_op(waiting.op_id, cx) + .map(|result| result.map(HostFutureOutput::Return)) + } + } } WaitingHostOpSource::BuiltinIo => { crate::builtins::runtime::poll_builtin_io_op(self, waiting.op_id, cx) + .map(|result| result.map(HostFutureOutput::Return)) } #[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))] WaitingHostOpSource::BuiltinSqlite => { crate::builtins::runtime::poll_builtin_sqlite_op(self, waiting.op_id, cx) + .map(|result| result.map(HostFutureOutput::Return)) } }; match poll_result { Poll::Pending => Poll::Pending, - Poll::Ready(Ok(values)) => { + Poll::Ready(Ok(output)) => { + let host_bridge_owned = self.host.submitted_host_ops.contains(&waiting.op_id); + let values = match output.finish(self) { + Ok(values) => values, + Err(err) => { + if host_bridge_owned { + self.host.submitted_host_ops.remove(&waiting.op_id); + } + self.instance.waiting_host_op = None; + return Poll::Ready(Err(err)); + } + }; + if host_bridge_owned { + self.host.submitted_host_ops.remove(&waiting.op_id); + if let Some(bridge) = self.host.async_bridge.as_mut() { + bridge.cancel_op(waiting.op_id); + } + } self.complete_waiting_host_op(waiting.op_id, values)?; Poll::Ready(Ok(())) } Poll::Ready(Err(err)) => { + if self.host.submitted_host_ops.remove(&waiting.op_id) + && let Some(bridge) = self.host.async_bridge.as_mut() + { + bridge.cancel_op(waiting.op_id); + } self.instance.waiting_host_op = None; Poll::Ready(Err(err)) } @@ -1009,6 +1302,12 @@ impl Vm { ) -> VmResult { let argc = argc_u8 as usize; if let Some(builtin) = BuiltinFunction::from_call_index(index) { + if builtin.requires_explicit_host_capability() + && !self.host.allow_default_builtin_capabilities + && !self.host.allowed_builtin_calls.contains(&index) + { + return Err(VmError::UnboundImport(builtin.name().to_string())); + } if !builtin.accepts_arity(argc_u8) { return Err(VmError::InvalidCallArity { import: builtin.name().to_string(), @@ -1037,6 +1336,20 @@ impl Vm { .get(usize::from(index)) .map(|import| import.return_type); let resolved_index = self.resolve_call_target(index, argc_u8)?; + if !self.host.allow_default_host_capabilities + && !self + .host + .allowed_host_function_slots + .contains(&resolved_index) + { + let import_name = self + .program + .imports + .get(usize::from(index)) + .map(|import| import.name.clone()) + .unwrap_or_else(|| format!("host slot {resolved_index}")); + return Err(VmError::UnboundImport(import_name)); + } if let Some(function) = self .host .host_functions @@ -1127,8 +1440,20 @@ impl Vm { crate::builtins::runtime::BuiltinCallOutcome::Pending(op_id) => { self.instance.stack.truncate(arg_start); let resume_ip = self.call_resume_ip(call_ip)?; - let source = builtin_waiting_source(builtin); - self.set_waiting_host_op(op_id, source)?; + if self.host.submitted_host_ops.contains(&op_id) { + if let Err(error) = + self.set_waiting_host_op(op_id, WaitingHostOpSource::HostBridge) + { + self.host.submitted_host_ops.remove(&op_id); + if let Some(bridge) = self.host.async_bridge.as_mut() { + bridge.cancel_op(op_id); + } + return Err(error); + } + } else { + let source = builtin_waiting_source(builtin); + self.set_waiting_host_op(op_id, source)?; + } self.instance.ip = resume_ip; Ok(HostCallExecOutcome::Pending(op_id)) } @@ -1839,7 +2164,10 @@ impl Vm { return Ok(()); } - if self.host.host_function_symbols.is_empty() && self.host.host_functions.is_empty() { + if self.host.allow_default_host_fallback + && self.host.host_function_symbols.is_empty() + && self.host.host_functions.is_empty() + { let import_names = self .program .imports @@ -1866,7 +2194,9 @@ impl Vm { let bound = if let Some(bound) = self.host.host_function_symbols.get(&import.name).copied() { bound - } else if crate::builtins::runtime::bind_default_host_function(self, &import.name) { + } else if self.host.allow_default_host_fallback + && crate::builtins::runtime::bind_default_host_function(self, &import.name) + { self.host .host_function_symbols .get(&import.name) diff --git a/src/vm/host_context.rs b/src/vm/host_context.rs new file mode 100644 index 00000000..61e8ab60 --- /dev/null +++ b/src/vm/host_context.rs @@ -0,0 +1,530 @@ +//! Generic host boundary: typed per-VM module state and the generic +//! host-agnostic execution-scope SDK. +//! +//! [`HostContext`] is the public, builtin-agnostic surface that a host +//! embedding or an external host extension (a module living outside +//! `src/builtins/**`) uses to register typed, per-VM module state, push typed +//! [`HostResource`]s, start [`HostOperation`]s, and read back resources / +//! operation status. Every scope SDK method delegates to the +//! [`ExecutionScope`] owned by the underlying +//! [`HostRuntime`](super::host_runtime::HostRuntime), so all inserts land in +//! the same live scope and a Closing/Quiescent scope rejects them with a +//! structured [`ExecutionScopeError::ScopeClosing`] (propagated through +//! [`HostContextErrorKind::Scope`]). +//! +//! It never hands out the underlying [`HostRuntime`](super::host_runtime::HostRuntime) +//! and never names a builtin domain module; concrete SQLite / IO / HTTP / SSE +//! remain same-crate builtins, but `src/vm` must not depend on any of their +//! implementation modules or on `rusqlite`. +//! +//! **Boundary contract (enforced by `tests/host_context_arch_tests.rs`):** +//! this module references neither `crate::builtins::*` nor `rusqlite`. +//! +//! Host module state is owned directly by [`HostRuntime`]: typed, per-VM, and +//! deliberately **not** cleared on +//! [`Vm::reset_for_reuse`](super::Vm::reset_for_reuse) or on execution-scope +//! close. Registered state therefore survives invocation resets — and scope +//! recycling — for the lifetime of the VM. +#![allow(clippy::result_large_err)] + +use std::any::{Any, TypeId}; +use std::collections::HashMap; +use std::fmt; +use std::task::{Context, Poll}; + +use super::Vm; +use super::execution_scope::{ExecutionScope, ExecutionScopeError, ScopeState}; +use super::host_runtime::HostRuntime; +use super::operation::{ + OperationCancelReason, OperationError, OperationId, OperationOutcome, OperationSpec, + OperationStatus, +}; +use super::resource::{ + CloseProgress, HostResource, Resource, ResourceCloseReason, ResourceError, ResourceHandle, + ResourceMut, ResourceRef, +}; + +/// Marker bound for a typed chunk of per-VM host module state. +/// +/// A host extension implements this for exactly one concrete `State` type and +/// registers it through [`HostContext::set_module_state`]. State is typed at +/// compile time (keyed by [`TypeId`]) and is per-`Vm`; it is intentionally not +/// cleared by [`Vm::reset_for_reuse`](super::Vm::reset_for_reuse) or by +/// execution-scope close, so policy / extension configuration survives across +/// invocation resets. +pub trait HostModule: Any + Send + 'static {} + +/// Blanket implementation so any `Send` value can be registered as typed +/// per-VM module state; the trait remains a documentation/constraint marker. +impl HostModule for T {} + +/// Structured failure kind carried by [`HostContextError`]. +/// +/// The generic boundary preserves the underlying structured error instead of +/// flattening it into a message, so callers can match machine-readably (e.g. +/// a rejected insert while the scope is Closing surfaces as +/// [`Self::Scope`]`(ExecutionScopeError::ScopeClosing)`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum HostContextErrorKind { + /// A plain boundary failure carrying only namespace + message. + Generic, + /// A structured failure from the execution scope (write / lifecycle + /// path: insert rejection while Closing, shutdown sequencing). + Scope(ExecutionScopeError), + /// A structured failure from the resource layer (typed borrow / handle + /// recovery). + Resource(ResourceError), + /// A structured failure from the operation layer (status query). + Operation(OperationError), +} + +/// Error surfaced by the generic host boundary. +/// +/// Carries a stable, non-domain `namespace` plus a human-readable message so +/// host-agnostic failures can be surfaced without referencing any builtin +/// domain type, and a structured [`HostContextErrorKind`] so generic +/// lifecycle violations stay machine-matchable. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HostContextError { + namespace: &'static str, + message: String, + kind: HostContextErrorKind, +} + +impl HostContextError { + /// Builds a boundary error with a stable (non-domain) namespace. + pub fn new(namespace: &'static str, message: impl Into) -> Self { + Self { + namespace, + message: message.into(), + kind: HostContextErrorKind::Generic, + } + } + + /// Builds a boundary error from a structured execution-scope failure. + fn from_scope(error: ExecutionScopeError) -> Self { + let message = error.to_string(); + Self { + namespace: "host::scope", + message, + kind: HostContextErrorKind::Scope(error), + } + } + + /// Builds a boundary error from a structured resource-layer failure. + fn from_resource(error: ResourceError) -> Self { + let message = error.to_string(); + Self { + namespace: "host::resource", + message, + kind: HostContextErrorKind::Resource(error), + } + } + + /// Builds a boundary error from a structured operation-layer failure. + fn from_operation(error: OperationError) -> Self { + let message = error.to_string(); + Self { + namespace: "host::operation", + message, + kind: HostContextErrorKind::Operation(error), + } + } + + /// The stable non-domain namespace of this error (e.g. `"host::module"`). + pub fn namespace(&self) -> &'static str { + self.namespace + } + + /// The human readable error message. + pub fn message(&self) -> &str { + &self.message + } + + /// The structured failure kind of this error. + pub fn kind(&self) -> &HostContextErrorKind { + &self.kind + } +} + +impl fmt::Display for HostContextError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: {}", self.namespace, self.message) + } +} + +impl std::error::Error for HostContextError {} + +/// Result type used by the generic host boundary. +pub type HostContextResult = Result; + +/// The public, generic host boundary for one [`Vm`](super::Vm). +/// +/// Obtained from [`Vm::host_context`](super::Vm::host_context). It never leaks +/// the underlying [`HostRuntime`] and never references a builtin domain module, +/// so external host extensions can register typed per-VM state and drive the +/// generic execution scope through a stable public surface. +pub struct HostContext<'a> { + vm: &'a mut Vm, +} + +impl<'a> HostContext<'a> { + pub(crate) fn new(vm: &'a mut Vm) -> Self { + Self { vm } + } + + /// Registers typed per-VM module state, replacing any earlier value of the + /// same type. + /// + /// Returns `true` when a previously registered value of the same type was + /// replaced, and `false` when this value was freshly registered. + pub fn set_module_state(&mut self, state: M) -> bool { + self.vm.host.set_module_state(state) + } + + /// Borrows the registered typed module state, if any. + pub fn module_state(&self) -> Option<&M> { + self.vm.host.get_module_state() + } + + /// Borrows the registered typed module state mutably, if any. + pub fn module_state_mut(&mut self) -> Option<&mut M> { + self.vm.host.get_module_state_mut() + } + + /// Removes and returns the registered typed module state, if any. + pub fn take_module_state(&mut self) -> Option { + self.vm.host.remove_module_state() + } + + /// Returns `true` when no module state is currently registered. + pub fn is_module_state_empty(&self) -> bool { + self.vm.host.is_module_state_empty() + } + + // ---- generic execution-scope SDK --------------------------------------- + + /// Read-only access to the execution scope owned by this VM's host + /// runtime (observe lifecycle state, resource/operation counts, typed + /// borrows and status). + /// + /// The scope is never handed out mutably through the generic boundary: all + /// mutations flow through the guarded SDK methods below. + pub fn execution_scope(&self) -> &ExecutionScope { + &self.vm.host.execution_scope + } + + /// The current lifecycle phase of this VM's execution scope. + pub fn scope_state(&self) -> ScopeState { + self.vm.host.execution_scope.state() + } + + /// Whether the execution scope is still accepting resource / operation + /// inserts. + pub fn is_scope_active(&self) -> bool { + self.vm.host.execution_scope.is_active() + } + + /// Whether the execution scope reached terminal quiescence. + pub fn is_scope_quiescent(&self) -> bool { + self.vm.host.execution_scope.is_quiescent() + } + + /// Number of live resources in the current execution scope. + pub fn resource_count(&self) -> usize { + self.vm.host.execution_scope.resources().len() + } + + /// Number of occupied operation slots in the current execution scope. + pub fn operation_count(&self) -> usize { + self.vm.host.execution_scope.operations().len() + } + + /// Inserts a typed [`HostResource`] into the current execution scope, + /// returning its typed capability token. + /// + /// A Closing/Quiescent scope rejects the insert with a structured + /// [`HostContextErrorKind::Scope`]`(`[`ExecutionScopeError::ScopeClosing`]`)`. + pub fn push_resource(&mut self, value: T) -> HostContextResult> { + self.vm + .host + .execution_scope + .push_resource(value) + .map_err(HostContextError::from_scope) + } + + /// Alias for [`Self::push_resource`], matching the public extension SDK + /// naming for inserting a typed [`HostResource`] into the current scope. + pub fn insert_resource(&mut self, value: T) -> HostContextResult> { + self.push_resource(value) + } + + /// Starts a host operation in the current execution scope from a full + /// generic [`OperationSpec`] (concrete [`HostOperation`] driver, optional + /// deadline, optional cleanup). + /// + /// External operations must produce concrete [`HostOperation`] drivers: + /// the scope and its registry own poll/cancel, so a driver is the only + /// thing the extension supplies. There is deliberately no second registry + /// and no adapter-specific generic helper on this surface. + pub fn start_operation(&mut self, spec: OperationSpec) -> HostContextResult { + self.vm + .host + .execution_scope + .start_operation(spec) + .map_err(HostContextError::from_scope) + } + + /// Cancels one started operation by id, forwarding the reason to its + /// concrete driver. Returns `false` when the operation was already + /// terminal. + pub fn cancel_operation( + &mut self, + id: OperationId, + reason: OperationCancelReason, + ) -> HostContextResult { + self.vm + .host + .execution_scope + .cancel_operation(id, reason) + .map_err(HostContextError::from_scope) + } + + /// Marks an operation completed without polling. The terminal slot remains + /// occupied until [`take_operation_outcome`](Self::take_operation_outcome). + pub fn complete_operation(&mut self, id: OperationId) -> HostContextResult { + self.vm + .host + .execution_scope + .complete_operation(id) + .map_err(HostContextError::from_scope) + } + + /// Consumes one terminal outcome and releases its slot for generation + /// reuse. + pub fn take_operation_outcome( + &mut self, + id: OperationId, + ) -> HostContextResult { + self.vm + .host + .execution_scope + .take_operation_outcome(id) + .map_err(HostContextError::from_scope) + } + + /// Drives one operation to terminal, polling its concrete driver. + pub fn poll_operation( + &mut self, + id: OperationId, + cx: &mut Context<'_>, + ) -> Poll> { + match self.vm.host.execution_scope.poll_operation(id, cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(result) => Poll::Ready(result.map_err(HostContextError::from_scope)), + } + } + + /// Aborts a started operation after a later handoff step fails. The driver + /// is cancelled at most once, the occupied registry slot is released, and + /// the id becomes stale as one atomic scope lifecycle action. + pub fn abort_operation( + &mut self, + id: OperationId, + reason: OperationCancelReason, + ) -> HostContextResult { + self.vm + .host + .execution_scope + .abort_operation(id, reason) + .map_err(HostContextError::from_scope) + } + + /// Reads the status of one operation. + pub fn operation_status(&self, id: OperationId) -> HostContextResult { + self.vm + .host + .execution_scope + .operations() + .status(id) + .map_err(HostContextError::from_operation) + } + + /// Closes one resource in the current execution scope via the generic + /// table contract. A `Pending` close is driven to completion by the usual + /// scope poll machinery. + pub fn close_resource( + &mut self, + handle: ResourceHandle, + reason: ResourceCloseReason, + ) -> HostContextResult { + self.vm + .host + .execution_scope + .close_resource::(handle, reason) + .map_err(HostContextError::from_scope) + } + + /// Immutably borrows a live resource for the duration of a host call. + /// + /// The token is re-validated against the current scope (arena, slot + /// generation, `TypeId`, open state); a stale / wrong-type / foreign-scope + /// token fails with a structured resource-layer error. + pub fn resource( + &self, + token: &Resource, + ) -> HostContextResult> { + self.vm + .host + .execution_scope + .resources() + .get(token) + .map_err(HostContextError::from_resource) + } + + /// Mutably borrows a typed resource for the duration of this synchronous + /// host call. + pub fn resource_mut( + &mut self, + token: &Resource, + ) -> HostContextResult> { + self.vm + .host + .execution_scope + .resources_mut() + .get_mut(token) + .map_err(HostContextError::from_resource) + } + + /// Validates a raw [`ResourceHandle`] against the current scope and + /// recovers a typed token (read-only). + pub fn typed_resource( + &self, + handle: ResourceHandle, + ) -> HostContextResult> { + self.vm + .host + .execution_scope + .resources() + .typed(handle) + .map_err(HostContextError::from_resource) + } + + /// Borrow a raw handle after typed arena/generation validation. + pub fn borrow_resource( + &self, + handle: ResourceHandle, + ) -> HostContextResult> { + let token = self.typed_resource::(handle)?; + self.resource(&token) + } + + /// Mutably borrow a raw handle after typed arena/generation validation. + pub fn borrow_resource_mut( + &mut self, + handle: ResourceHandle, + ) -> HostContextResult> { + let token = self.typed_resource::(handle)?; + self.resource_mut(&token) + } + + /// Begins closing the resource through the generic table contract. + /// + /// This is the generic "close one resource" adapter (host-agnostic): the + /// resource arena/type/generation/live checks and `begin_close` happen + /// before any state mutation, so a rejected close leaves the table + /// untouched. + pub fn begin_close( + &mut self, + handle: ResourceHandle, + reason: ResourceCloseReason, + ) -> HostContextResult { + self.close_resource::(handle, reason) + } +} + +/// The typed per-VM module-state store owned by a host runtime. +/// +/// This is the generic host-owned policy/configuration storage the SDK's +/// [`HostContext::set_module_state`] family drives. State is opaque to the VM, +/// keyed by [`TypeId`], and deliberately survives scope reset (the HTTP host +/// configuration is the same-crate precedent). +pub(crate) struct ModuleStateStore { + entries: HashMap>, +} + +impl Default for ModuleStateStore { + fn default() -> Self { + Self::new() + } +} + +impl ModuleStateStore { + pub(crate) fn new() -> Self { + Self { + entries: HashMap::new(), + } + } + + pub(crate) fn set(&mut self, state: M) -> bool { + self.entries + .insert(TypeId::of::(), Box::new(state)) + .is_some() + } + + pub(crate) fn get(&self) -> Option<&M> { + self.entries + .get(&TypeId::of::()) + .and_then(|state| state.downcast_ref::()) + } + + pub(crate) fn get_mut(&mut self) -> Option<&mut M> { + self.entries + .get_mut(&TypeId::of::()) + .and_then(|state| state.downcast_mut::()) + } + + pub(crate) fn remove(&mut self) -> Option { + self.entries + .remove(&TypeId::of::()) + .and_then(|state| state.downcast::().ok()) + .map(|state| *state) + } + + pub(crate) fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + #[allow(dead_code)] + pub(crate) fn clear(&mut self) { + self.entries.clear(); + } +} + +impl HostRuntime { + /// Registers typed per-VM module state, replacing any earlier value of the + /// same type. + pub(crate) fn set_module_state(&mut self, state: M) -> bool { + self.module_state_store.set(state) + } + + /// Borrows the registered typed module state, if any. + pub(crate) fn get_module_state(&self) -> Option<&M> { + self.module_state_store.get() + } + + /// Borrows the registered typed module state mutably, if any. + pub(crate) fn get_module_state_mut(&mut self) -> Option<&mut M> { + self.module_state_store.get_mut() + } + + /// Removes and returns the registered typed module state, if any. + pub(crate) fn remove_module_state(&mut self) -> Option { + self.module_state_store.remove() + } + + /// Returns `true` when no module state is currently registered. + pub(crate) fn is_module_state_empty(&self) -> bool { + self.module_state_store.is_empty() + } +} diff --git a/src/vm/host_extension.rs b/src/vm/host_extension.rs new file mode 100644 index 00000000..9057e4dc --- /dev/null +++ b/src/vm/host_extension.rs @@ -0,0 +1,264 @@ +//! Public host-extension surface. +//! +//! This module is the controlled extension boundary through which an external +//! host crate installs persistent policy state and registers host functions +//! without accessing any [`HostRuntime`](super::host_runtime::HostRuntime) +//! private field or naming a builtin domain module: +//! +//! - [`HostExtension::install`] installs typed per-VM module state (policy / +//! configuration) through the generic [`HostContext`] module-state store. +//! That store is owned directly by the host runtime: it persists across +//! [`Vm::reset_for_reuse`](super::Vm::reset_for_reuse) and execution-scope +//! close, and it never participates in resource close. +//! - [`HostExtension::register`] registers host functions into a +//! [`HostFunctionRegistry`]. Registration is validated against the +//! extension's [`HostApiCatalog`] via [`catalog_import_schemas`] so the +//! registered function declarations — parameter labels, type schemas and +//! passing modes — match the catalog exactly. The catalog is the +//! authoritative host-side contract: it carries the fingerprint and the +//! resource type keys the host exposes, and the same catalog can be +//! supplied to the compiler so the program's `HostImport`s resolve against +//! it. +//! +//! `src/vm` therefore stays host-agnostic: resource classes, pending +//! operations and module state are supplied by the extension, while the +//! execution scope owns their lifecycle. +//! +//! **Boundary contract:** like [`super::host_context`], this module has no +//! coupling to the builtin runtime modules or any concrete host library. + +use crate::host_api::{HostApiCatalog, HostApiFingerprint, HostParamPassing, HostTypeSchema}; +use crate::vm::VmResult; + +pub use super::host_context::HostContext; +pub use super::host_context::HostModule as HostModuleState; + +/// One catalog-derived host import parameter descriptor. +/// +/// This is the SDK-local (host-side) declaration of a parameter the extension +/// registers: its label, its semantic [`HostTypeSchema`] and its passing mode. +/// It mirrors what the compiler embeds at a call site when the same catalog is +/// supplied to codegen, so registering these keeps host and guest sides in +/// lock-step without any raw fingerprint construction on the host side. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HostImportParam { + /// Parameter label, unique within its function. + pub name: String, + /// Semantic type schema of the parameter. + pub schema: HostTypeSchema, + /// Passing mode (value / borrow / borrow-mut / take-owned). + pub passing: HostParamPassing, +} + +/// One catalog-derived host import schema descriptor. +/// +/// Produced by [`catalog_import_schemas`] from a [`HostApiCatalog`]: the +/// parameter labels, type schemas, passing modes, the return schema and the +/// catalog's own fingerprint. This is the exact host-side identity an +/// extension registers against. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HostImportSchema { + /// Ordered parameter declarations. + pub params: Vec, + /// Return type schema. + pub return_type: HostTypeSchema, + /// The catalog fingerprint this schema was derived from. + pub fingerprint: HostApiFingerprint, +} + +/// Public name for the typed per-VM module-state marker used by the external +/// extension surface. +/// +/// `HostModuleState` is the stable alias for `HostModule`: a marker bound on +/// a concrete `State` type (keyed by `TypeId`), registered through +/// [`HostContext::set_module_state`] and borrowed through +/// [`HostContext::module_state`] / [`HostContext::module_state_mut`]. State is +/// per-`Vm`, deliberately survives +/// [`Vm::reset_for_reuse`](super::Vm::reset_for_reuse) and execution-scope +/// close, and never participates in resource close. +/// +/// Registers a host extension against the standard host-function registry and +/// installs its persistent module state. +/// +/// Used directly by embedders; the `register` / `install` lifecycle is split +/// so an extension can also be registered into a caller-supplied (e.g. +/// restricted / capability-granted) [`HostFunctionRegistry`] by calling +/// [`HostExtension::register`] directly and binding it with +/// [`HostFunctionRegistry::bind_vm_cached`]. +pub trait HostExtension: Send + Sync + 'static { + /// Registers this extension's host functions into `registry`. + /// + /// Registration must be validated against the extension's + /// [`HostApiCatalog`] (e.g. [`catalog_import_schemas`] plus the + /// [`validate_catalog_import_schemas`] family); a name-only fallback is + /// not part of this surface. The default registers nothing. + fn register(&self, registry: &mut super::host::HostFunctionRegistry) -> VmResult<()> { + let _ = registry; + Ok(()) + } + + /// Installs this extension's persistent per-VM module state. + /// + /// Typed state installed here (through + /// [`HostContext::set_module_state`]) survives + /// [`Vm::reset_for_reuse`](super::Vm::reset_for_reuse) and scope close and + /// never participates in resource close. + /// + /// **Infallible by design.** The module-state install phase performs no + /// fallible operations (the state store is infallible), so + /// [`Vm::install_extension`](super::Vm::install_extension) can guarantee + /// transactional failure semantics: every fallible step (registration and + /// registry binding) runs *before* this method, and once it runs the VM is + /// fully and consistently installed. Extensions that need a fallible + /// initialization step must perform it in [`Self::register`] instead, so + /// the failure surfaces before any install mutation. The default installs + /// nothing. + fn install(&self, vm: &mut super::Vm) { + let _ = vm; + } + + /// Transactional install: register into a fresh standard registry, bind it + /// to `vm`, then run the infallible install phase. + /// + /// This is the default implementation behind + /// [`Vm::install_extension`](super::Vm::install_extension). Every fallible + /// step (registration and registry binding) runs before [`Self::install`], + /// so a failure leaves the VM unmodified. + fn install_into(&self, vm: &mut super::Vm) -> VmResult<()> { + let mut registry = super::host::HostFunctionRegistry::new(); + self.register(&mut registry)?; + registry.bind_vm_cached(vm)?; + self.install(vm); + Ok(()) + } +} + +/// Converts every catalog-declared overload of `name` into the exact +/// [`HostImportSchema`] the compiler embeds at a call site. +/// +/// The produced schemas carry the declared parameter labels, type schemas, +/// passing modes, return schema and the catalog's own +/// [`HostApiCatalog::fingerprint`](crate::host_api::HostApiCatalog::fingerprint) +/// — exactly the identity stored in a `HostImport`'s schema during codegen +/// when the same catalog is supplied to the compiler. Registering against +/// these schemas therefore satisfies the exact-schema registry lookup with no +/// drift and no raw fingerprint construction on the host side. +pub fn catalog_import_schemas(catalog: &HostApiCatalog, name: &str) -> Vec { + let fingerprint = catalog.fingerprint(); + catalog_import_schemas_with_fingerprint(catalog, name, fingerprint) +} + +fn catalog_import_schemas_with_fingerprint( + catalog: &HostApiCatalog, + name: &str, + fingerprint: HostApiFingerprint, +) -> Vec { + catalog + .functions_named(name) + .into_iter() + .map(|function| HostImportSchema { + params: function + .params + .iter() + .map(|param| HostImportParam { + name: param.name.clone(), + schema: param.ty.clone(), + passing: param.passing, + }) + .collect(), + return_type: function.return_type.clone(), + fingerprint, + }) + .collect() +} + +/// Validates the adapter ABI for one required catalog member before registry +/// mutation. The member must exist and match one of the canonical adapter +/// overloads in parameter labels, passing modes, parameter schemas and return +/// schema. Catalog fingerprints are deliberately ignored so custom and +/// combined catalogs remain usable. +pub fn validate_catalog_import_schemas( + catalog: &HostApiCatalog, + contract: &HostApiCatalog, + name: &str, +) -> VmResult> { + validate_catalog_import_schemas_with_fingerprints( + catalog, + contract, + name, + catalog.fingerprint(), + contract.fingerprint(), + ) +} + +/// Validates one adapter member using fingerprints computed once by a +/// registration pass. Adapter contract tables use this to avoid recomputing a +/// catalog fingerprint for every overload/member. +pub fn validate_catalog_import_schemas_with_fingerprints( + catalog: &HostApiCatalog, + contract: &HostApiCatalog, + name: &str, + catalog_fingerprint: HostApiFingerprint, + contract_fingerprint: HostApiFingerprint, +) -> VmResult> { + let expected = catalog_import_schemas_with_fingerprint(contract, name, contract_fingerprint); + let got = catalog_import_schemas_with_fingerprint(catalog, name, catalog_fingerprint); + if got.is_empty() { + return Err(crate::vm::VmError::HostError(format!( + "missing catalog member '{name}' (expected {} overload(s))", + expected.len() + ))); + } + + let compatible = |expected: &HostImportSchema, got: &HostImportSchema| { + expected.params == got.params && expected.return_type == got.return_type + }; + let all_expected_match = expected + .iter() + .all(|expected| got.iter().any(|got| compatible(expected, got))); + let all_got_match = got + .iter() + .all(|got| expected.iter().any(|expected| compatible(expected, got))); + if expected.len() != got.len() || !all_expected_match || !all_got_match { + return Err(crate::vm::VmError::HostError(format!( + "incompatible catalog schema for '{name}': expected {expected:?}, got {got:?}" + ))); + } + Ok(got) +} + +/// Registers one catalog-validated host function into `registry`. +/// +/// The function's declared parameter count must match `arity` and every +/// declared schema must match the catalog's declaration for `name`. This is +/// the exact-schema registration surface adapted to the rewritten core: the +/// registry binds by name/arity, and the catalog is the authoritative +/// host-side contract the declaration is checked against before mutation. +pub fn register_catalog_function( + registry: &mut super::host::HostFunctionRegistry, + catalog: &HostApiCatalog, + name: &str, + arity: u8, + factory: F, +) -> VmResult<()> +where + F: Fn() -> Box + Send + Sync + 'static, +{ + let schemas = catalog_import_schemas(catalog, name); + if schemas.is_empty() { + return Err(crate::vm::VmError::HostError(format!( + "catalog declares no function '{name}'" + ))); + } + // The registered declaration must match the catalog exactly (single + // overload: parameter count must equal the declared arity). + let schema = &schemas[0]; + if schema.params.len() != arity as usize { + return Err(crate::vm::VmError::HostError(format!( + "catalog function '{name}' declares {} parameter(s); arity {arity} does not match", + schema.params.len() + ))); + } + registry.register(name, arity, factory); + Ok(()) +} diff --git a/src/vm/host_runtime.rs b/src/vm/host_runtime.rs index 0285713b..99658cde 100644 --- a/src/vm/host_runtime.rs +++ b/src/vm/host_runtime.rs @@ -13,7 +13,9 @@ //! that host code addresses through the generic, host-agnostic //! [`ExecutionScope`] lifecycle. -use std::collections::HashMap; +use std::any::{Any, TypeId}; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; use crate::builtins::runtime::IoState; #[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))] @@ -44,6 +46,42 @@ pub(crate) struct HostRuntime { pub(crate) next_host_op_id: HostOpId, /// The isolated execution scope owned by this host runtime. pub(super) execution_scope: ExecutionScope, + /// Host-owned typed configuration/policy state, keyed by `TypeId`. + /// + /// This is the generic host-owned policy storage: host implementations + /// (IO, SQLite, external resources) store their policy/configuration + /// here instead of adding bespoke fields. The state is opaque to the VM + /// and replaced wholesale on scope reset. + host_function_state: HashMap>, + /// The generic per-VM module-state store surfaced to external host + /// extensions through [`HostContext`](super::host_context::HostContext). + /// + /// This is the same generic host-owned policy/configuration storage, but + /// exposed through the public [`HostContext`] boundary. It is typed + /// (keyed by `TypeId`), per-`Vm`, and deliberately **not** cleared on + /// scope reset, so extension policy/configuration survives reset and + /// scope recycling for the lifetime of the VM. + pub(crate) module_state_store: super::host_context::ModuleStateStore, + /// Whether the default builtin capability set is enabled for this VM. + /// + /// A restricted registry (`HostFunctionRegistry::restricted`) binds this + /// to `false`, making privileged builtins require an explicit capability + /// grant before execution. + pub(crate) allow_default_builtin_capabilities: bool, + /// Explicitly allowed builtin call indices (from the bound capability + /// profile), enforced when `allow_default_builtin_capabilities` is off. + pub(crate) allowed_builtin_calls: Vec, + /// Whether the default host capability set is enabled for this VM. + pub(crate) allow_default_host_capabilities: bool, + /// Host-function slots permitted by the bound capability profile, + /// enforced when `allow_default_host_capabilities` is off. + pub(crate) allowed_host_function_slots: Vec, + /// Whether unbound host imports fall back to the default host functions. + pub(crate) allow_default_host_fallback: bool, + /// Ops submitted to the async host bridge (via `Vm::submit_host_future`) + /// that are still pending. These route to the bridge's + /// `poll_submitted_op` instead of a runtime-owned operation driver. + pub(crate) submitted_host_ops: HashSet, } impl HostRuntime { @@ -70,9 +108,49 @@ impl HostRuntime { next_host_op_id: 1, execution_scope: ExecutionScope::new() .expect("host runtime execution-scope identity space must be available"), + host_function_state: HashMap::new(), + module_state_store: super::host_context::ModuleStateStore::new(), + allow_default_builtin_capabilities: true, + allowed_builtin_calls: Vec::new(), + allow_default_host_capabilities: true, + allowed_host_function_slots: Vec::new(), + allow_default_host_fallback: true, + submitted_host_ops: HashSet::new(), } } + /// Stores host-owned typed policy/configuration state. + pub(crate) fn set_host_function_state(&mut self, state: T) + where + T: Send + Sync + 'static, + { + self.host_function_state + .insert(TypeId::of::(), Arc::new(state)); + } + + /// Returns host-owned typed policy/configuration state, if any. + pub(crate) fn host_function_state(&self) -> Option<&T> + where + T: Send + Sync + 'static, + { + self.host_function_state + .get(&TypeId::of::()) + .and_then(|state| state.downcast_ref::()) + } + + /// Removes host-owned typed policy/configuration state. + pub(crate) fn remove_host_function_state(&mut self) -> Option> + where + T: Send + Sync + 'static, + { + self.host_function_state.remove(&TypeId::of::()) + } + + /// Whether the default builtin capability set is enabled. + pub(crate) fn default_builtin_capabilities_enabled(&self) -> bool { + self.allow_default_builtin_capabilities + } + /// Replaces the active execution scope with a fresh one. /// /// Dropping the old scope runs its generic close sweep, retiring every @@ -85,6 +163,7 @@ impl HostRuntime { { self.sqlite_state = SqliteState::default(); } + self.host_function_state.clear(); self.execution_scope = ExecutionScope::new() .expect("host runtime execution-scope identity space must be available"); } diff --git a/src/vm/mod.rs b/src/vm/mod.rs index ef9232b2..5f5ea3b3 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -4,12 +4,16 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; pub(crate) mod aot; +pub mod async_host; +mod capability; pub mod diagnostics; mod engine; mod epoch; pub mod execution_scope; mod fuel; mod host; +pub mod host_context; +pub mod host_extension; mod host_runtime; mod instance; pub(crate) mod jit; @@ -19,11 +23,14 @@ pub mod operation; pub mod program; pub mod resource; mod run_context; +pub mod standard_composition; mod store; mod superinstructions; #[cfg(test)] mod tests; pub use self::aot::AotArtifactError; +pub use self::async_host::{CaptureAsyncHostContext, HostFuture, HostFutureOutput}; +pub use self::capability::{CapabilityProfile, CapabilityProfileBuilder}; use self::engine::Engine; pub use self::epoch::{EpochCheckpoint, EpochHandle}; use self::execution_scope::ExecutionScopeError; @@ -34,10 +41,20 @@ pub use self::host::{ StaticHostStackFunction, }; use self::host::{HostCallExecOutcome, VmHostFunction}; +pub use self::host_context::{ + HostContext, HostContextError, HostContextErrorKind, HostContextResult, HostModule, + HostModule as HostModuleState, +}; +pub use self::host_extension::{ + HostExtension, HostImportParam, HostImportSchema, catalog_import_schemas, + register_catalog_function, validate_catalog_import_schemas, + validate_catalog_import_schemas_with_fingerprints, +}; use self::host_runtime::HostRuntime; use self::instance::{ExecutionFrame, FrameContinuation, Instance, QueuedCallable}; pub use self::resource::ResourceCloseReason; use self::run_context::{InterruptMode, RunContext}; +pub use self::standard_composition::StandardSurfaceComposition; #[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))] pub use crate::builtins::runtime::sqlite::{SqliteLimits, SqlitePolicy}; pub use crate::bytecode::{ @@ -2672,6 +2689,33 @@ impl Vm { &mut self.host.execution_scope } + /// Returns the generic host boundary for this VM. + /// + /// [`HostContext`](crate::vm::host_context::HostContext) exposes typed + /// per-VM module state (policy / configuration) and the generic + /// host-agnostic execution-scope SDK (insert resources, start / cancel / + /// poll host operations, borrow and take resources) to external host + /// extensions without leaking the underlying host runtime or naming a + /// builtin domain module. + pub fn host_context(&mut self) -> crate::vm::host_context::HostContext<'_> { + crate::vm::host_context::HostContext::new(self) + } + + /// Installs a [`HostExtension`](crate::vm::host_extension::HostExtension) + /// onto this VM. + /// + /// Registration (into the VM's bound host-function registry) is + /// transactional and runs *before* the infallible install phase, so a + /// fallible registration/registry-binding failure surfaces before any + /// per-VM module state is installed. See + /// [`HostExtension`](crate::vm::host_extension::HostExtension). + pub fn install_extension( + &mut self, + extension: &dyn crate::vm::host_extension::HostExtension, + ) -> VmResult<()> { + extension.install_into(self) + } + /// Replaces the adapter-owned SQLite embedding policy. /// /// Open connections keep the limits they were opened with; new opens use diff --git a/src/vm/resource/close.rs b/src/vm/resource/close.rs index 98ff87d7..ad6f8cdd 100644 --- a/src/vm/resource/close.rs +++ b/src/vm/resource/close.rs @@ -7,6 +7,8 @@ use std::any::Any; use std::task::{Context, Poll}; +use crate::host_api::ResourceTypeKey; + use super::error::ResourceResult; use super::reason::ResourceCloseReason; @@ -35,6 +37,19 @@ pub enum CloseProgress { /// The `Any` supertrait lets the table reconnect each erased value to its /// concrete `TypeId` without ever naming a concrete class. pub trait HostResource: Any + Send + 'static { + /// Stable catalog identity for this concrete resource declaration. + /// + /// New resource declarations should override this method. The default + /// keeps pre-existing host resources source-compatible; such resources + /// participate in legacy typed APIs but cannot satisfy an exact request + /// carrying a non-empty [`ResourceTypeKey`]. + fn resource_type_key() -> Option + where + Self: Sized, + { + None + } + /// Begins closing the resource, emitting a synchronous cancel/close request. /// /// The default is a synchronous no-op close. diff --git a/src/vm/standard_composition.rs b/src/vm/standard_composition.rs new file mode 100644 index 00000000..42d6b0da --- /dev/null +++ b/src/vm/standard_composition.rs @@ -0,0 +1,77 @@ +//! Generic contract for composing standard host surfaces. +//! +//! The host-agnostic VM core must not know which concrete standard domains +//! exist (`io::`, `http::`, `sqlite::`, …) or which same-crate builtin modules +//! implement them. All of that knowledge belongs to the standard builtin +//! composition layer. This module defines the *generic* abstraction the core +//! consumes instead: +//! +//! - [`StandardSurfaceComposition`] — the caller-provided strategy the core +//! delegates to for: deciding whether an import belongs to the standard +//! catalog, ensuring the required standard surfaces are present on a +//! registry, building a fresh default registry, and binding a legacy +//! by-name default host function. +//! +//! The composition is **explicit caller-provided per-instance state**: a +//! `HostFunctionRegistry` and a `Vm` carry an `Arc` installed through the outer standard-runtime +//! constructor/registry path. There is deliberately no process-global slot and +//! no first-wins installation: `src/vm` never names a concrete domain module, +//! feature, surface count, or bit assignment. +//! +//! This module is compiled only under `feature = "runtime"` (like the rest of +//! `src/vm`). + +use std::sync::Arc; + +use crate::bytecode::HostImport; + +use super::host::HostFunctionRegistry; +use super::{Vm, VmResult}; + +/// Caller-provided strategy for composing the standard host surfaces. +/// +/// Implemented by the standard builtin composition layer +/// (`crate::builtins::runtime`). The VM core invokes it generically and never +/// names a concrete domain module, namespace prefix, feature, or surface +/// count. +pub trait StandardSurfaceComposition: Send + Sync { + /// Whether `import` belongs to the standard catalog (its name resolves to + /// a registered standard host callable). + fn import_in_standard(&self, import: &HostImport) -> bool; + + /// Ensures every standard surface required by `imports` is present on + /// `registry`, staging exactly the missing surfaces, and returns whether + /// any surface was staged. + /// + /// This is the single opaque required/present/stage operation: the + /// composition implementation computes which surfaces the import set + /// requires and which the registry already carries, and registers only + /// the missing ones. The VM core never sees a surface mask, a concrete + /// surface count, or a bit assignment. + fn ensure_surfaces( + &self, + imports: &[HostImport], + registry: &mut HostFunctionRegistry, + ) -> VmResult; + + /// Builds a fresh registry carrying every enabled standard surface. + fn build_default_registry(&self) -> VmResult; + + /// Binds the legacy by-name default host function `name` on `vm`, if one + /// exists; returns whether it bound. + fn bind_default_name(&self, vm: &mut Vm, name: &str) -> bool; +} + +/// Shared per-runtime handle wrapping a caller-provided composition and kept +/// out of `src/vm` core dispatch. External host crates can store one on their +/// own extension state, or a VM can carry it through the standard runtime. +#[derive(Clone)] +pub struct StandardCompositionHandle(pub Arc); + +impl StandardCompositionHandle { + /// Install this composition on a registry's standard composition slot. + pub fn install(&self, registry: &mut HostFunctionRegistry) { + registry.set_standard_composition(Arc::clone(&self.0)); + } +} diff --git a/src/vm/tests.rs b/src/vm/tests.rs index bc26d402..e3446e55 100644 --- a/src/vm/tests.rs +++ b/src/vm/tests.rs @@ -1993,3 +1993,64 @@ fn call_ret_fusion_pattern_requires_immediate_ret() { vm_no_next.instance.ip = 4; assert!(!vm_no_next.can_fuse_call_ret_pattern()); } + +#[test] +fn async_host_future_is_submitted_to_the_host_bridge() { + use std::sync::{Arc, Mutex}; + + struct RecordingBridge { + submitted: Arc>>, + future: Arc>>, + } + + impl HostAsyncBridge for RecordingBridge { + fn submit_op(&mut self, op_id: HostOpId, future: HostFuture) -> VmResult<()> { + self.submitted.lock().expect("submitted lock").push(op_id); + *self.future.lock().expect("future lock") = Some(future); + Ok(()) + } + + fn poll_op( + &mut self, + _op_id: HostOpId, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Pending + } + } + + let submitted = Arc::new(Mutex::new(Vec::new())); + let future = Arc::new(Mutex::new(None)); + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + vm.set_async_bridge(Box::new(RecordingBridge { + submitted: Arc::clone(&submitted), + future: Arc::clone(&future), + })); + + let outcome = vm + .submit_host_future(Box::pin(async { + Ok(HostFutureOutput::returning(CallReturn::one(Value::Int(42)))) + })) + .expect("host bridge should accept future"); + let CallOutcome::Pending(op_id) = outcome else { + panic!("async host submission should suspend"); + }; + + assert_eq!(*submitted.lock().expect("submitted lock"), vec![op_id]); + assert!(future.lock().expect("future lock").is_some()); + assert!( + vm.host.submitted_host_ops.contains(&op_id), + "submitted bridge op should be tracked in the host runtime" + ); +} + +#[test] +fn capability_profile_allow_all_and_deny_all_differ() { + let allow_all = crate::vm::CapabilityProfile::allow_all(); + let deny_all = crate::vm::CapabilityProfile::deny_all(); + assert!(allow_all.allows_builtin(crate::builtins::BuiltinFunction::Len)); + assert!(allow_all.allows_host_import("anything::at::all")); + assert!(!deny_all.allows_builtin(crate::builtins::BuiltinFunction::Len)); + assert!(!deny_all.allows_host_import("anything::at::all")); + assert_ne!(allow_all.fingerprint(), deny_all.fingerprint()); +} diff --git a/tests/builtins/io_async_tests.rs b/tests/builtins/io_async_tests.rs new file mode 100644 index 00000000..35019190 --- /dev/null +++ b/tests/builtins/io_async_tests.rs @@ -0,0 +1,110 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use vm::{Value, Vm, VmError, VmStatus, compile_source}; + +fn run_source(source: &str) -> Result, VmError> { + let compiled = + compile_source(&format!("use io;\n{source}")).expect("async io source should compile"); + let mut vm = Vm::new(compiled.program); + super::async_test_bridge::install(&mut vm); + + let mut status = vm.run()?; + loop { + match status { + VmStatus::Halted => return Ok(vm.stack().to_vec()), + VmStatus::Yielded => status = vm.resume()?, + VmStatus::Waiting(_) => { + vm.wait_for_host_op_blocking()?; + status = vm.resume()?; + } + } + } +} + +#[test] +fn async_io_round_trips_file_operations_through_host_driver() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should follow Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!("pd-vm-async-io-{}-{nonce}", std::process::id())); + + let stack = run_source(&format!( + r#" + let handle = io::open("{}", "w"); + io::write(handle, "host-driven"); + io::flush(handle); + io::close(handle); + io::exists("{}"); + "#, + path.display(), + path.display(), + )) + .expect("async io program should complete"); + + assert_eq!(stack.last(), Some(&Value::Bool(true))); + assert_eq!( + std::fs::read_to_string(&path).expect("written file should exist"), + "host-driven" + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn async_io_read_line_preserves_buffered_data_between_calls() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should follow Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "pd-vm-async-read-line-{}-{nonce}", + std::process::id() + )); + std::fs::write(&path, "first\nsecond\n").expect("fixture should be written"); + + let stack = run_source(&format!( + r#" + let handle = io::open("{}", "r"); + io::read_line(handle); + let second = io::read_line(handle); + io::close(handle); + second; + "#, + path.display(), + )) + .expect("async read_line program should complete"); + + assert_eq!(stack.last(), Some(&Value::string("second\n"))); + let _ = std::fs::remove_file(path); +} + +#[cfg(unix)] +#[test] +fn async_io_popen_reads_through_tokio_process_pipe() { + let stack = run_source( + r#" + let handle = io::popen("printf async-process", "r"); + let output = io::read_all(handle); + io::close(handle); + output; + "#, + ) + .expect("async popen program should complete"); + + assert_eq!(stack.last(), Some(&Value::string("async-process"))); +} + +#[test] +fn io_implementations_do_not_create_private_threads_or_runtimes() { + let async_source = include_str!("../../src/builtins/runtime/io/async_io.rs"); + let blocking_source = include_str!("../../src/builtins/runtime/io/blocking.rs"); + + // The async implementation must run on the bridge's executor: it must + // not spawn its own threads or build its own tokio runtime. + assert!(!async_source.contains("thread::Builder")); + assert!(!async_source.contains("runtime::Builder")); + assert!(!async_source.contains("spawn_blocking")); + // The blocking implementation must not create a private runtime either; + // per-op worker threads are driven by the blocking path itself. + assert!(!blocking_source.contains("runtime::Builder")); +} diff --git a/tests/builtins/io_builtin_edge_tests.rs b/tests/builtins/io_builtin_edge_tests.rs index 0e58dc0c..f92241fd 100644 --- a/tests/builtins/io_builtin_edge_tests.rs +++ b/tests/builtins/io_builtin_edge_tests.rs @@ -1,4 +1,4 @@ -use vm::{Value, Vm, VmError, VmStatus, compile_source}; +use vm::{IoHostExt, Value, Vm, VmError, VmStatus, compile_source}; fn run_source(source: &str) -> Result, VmError> { let wrapped = format!("use io;\n{source}"); @@ -119,3 +119,129 @@ fn io_flush_on_read_handle_is_a_noop_true() { .expect("program should execute"); assert_eq!(stack.last(), Some(&Value::Bool(true))); } + +#[cfg(unix)] +#[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 = vm::HostFunctionRegistry::restricted(); + registry.set_capability_profile( + vm::CapabilityProfile::builder() + .allow_builtin(vm::BuiltinFunction::IoPopen) + .build(), + ); + let mut vm = Vm::new(compiled.program); + vm.configure_io(vm::IoPolicy::default()); + 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 = vm::HostFunctionRegistry::restricted(); + registry.set_capability_profile( + vm::CapabilityProfile::builder() + .allow_builtin(vm::BuiltinFunction::IoExists) + .build(), + ); + let mut vm = Vm::new(compiled.program); + vm.configure_io(vm::IoPolicy::default()); + 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"))); +} + +#[test] +fn restricted_registry_defaults_to_deny_when_io_host_state_is_absent() { + let compiled = compile_source( + r#" + use io; + io::exists("Cargo.toml"); + "#, + ) + .expect("source should compile"); + let mut registry = vm::HostFunctionRegistry::restricted(); + registry.set_capability_profile( + vm::CapabilityProfile::builder() + .allow_builtin(vm::BuiltinFunction::IoExists) + .build(), + ); + let mut vm = Vm::new(compiled.program); + registry + .bind_vm_cached(&mut vm) + .expect("profile should bind"); + + let error = vm + .run() + .expect_err("missing IO host state should use the deny-by-default policy"); + assert!(matches!(error, VmError::HostError(message) if message.contains("allowed roots"))); +} + +#[test] +fn io_policy_limits_write_size() { + let path = std::env::temp_dir().join(format!( + "pd-vm-policy-write-limit-{}-{:?}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should follow Unix epoch") + .as_nanos() + )); + 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 = vm::IoPolicy { + allowed_roots: vec![std::env::temp_dir().display().to_string()], + allow_write: true, + max_write_bytes: 3, + ..vm::IoPolicy::default() + }; + let mut registry = vm::HostFunctionRegistry::restricted(); + registry.set_capability_profile( + vm::CapabilityProfile::builder() + .allow_builtin(vm::BuiltinFunction::IoOpen) + .allow_builtin(vm::BuiltinFunction::IoWrite) + .build(), + ); + let mut vm = Vm::new(compiled.program); + vm.configure_io(policy); + 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); +} diff --git a/tests/builtins/stdlib_tests.rs b/tests/builtins/stdlib_tests.rs index cc1674f1..d6df463d 100644 --- a/tests/builtins/stdlib_tests.rs +++ b/tests/builtins/stdlib_tests.rs @@ -14,6 +14,8 @@ fn run_rustscript_spec(path: &Path) -> Vec { ); let mut vm = Vm::new(compiled.program); + #[cfg(feature = "async")] + super::async_test_bridge::install(&mut vm); loop { let status = vm.run().expect("spec vm should run"); match status { diff --git a/tests/builtins_tests.rs b/tests/builtins_tests.rs index 204b341f..44eade8d 100644 --- a/tests/builtins_tests.rs +++ b/tests/builtins_tests.rs @@ -1,11 +1,21 @@ #![cfg(feature = "runtime")] +#[cfg(feature = "async")] +#[path = "support/async_test_bridge.rs"] +mod async_test_bridge; + +#[cfg(not(feature = "async"))] #[path = "builtins/io_builtin_edge_tests.rs"] mod io_builtin_edge_tests; +#[cfg(all(not(feature = "async"), not(target_arch = "wasm32")))] #[path = "builtins/io_scope_lifecycle_tests.rs"] mod io_scope_lifecycle_tests; +#[cfg(feature = "async")] +#[path = "builtins/io_async_tests.rs"] +mod io_async_tests; + #[cfg(feature = "sqlite")] #[path = "builtins/sqlite_scope_lifecycle_tests.rs"] mod sqlite_scope_lifecycle_tests; diff --git a/tests/compiler/compiler_rustscript_tests.rs b/tests/compiler/compiler_rustscript_tests.rs index 024a2bf1..b7fa4afc 100644 --- a/tests/compiler/compiler_rustscript_tests.rs +++ b/tests/compiler/compiler_rustscript_tests.rs @@ -163,6 +163,8 @@ fn rustscript_io_namespace_builtin_calls_are_supported() { "#; let compiled = compile_source(source).expect("compile should succeed"); let mut vm = Vm::new(compiled.program); + #[cfg(feature = "async")] + super::async_test_bridge::install(&mut vm); loop { let status = vm.run().expect("vm should run"); @@ -532,6 +534,8 @@ fn compile_source_file_with_rustscript_complex_fixture() { std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/example_complex.rss"); let compiled = compile_source_file(path.as_path()).expect("compile should succeed"); let mut vm = Vm::new(compiled.program); + #[cfg(feature = "async")] + super::async_test_bridge::install(&mut vm); for func in &compiled.functions { match func.name.as_str() { diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 11328072..d8ce5df8 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -1,5 +1,9 @@ #![allow(clippy::duplicate_mod)] +#[cfg(feature = "async")] +#[path = "support/async_test_bridge.rs"] +mod async_test_bridge; + #[cfg(feature = "runtime")] #[path = "compiler/compiler_common_tests.rs"] mod compiler_common_tests; diff --git a/tests/fixtures/external-host-extension/.gitignore b/tests/fixtures/external-host-extension/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/tests/fixtures/external-host-extension/.gitignore @@ -0,0 +1 @@ +/target diff --git a/tests/fixtures/external-host-extension/Cargo.lock b/tests/fixtures/external-host-extension/Cargo.lock new file mode 100644 index 00000000..4556c8c4 --- /dev/null +++ b/tests/fixtures/external-host-extension/Cargo.lock @@ -0,0 +1,319 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "external-host-extension" +version = "0.0.0" +dependencies = [ + "pd-vm", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pd-host-function" +version = "0.1.0" +dependencies = [ + "pd-host-schema", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pd-host-schema" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "pd-vm" +version = "0.1.0" +dependencies = [ + "base64", + "futures-channel", + "libc", + "paste", + "pd-host-function", + "regex", + "rt-format", + "self_cell", + "serde", + "serde_json", + "syn 2.0.119", + "windows-sys", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rt-format" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45087cee619d316fa4bd1675494acff4a5eaa0892fa53bc364bd246f13e452e2" +dependencies = [ + "lazy_static", + "regex", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tests/fixtures/external-host-extension/Cargo.toml b/tests/fixtures/external-host-extension/Cargo.toml new file mode 100644 index 00000000..bee196b6 --- /dev/null +++ b/tests/fixtures/external-host-extension/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "external-host-extension" +version = "0.0.0" +edition = "2024" +publish = false + +# Standalone fixture crate that consumes only the PUBLIC host-extension SDK of +# pd-vm. It is deliberately NOT a member of the pd-vm workspace: this proves +# the extension surface (HostContext module state / resource insert / operation +# start, HostExtension register/install, external resource/operation SDK, and +# the host-API catalog) works from a genuinely separate crate with no +# crate-private access. The empty [workspace] table detaches it from the +# enclosing pd-vm workspace so `cargo check --manifest-path` treats it as its +# own root. +[workspace] + +[dependencies] +vm = { package = "pd-vm", path = "../../..", default-features = false, features = ["runtime"] } \ No newline at end of file diff --git a/tests/fixtures/external-host-extension/src/lib.rs b/tests/fixtures/external-host-extension/src/lib.rs new file mode 100644 index 00000000..b1381818 --- /dev/null +++ b/tests/fixtures/external-host-extension/src/lib.rs @@ -0,0 +1,399 @@ +//! External host-extension fixture. +//! +//! A standalone crate that consumes only the **public** host-extension SDK of +//! `pd-vm` (crate name `vm`): the [`HostApiCatalog`] model, typed per-VM +//! module state through [`HostContext`], the [`HostExtension`] register / +//! install lifecycle, external [`HostResource`] insertion and typed borrows +//! through the generic host boundary, and external concrete +//! [`HostOperation`] drivers started into the VM's execution scope. +//! +//! It is deliberately **not** a member of the pd-vm workspace: this proves +//! the extension surface works from a genuinely separate crate with no +//! crate-private access. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use vm::{ + CallOutcome, HostApiCatalog, HostContextError, HostContextErrorKind, HostExtension, + HostFunctionRegistry, HostImportSchema, HostParamPassing, HostTypeSchema, ResourceTypeKey, + ResourceTypeSchema, Value, Vm, VmError, VmResult, catalog_import_schemas, resource, return_one, +}; + +/// Number of times the external `Counter` resource was closed. +pub static CLOSED_COUNTERS: AtomicUsize = AtomicUsize::new(0); +/// Number of times the external `Widget` resource was closed. +pub static CLOSED_WIDGETS: AtomicUsize = AtomicUsize::new(0); + +/// Serializes tracker-dependent tests (the close counters are process-global). +static TRACKER_LOCK: Mutex<()> = Mutex::new(()); + +fn reset_trackers() { + CLOSED_COUNTERS.store(0, Ordering::SeqCst); + CLOSED_WIDGETS.store(0, Ordering::SeqCst); +} + +/// A typed external resource: closed through the generic poll-based close +/// contract, identified by a catalog `ResourceTypeKey`. +#[derive(Debug)] +pub struct Counter(pub u64); + +impl resource::HostResource for Counter { + fn resource_type_key() -> Option { + Some(ResourceTypeKey::new("demo.counter").expect("static key")) + } + + fn begin_close( + &mut self, + _reason: resource::ResourceCloseReason, + ) -> resource::ResourceResult { + CLOSED_COUNTERS.fetch_add(1, Ordering::SeqCst); + Ok(resource::CloseProgress::Ready) + } +} + +/// A second typed external resource with its own key. +#[derive(Debug)] +pub struct Widget(pub i64); + +impl resource::HostResource for Widget { + fn resource_type_key() -> Option { + Some(ResourceTypeKey::new("demo.widget").expect("static key")) + } + + fn begin_close( + &mut self, + _reason: resource::ResourceCloseReason, + ) -> resource::ResourceResult { + CLOSED_WIDGETS.fetch_add(1, Ordering::SeqCst); + Ok(resource::CloseProgress::Ready) + } +} + +/// Persistent per-VM module state: survives execution-scope reset and never +/// participates in resource close. Covered by `HostModule`'s blanket impl. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DemoPolicy { + pub max_counters: u64, +} + +/// An external concrete [`HostOperation`] driver. Polling advances the +/// operation; cancellation records the reason and completes promptly. +#[derive(Debug)] +pub struct CounterOp { + pub remaining: u64, + pub cancelled: Arc, +} + +impl vm::operation::HostOperation for CounterOp { + fn poll( + &mut self, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + if self.remaining == 0 { + std::task::Poll::Ready(Ok(())) + } else { + self.remaining -= 1; + std::task::Poll::Pending + } + } + + fn cancel( + &mut self, + _reason: vm::operation::OperationCancelReason, + ) -> vm::operation::OperationResult<()> { + self.cancelled.fetch_add(1, Ordering::SeqCst); + self.remaining = 0; + Ok(()) + } +} + +// ---- catalog --------------------------------------------------------------- + +/// The external extension's catalog: one resource key per concrete type and +/// one declared function per registered host callable. +pub fn demo_catalog() -> Arc { + let mut builder = HostApiCatalog::builder(); + builder.resource(ResourceTypeSchema::new( + ResourceTypeKey::new("demo.counter").expect("key"), + "An external counter resource", + )); + builder.resource(ResourceTypeSchema::new( + ResourceTypeKey::new("demo.widget").expect("key"), + "An external widget resource", + )); + builder.function(vm::HostFunctionSchema::new( + "demo::make_counter", + vec![vm::HostParamSchema::value("seed", HostTypeSchema::Int)], + )); + builder.function(vm::HostFunctionSchema::new( + "demo::make_widget", + vec![vm::HostParamSchema::value("seed", HostTypeSchema::Int)], + )); + builder.function(vm::HostFunctionSchema::with_return( + "demo::read_counter", + vec![vm::HostParamSchema::with_passing( + "handle", + HostTypeSchema::Resource(ResourceTypeKey::new("demo.counter").expect("key")), + HostParamPassing::Borrow, + )], + HostTypeSchema::Int, + )); + builder.function(vm::HostFunctionSchema::new("demo::spawn_op", vec![])); + Arc::new(builder.build().expect("catalog must build")) +} + +fn decode_handle(raw: i64) -> Result { + resource::ResourceHandle::from_raw(raw as u64) + .map_err(|error| VmError::HostError(error.to_string())) +} + +fn host_error(error: HostContextError) -> VmError { + VmError::HostError(error.to_string()) +} + +/// External host function: inserts a `Counter` into the VM's execution scope +/// and returns its raw handle to the guest. +fn make_counter(vm: &mut Vm, args: &[Value]) -> VmResult { + let seed = match args.first() { + Some(Value::Int(seed)) => *seed, + _ => return Err(VmError::TypeMismatch("int seed")), + }; + let token = vm + .host_context() + .push_resource(Counter(seed as u64)) + .map_err(host_error)?; + Ok(CallOutcome::Return(return_one(token.handle().raw() as i64))) +} + +/// External host function: inserts a `Widget` into the scope. +fn make_widget(vm: &mut Vm, args: &[Value]) -> VmResult { + let seed = match args.first() { + Some(Value::Int(seed)) => *seed, + _ => return Err(VmError::TypeMismatch("int seed")), + }; + let token = vm + .host_context() + .push_resource(Widget(seed)) + .map_err(host_error)?; + Ok(CallOutcome::Return(return_one(token.handle().raw() as i64))) +} + +/// External host function: borrows a `Counter` through the typed host +/// boundary and reads its value. +fn read_counter(vm: &mut Vm, args: &[Value]) -> VmResult { + let raw = match args.first() { + Some(Value::Int(raw)) => *raw, + _ => return Err(VmError::TypeMismatch("int handle")), + }; + let decoded = decode_handle(raw)?; + let value = vm + .host_context() + .borrow_resource::(decoded) + .map_err(host_error)? + .0; + Ok(CallOutcome::Return(return_one(value as i64))) +} + +/// External host function: starts a concrete [`HostOperation`] driver in the +/// VM's execution scope and returns a non-zero id. +fn spawn_op(vm: &mut Vm, _args: &[Value]) -> VmResult { + let cancelled = Arc::new(AtomicUsize::new(0)); + let spec = vm::operation::OperationSpec::new(CounterOp { + remaining: 2, + cancelled: Arc::clone(&cancelled), + }); + let id = vm + .host_context() + .start_operation(spec) + .map_err(host_error)?; + Ok(CallOutcome::Return(return_one(id.raw() as i64))) +} + +fn register_from_catalog( + registry: &mut HostFunctionRegistry, + catalog: &HostApiCatalog, + name: &str, + arity: u8, + function: vm::StaticHostFunction, +) -> VmResult<()> { + // Validate the declaration against the catalog before registering. + let schemas: Vec = catalog_import_schemas(catalog, name); + if schemas.is_empty() { + return Err(VmError::HostError(format!( + "catalog declares no function '{name}'" + ))); + } + let _ = schemas; + registry.register_static(name, arity, function); + Ok(()) +} + +/// External host extension: registers host functions and installs persistent +/// per-VM module state through the public [`HostExtension`] surface. +pub struct DemoExtension; + +impl HostExtension for DemoExtension { + fn register(&self, registry: &mut HostFunctionRegistry) -> VmResult<()> { + let catalog = demo_catalog(); + register_from_catalog(registry, &catalog, "demo::make_counter", 1, make_counter)?; + register_from_catalog(registry, &catalog, "demo::make_widget", 1, make_widget)?; + register_from_catalog(registry, &catalog, "demo::read_counter", 1, read_counter)?; + register_from_catalog(registry, &catalog, "demo::spawn_op", 0, spawn_op)?; + Ok(()) + } + + fn install(&self, vm: &mut Vm) { + let mut context = vm.host_context(); + context.set_module_state(DemoPolicy { max_counters: 3 }); + } +} + +// ---- tests ---------------------------------------------------------------- + +#[cfg(test)] +fn installed_vm() -> Vm { + let program = vm::Program::new(Vec::new(), vec![vm::OpCode::Ret as u8]); + let mut vm = Vm::new(program); + vm.install_extension(&DemoExtension) + .expect("extension should install"); + vm +} + +#[test] +fn extension_installs_module_state_and_registers_host_functions() { + let mut vm = installed_vm(); + // The infallible install phase registered typed per-VM module state. + assert_eq!( + vm.host_context() + .module_state::() + .map(|policy| policy.max_counters), + Some(3) + ); + // The catalog-derived registration surface is exercised by register(). + assert!(vm.host_context().is_scope_active()); + drop(vm); +} + +#[test] +fn external_resource_insert_borrow_and_close_through_scope() { + let _tracker_guard = TRACKER_LOCK.lock().unwrap(); + reset_trackers(); + let mut vm = installed_vm(); + let counter = { + let token = vm + .host_context() + .push_resource(Counter(7)) + .expect("insert counter"); + token + }; + assert_eq!(vm.host_context().resource_count(), 1); + + // Typed borrow reads the value through the SDK (borrow is call-scoped). + { + let context = vm.host_context(); + let borrowed = context + .borrow_resource::(counter.handle()) + .expect("borrow counter"); + assert_eq!(borrowed.0, 7); + } + + // Mut borrow writes through the SDK. + { + let mut context = vm.host_context(); + let mut borrowed = context + .borrow_resource_mut::(counter.handle()) + .expect("mut borrow counter"); + borrowed.0 = 9; + } + { + let context = vm.host_context(); + let borrowed = context + .borrow_resource::(counter.handle()) + .expect("re-borrow counter"); + assert_eq!(borrowed.0, 9); + } + + // Vm drop closes the resource through the scope close sweep. + drop(vm); + assert_eq!(CLOSED_COUNTERS.load(Ordering::SeqCst), 1); +} + +#[test] +fn typed_wrong_resource_rejection_is_structured() { + let _tracker_guard = TRACKER_LOCK.lock().unwrap(); + reset_trackers(); + let mut vm = installed_vm(); + let token = vm + .host_context() + .push_resource(Widget(2)) + .expect("insert widget"); + + // Wrong concrete type is rejected with a structured resource error. + let error = vm + .host_context() + .borrow_resource::(token.handle()) + .unwrap_err(); + assert_eq!(error.namespace(), "host::resource"); + assert!(matches!( + error.kind(), + HostContextErrorKind::Resource(resource_error) + if resource_error.code() == resource::ResourceErrorCode::ResourceTypeMismatch + )); + drop(vm); +} + +#[test] +fn reset_driven_scope_cleanup_closes_resources_and_cancels_operations() { + let _tracker_guard = TRACKER_LOCK.lock().unwrap(); + reset_trackers(); + let mut vm = installed_vm(); + vm.host_context() + .push_resource(Counter(1)) + .expect("counter"); + vm.host_context().push_resource(Widget(2)).expect("widget"); + let cancelled = Arc::new(AtomicUsize::new(0)); + let spec = vm::operation::OperationSpec::new(CounterOp { + remaining: 200, + cancelled: Arc::clone(&cancelled), + }); + vm.host_context().start_operation(spec).expect("op start"); + assert_eq!(vm.host_context().resource_count(), 2); + assert_eq!(vm.host_context().operation_count(), 1); + + // Reset drives the scope to quiescence: resources close, op cancels. + vm.reset_for_reuse(); + assert_eq!(vm.host_context().resource_count(), 0); + assert_eq!(vm.host_context().operation_count(), 0); + assert_eq!(CLOSED_COUNTERS.load(Ordering::SeqCst), 1); + assert_eq!(CLOSED_WIDGETS.load(Ordering::SeqCst), 1); + assert!( + cancelled.load(Ordering::SeqCst) > 0, + "the pending operation driver must be cancelled by the scope close" + ); +} + +#[test] +fn module_state_survives_reset_and_never_participates_in_close() { + let _tracker_guard = TRACKER_LOCK.lock().unwrap(); + reset_trackers(); + let mut vm = installed_vm(); + vm.reset_for_reuse(); + assert!( + vm.host_context().module_state::().is_some(), + "module state must survive reset" + ); + assert_eq!(CLOSED_COUNTERS.load(Ordering::SeqCst), 0); + assert_eq!(CLOSED_WIDGETS.load(Ordering::SeqCst), 0); + drop(vm); +} + +#[test] +fn catalog_validates_declarations_and_fingerprint() { + let catalog = demo_catalog(); + assert!(catalog.has_resource(&ResourceTypeKey::new("demo.counter").expect("key"))); + assert!(catalog.has_resource(&ResourceTypeKey::new("demo.widget").expect("key"))); + assert!(!catalog.functions_named("demo::make_counter").is_empty()); + // Fingerprint is deterministic. + assert_eq!(catalog.fingerprint(), catalog.fingerprint()); +} diff --git a/tests/host_binding_generation_tests.rs b/tests/host_binding_generation_tests.rs index e465a9dc..cff6b85d 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") @@ -140,6 +143,18 @@ fn infers_host_suspension_from_the_return_signature() { fn host() -> VmResult {} ); assert_eq!(infer_host_execution(&synchronous), HostExecutionKind::Sync); + + let asynchronous = parse_quote!( + async fn host(value: String) -> VmResult {} + ); + assert_eq!( + infer_host_execution(&asynchronous), + HostExecutionKind::MaySuspend + ); + assert_eq!( + classify_host_binding(&asynchronous), + HostBindingKind::StaticStack + ); } fn assert_runtime_sleep_loop_uses_native_host_call(bind_cached_registry: bool) { @@ -226,3 +241,100 @@ fn runtime_exit_still_halts_for_direct_and_cached_default_bindings() { assert!(vm.stack().is_empty()); } } + +#[test] +fn restricted_capabilities_disable_trace_jit_for_host_imports_and_builtins() { + for source in [ + r#" + use runtime; + let mut i = 0; + while i < 4 { + let _ = runtime::sleep(0); + i = i + 1; + } + i; + "#, + r#" + use re; + let mut i = 0; + while i < 4 { + let _ = re::match("a", "a"); + i = i + 1; + } + i; + "#, + ] { + let compiled = compile_source(source).expect("restricted loop should compile"); + let mut vm = Vm::new(compiled.program); + vm.set_jit_config(JitConfig { + enabled: native_jit_supported(), + hot_loop_threshold: 1, + max_trace_len: 512, + }); + let error = HostFunctionRegistry::restricted() + .bind_vm_cached(&mut vm) + .expect_err("restricted registry should reject ungranted capability during preflight"); + + assert!( + error + .to_string() + .contains("capability profile does not allow") + ); + assert_eq!(vm.jit_native_exec_count(), 0); + } +} + +#[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 vm_host_core_does_not_name_builtin_subsystem_policies() { + let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let host_runtime = std::fs::read_to_string(manifest.join("src/vm/host_runtime.rs")) + .expect("host runtime source"); + let capability = + std::fs::read_to_string(manifest.join("src/vm/capability.rs")).expect("capability source"); + let host = std::fs::read_to_string(manifest.join("src/vm/host.rs")).expect("host source"); + + for forbidden in [ + "HttpState", + "IoPolicy", + "SqlitePolicy", + "http_state", + "io_policy", + "sqlite_policy", + ] { + assert!( + !host_runtime.contains(forbidden), + "HostRuntime leaked {forbidden}" + ); + assert!( + !capability.contains(forbidden), + "capability.rs leaked {forbidden}" + ); + assert!(!host.contains(forbidden), "host.rs leaked {forbidden}"); + } +} diff --git a/tests/host_context_arch_tests.rs b/tests/host_context_arch_tests.rs new file mode 100644 index 00000000..ad4c70e9 --- /dev/null +++ b/tests/host_context_arch_tests.rs @@ -0,0 +1,134 @@ +//! Architecture tests for the generic host-context boundary. +//! +//! These tests verify two properties that the host-context SDK commit +//! guarantees: +//! +//! 1. **Boundary hygiene** — `src/vm` (and, in particular, the boundary file +//! `src/vm/host_context.rs`) does not import builtin *domain* modules +//! (`sqlite`, `io`, `http`, `json`, ...) nor `rusqlite`. Standard SQLite / +//! IO / HTTP / SSE remain same-crate builtins; `src/vm` only owns the +//! generic boundary and must stay domain-agnostic. +//! 2. **Generic external registration** — an external host *extension* +//! registers typed, per-VM module state purely through the public +//! [`HostContext`] surface, without ever touching host-runtime internals +//! (which stay private) or a builtin domain type. + +use std::fs; +use std::path::{Path, PathBuf}; + +use vm::{HostExtension, Program, Vm}; + +/// The builtin *domain* modules that `src/vm` must not import. +const FORBIDDEN_DOMAIN_IMPORTS: &[&str] = &[ + "builtins::runtime::sqlite", + "builtins::runtime::io", + "builtins::runtime::http", + "builtins::runtime::json", + "builtins::runtime::typed", +]; + +/// `rusqlite` must never appear in `src/vm`. +const FORBIDDEN_RUSQLITE: &str = "rusqlite"; + +fn vm_source_files() -> Vec { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let vm_dir = root.join("src").join("vm"); + // The boundary files that must stay domain-agnostic. The rest of `src/vm` + // legitimately re-exports domain SQLite limits/policy for embedding, so + // the boundary guard is scoped to the files we own here. + let mut files = Vec::new(); + for name in [ + "host_context.rs", + "host_extension.rs", + "standard_composition.rs", + ] { + let path = vm_dir.join(name); + assert!( + path.exists(), + "expected boundary file {} to exist", + path.display() + ); + files.push(path); + } + files +} + +/// Removes `//` line comments and `/* ... */` block comments so the import +/// guards inspect actual code (imports / inline paths) rather than doc prose +/// that merely *discusses* the boundary rules. +fn strip_comments(source: &str) -> String { + let mut out = String::with_capacity(source.len()); + let bytes = source.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + if bytes[i] == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' { + while i < bytes.len() && bytes[i] != b'\n' { + i += 1; + } + continue; + } + if bytes[i] == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' { + i += 2; + while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } + i += 2; + continue; + } + out.push(bytes[i] as char); + i += 1; + } + out +} + +#[test] +fn vm_core_does_not_import_builtin_domain_modules() { + for file in vm_source_files() { + let source = fs::read_to_string(&file).expect("read vm source"); + let code = strip_comments(&source); + for forbidden in FORBIDDEN_DOMAIN_IMPORTS { + assert!( + !code.contains(forbidden), + "`src/vm` file `{}` must not import `{forbidden}`", + file.display() + ); + } + assert!( + !code.contains(FORBIDDEN_RUSQLITE), + "`src/vm` file `{}` must not reference rusqlite", + file.display() + ); + } +} + +/// Generic external extension: registers typed per-VM module state through the +/// public [`HostContext`] and the [`HostExtension`] lifecycle only. +#[derive(Debug)] +struct DemoPolicy { + max_items: u64, +} + +struct DemoExtension; + +impl HostExtension for DemoExtension { + fn install(&self, vm: &mut Vm) { + vm.host_context() + .set_module_state(DemoPolicy { max_items: 3 }); + } +} + +#[test] +fn external_extension_registers_module_state_through_public_surface() { + let program = Program::new(Vec::new(), vec![vm::OpCode::Ret as u8]); + let mut vm = Vm::new(program); + vm.install_extension(&DemoExtension) + .expect("extension should install"); + assert_eq!( + vm.host_context() + .module_state::() + .map(|policy| policy.max_items), + Some(3) + ); + // Module state is generic storage: it does not register as a resource. + assert_eq!(vm.host_context().resource_count(), 0); +} diff --git a/tests/host_sdk_tests.rs b/tests/host_sdk_tests.rs new file mode 100644 index 00000000..9e39d770 --- /dev/null +++ b/tests/host_sdk_tests.rs @@ -0,0 +1,239 @@ +//! Dedicated tests for the external host-extension SDK surface restored into +//! PR18: the host-API catalog model, the generic host-context boundary, and +//! the host-extension register/install lifecycle — all exercised through the +//! public crate API (the same surface an external host crate consumes). + +use vm::{ + CallOutcome, HostApiBuilder, HostApiCatalog, HostContextErrorKind, HostExtension, + HostFunctionRegistry, HostParamPassing, HostTypeSchema, Program, ResourceTypeKey, + ResourceTypeSchema, Value, Vm, VmError, VmResult, catalog_import_schemas, operation, resource, +}; + +fn counter_key() -> ResourceTypeKey { + ResourceTypeKey::new("demo.counter").expect("static key") +} + +fn catalog() -> HostApiCatalog { + let mut builder = HostApiBuilder::new(); + builder.resource(ResourceTypeSchema::new(counter_key(), "A counter")); + builder.function(vm::HostFunctionSchema::with_return( + "demo::make", + vec![vm::HostParamSchema::value("seed", HostTypeSchema::Int)], + HostTypeSchema::Resource(counter_key()), + )); + builder.function(vm::HostFunctionSchema::with_return( + "demo::read", + vec![vm::HostParamSchema::with_passing( + "handle", + HostTypeSchema::Resource(counter_key()), + HostParamPassing::Borrow, + )], + HostTypeSchema::Int, + )); + builder.build().expect("catalog must build") +} + +#[test] +fn catalog_import_schemas_carries_fingerprint_and_passing() { + let catalog = catalog(); + let schemas = catalog_import_schemas(&catalog, "demo::read"); + assert_eq!(schemas.len(), 1); + assert_eq!(schemas[0].fingerprint, catalog.fingerprint()); + assert_eq!(schemas[0].params.len(), 1); + assert_eq!(schemas[0].params[0].passing, HostParamPassing::Borrow); +} + +#[test] +fn catalog_fingerprint_is_stable_and_semantic() { + let a = catalog(); + let mut b = HostApiBuilder::new(); + b.resource(ResourceTypeSchema::new(counter_key(), "Different docs")); + b.function(vm::HostFunctionSchema::with_return( + "demo::make", + vec![vm::HostParamSchema::value("seed", HostTypeSchema::Int)], + HostTypeSchema::Resource(counter_key()), + )); + b.function(vm::HostFunctionSchema::with_return( + "demo::read", + vec![vm::HostParamSchema::with_passing( + "handle", + HostTypeSchema::Resource(counter_key()), + HostParamPassing::Borrow, + )], + HostTypeSchema::Int, + )); + let b = b.build().expect("catalog must build"); + // Documentation is excluded from the fingerprint; semantic fields match. + assert_eq!(a.fingerprint(), b.fingerprint()); +} + +#[derive(Debug)] +struct Counter(u64); + +impl resource::HostResource for Counter { + fn resource_type_key() -> Option { + Some(counter_key()) + } + + fn begin_close( + &mut self, + _reason: resource::ResourceCloseReason, + ) -> resource::ResourceResult { + Ok(resource::CloseProgress::Ready) + } +} + +#[derive(Debug)] +struct TickingOp; + +impl operation::HostOperation for TickingOp { + fn poll( + &mut self, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Pending + } + + fn cancel( + &mut self, + _reason: operation::OperationCancelReason, + ) -> operation::OperationResult<()> { + Ok(()) + } +} + +struct DemoExtension; + +impl HostExtension for DemoExtension { + fn register(&self, registry: &mut HostFunctionRegistry) -> VmResult<()> { + let catalog = catalog(); + let schemas = catalog_import_schemas(&catalog, "demo::make"); + assert!(!schemas.is_empty()); + registry.register_static("demo::make", 1, make_counter as vm::StaticHostFunction); + registry.register_static("demo::read", 1, read_counter as vm::StaticHostFunction); + Ok(()) + } + + fn install(&self, vm: &mut Vm) { + vm.host_context().set_module_state("installed"); + } +} + +fn make_counter(vm: &mut Vm, args: &[Value]) -> VmResult { + let seed = match args.first() { + Some(Value::Int(seed)) => *seed, + _ => return Err(VmError::TypeMismatch("int")), + }; + let token = vm + .host_context() + .push_resource(Counter(seed as u64)) + .map_err(|error| VmError::HostError(error.to_string()))?; + Ok(CallOutcome::Return(vm::return_one( + token.handle().raw() as i64 + ))) +} + +fn read_counter(vm: &mut Vm, args: &[Value]) -> VmResult { + let raw = match args.first() { + Some(Value::Int(raw)) => *raw, + _ => return Err(VmError::TypeMismatch("int")), + }; + let handle = resource::ResourceHandle::from_raw(raw as u64) + .map_err(|error| VmError::HostError(error.to_string()))?; + let value = vm + .host_context() + .borrow_resource::(handle) + .map_err(|error| VmError::HostError(error.to_string()))? + .0; + Ok(CallOutcome::Return(vm::return_one(value as i64))) +} + +#[test] +fn extension_register_and_install_are_transactional() { + let program = Program::new(Vec::new(), vec![vm::OpCode::Ret as u8]); + let mut vm = Vm::new(program); + vm.install_extension(&DemoExtension) + .expect("extension should install"); + assert_eq!(vm.host_context().module_state::<&str>(), Some(&"installed")); +} + +#[test] +fn host_context_inserts_resources_and_starts_operations() { + let program = Program::new(Vec::new(), vec![vm::OpCode::Ret as u8]); + let mut vm = Vm::new(program); + { + let token = vm + .host_context() + .push_resource(Counter(42)) + .expect("push counter"); + let value = vm + .host_context() + .borrow_resource::(token.handle()) + .expect("borrow counter") + .0; + assert_eq!(value, 42); + } + let id = vm + .host_context() + .start_operation(operation::OperationSpec::new(TickingOp)) + .expect("start operation"); + assert_eq!(vm.host_context().operation_count(), 1); + assert_eq!( + vm.host_context().operation_status(id).expect("status"), + operation::OperationStatus::Pending + ); +} + +#[test] +fn closing_scope_rejects_new_inserts_with_structured_error() { + let program = Program::new(Vec::new(), vec![vm::OpCode::Ret as u8]); + let mut vm = Vm::new(program); + // Drive the public execution scope into Closing (new inserts sealed). + vm.execution_scope() + .begin_close(resource::ResourceCloseReason::Requested) + .expect("begin close"); + let error = vm + .host_context() + .push_resource(Counter(1)) + .expect_err("closing scope must reject inserts"); + assert!(matches!( + error.kind(), + HostContextErrorKind::Scope(scope_error) + if matches!( + scope_error, + vm::execution_scope::ExecutionScopeError::ScopeClosing + ) + )); +} + +#[test] +fn external_operation_driver_cancels_on_scope_close() { + let program = Program::new(Vec::new(), vec![vm::OpCode::Ret as u8]); + let mut vm = Vm::new(program); + let cancelled = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let driver = TickingOpWithFlag(cancelled.clone()); + vm.host_context() + .start_operation(operation::OperationSpec::new(driver)) + .expect("start"); + drop(vm); + assert_eq!(cancelled.load(std::sync::atomic::Ordering::SeqCst), 1); +} + +struct TickingOpWithFlag(std::sync::Arc); + +impl operation::HostOperation for TickingOpWithFlag { + fn poll( + &mut self, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Pending + } + + fn cancel( + &mut self, + _reason: operation::OperationCancelReason, + ) -> operation::OperationResult<()> { + self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(()) + } +} diff --git a/tests/support/async_test_bridge.rs b/tests/support/async_test_bridge.rs new file mode 100644 index 00000000..39f77d08 --- /dev/null +++ b/tests/support/async_test_bridge.rs @@ -0,0 +1,71 @@ +use std::collections::HashMap; +use std::task::{Context, Poll}; + +use vm::{ + CallReturn, HostAsyncBridge, HostFuture, HostFutureOutput, HostOpId, Vm, VmError, VmResult, +}; + +struct TokioTestBridge { + runtime: tokio::runtime::Runtime, + futures: HashMap, +} + +impl TokioTestBridge { + fn new() -> Self { + Self { + runtime: tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("test runtime should build"), + futures: HashMap::new(), + } + } +} + +impl HostAsyncBridge for TokioTestBridge { + fn submit_op(&mut self, op_id: HostOpId, future: HostFuture) -> VmResult<()> { + if self.futures.insert(op_id, future).is_some() { + return Err(VmError::HostError(format!( + "duplicate submitted host op {op_id}" + ))); + } + Ok(()) + } + + fn poll_op(&mut self, op_id: HostOpId, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Err(VmError::HostError(format!( + "unexpected external op {op_id}" + )))) + } + + fn poll_submitted_op( + &mut self, + op_id: HostOpId, + cx: &mut Context<'_>, + ) -> Poll> { + let poll = { + let future = match self.futures.get_mut(&op_id) { + Some(future) => future, + None => { + return Poll::Ready(Err(VmError::HostError(format!( + "unknown submitted host op {op_id}" + )))); + } + }; + let _guard = self.runtime.enter(); + future.as_mut().poll(cx) + }; + if poll.is_ready() { + self.futures.remove(&op_id); + } + poll + } + + fn cancel_op(&mut self, op_id: HostOpId) { + self.futures.remove(&op_id); + } +} + +pub(crate) fn install(vm: &mut Vm) { + vm.set_async_bridge(Box::new(TokioTestBridge::new())); +}