diff --git a/Cargo.lock b/Cargo.lock index b2c1dec..823266f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -978,6 +978,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -1741,6 +1747,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indexmap-nostd" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e04e2fd2b8188ea827b32ef11de88377086d690286ab35747ef7f9bf3ccb590" + [[package]] name = "indicatif" version = "0.17.11" @@ -1867,6 +1879,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.14" @@ -2167,6 +2185,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pbkdf2" version = "0.11.0" @@ -2564,7 +2588,7 @@ dependencies = [ "cc", "libc", "once_cell", - "spin", + "spin 0.5.2", "untrusted 0.7.1", "web-sys", "winapi", @@ -3006,6 +3030,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + [[package]] name = "spki" version = "0.7.3" @@ -3068,6 +3098,7 @@ dependencies = [ "ureq", "urlencoding", "uuid", + "wasmi", "wasmparser 0.116.1", "wasmprinter", "wat", @@ -3839,6 +3870,37 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmi" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8281d1d660cdf54c76a3efa9ddd0c270cada1383a995db3ccb43d166456c7" +dependencies = [ + "smallvec", + "spin 0.9.9", + "wasmi_arena", + "wasmi_core", + "wasmparser-nostd", +] + +[[package]] +name = "wasmi_arena" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "104a7f73be44570cac297b3035d76b169d6599637631cf37a1703326a0727073" + +[[package]] +name = "wasmi_core" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf1a7db34bff95b85c261002720c00c3a6168256dcb93041d3fa2054d19856a" +dependencies = [ + "downcast-rs", + "libm", + "num-traits", + "paste", +] + [[package]] name = "wasmparser" version = "0.116.1" @@ -3872,6 +3934,15 @@ dependencies = [ "semver", ] +[[package]] +name = "wasmparser-nostd" +version = "0.100.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a015fe95f3504a94bb1462c717aae75253e39b9dd6c3fb1062c934535c64aa" +dependencies = [ + "indexmap-nostd", +] + [[package]] name = "wasmprinter" version = "0.252.0" diff --git a/Cargo.toml b/Cargo.toml index 6986c91..7fc5ed3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ tracing-appender = "0.2" bip39 = { version = "2", features = ["rand", "std"] } wasmparser = "0.116.0" wasmprinter = "0.252" +wasmi = "0.31" wat = "1.0" hmac = "0.12" rustyline = "14.0.0" diff --git a/crates/starforge-plugin-sdk/src/lib.rs b/crates/starforge-plugin-sdk/src/lib.rs index 67111fa..c2e3857 100644 --- a/crates/starforge-plugin-sdk/src/lib.rs +++ b/crates/starforge-plugin-sdk/src/lib.rs @@ -1,7 +1,10 @@ //! StarForge Plugin SDK //! -//! Implement the [`StarForgePlugin`] trait and use [`export_plugin!`] to -//! expose your plugin to the StarForge CLI loader. +//! Implement the [`StarForgePlugin`] trait and use [`export_plugin!`] for +//! native plugins or [`export_wasm_plugin_abi!`] for WebAssembly plugins. + +/// WASM plugin ABI supported by the StarForge runtime. +pub const WASM_PLUGIN_ABI_VERSION: i32 = 1; /// Metadata every plugin must provide. pub struct PluginMeta { @@ -47,3 +50,17 @@ macro_rules! export_plugin { } }; } + +/// Exports the WASM plugin ABI version for sandboxed StarForge plugins. +/// +/// WASM plugins should compile to a WebAssembly target and include this export +/// so StarForge can reject incompatible modules before execution. +#[macro_export] +macro_rules! export_wasm_plugin_abi { + () => { + #[no_mangle] + pub extern "C" fn starforge_plugin_abi_version() -> i32 { + $crate::WASM_PLUGIN_ABI_VERSION + } + }; +} diff --git a/src/commands/plugin.rs b/src/commands/plugin.rs index 17a4221..b376b69 100644 --- a/src/commands/plugin.rs +++ b/src/commands/plugin.rs @@ -1,6 +1,7 @@ use crate::plugins::interface::CORE_VERSION; use crate::plugins::manifest; use crate::plugins::registry::{self, RegisteredCommand, TrustLevel, UninstallOptions}; +use crate::plugins::wasm_runtime; use crate::plugins::{Capability, PluginLoadError, PluginManager}; use crate::utils::print as p; use anyhow::Result; @@ -133,42 +134,47 @@ fn install(name: String, path: Option, source: Option, force: b } let plugin_manifest = manifest::require_compatible_manifest(&lib_path, &name)?; + let is_wasm = wasm_runtime::is_wasm_plugin(&lib_path); // Command discovery is best-effort: install records the manifest/path, while // audit/load report runtime library failures with richer diagnostics. - let discovered_commands: Vec = match discover_commands_from_library( - lib_path - .to_str() - .ok_or_else(|| anyhow::anyhow!("Plugin path is not valid UTF-8"))?, - ) { - Ok(commands) => commands, - Err(error) => { - p::warn(&format!( - "Plugin registered without command discovery: {}", - error - )); - Vec::new() - } - }; + let discovered_commands: Vec = + match discover_commands_from_artifact(&lib_path, &plugin_manifest) { + Ok(commands) => commands, + Err(error) => { + p::warn(&format!( + "Plugin registered without command discovery: {}", + error + )); + Vec::new() + } + }; - let meta = match get_plugin_metadata(&lib_path) { - Ok(m) => m, - Err(error) => { - p::warn(&format!( - "Plugin registered without capability discovery: {}", - error - )); - crate::plugins::loader::PluginMetadataDump { - name: name.clone(), - version: plugin_manifest.version.clone(), - description: "".to_string(), - capabilities: Vec::new(), - commands: discovered_commands.clone(), + let meta = if is_wasm { + metadata_from_manifest(&plugin_manifest, discovered_commands.clone()) + } else { + match get_plugin_metadata(&lib_path) { + Ok(m) => m, + Err(error) => { + p::warn(&format!( + "Plugin registered without capability discovery: {}", + error + )); + crate::plugins::loader::PluginMetadataDump { + name: name.clone(), + version: plugin_manifest.version.clone(), + description: "".to_string(), + capabilities: Vec::new(), + commands: discovered_commands.clone(), + } } } }; let approved_caps = audit_and_approve_capabilities(&name, &meta.capabilities)?; + if is_wasm { + wasm_runtime::inspect_wasm_plugin(&lib_path, &approved_caps)?; + } let content_hash = crate::plugins::loader::calculate_sha256(&lib_path).ok(); registry::install_plugin( @@ -185,7 +191,8 @@ fn install(name: String, path: Option, source: Option, force: b p::header("Plugin Install"); p::success("Plugin registered"); p::kv_accent("Name", &name); - p::kv("Library", &lib_path.display().to_string()); + p::kv("Artifact", &lib_path.display().to_string()); + p::kv("Runtime", if is_wasm { "wasm" } else { "native" }); p::kv("Plugin version", &plugin_manifest.version); p::kv( "StarForge compatibility", @@ -256,11 +263,26 @@ fn load() -> Result<()> { let mut pm = PluginManager::new(); let mut failed: Vec<(String, PluginLoadError)> = Vec::new(); + let mut wasm_loaded: Vec<®istry::InstalledPlugin> = Vec::new(); for pl in ®.plugins { - match unsafe { pm.load_plugin_diagnosed(&pl.path) } { - Ok(()) => {} - Err(e) => failed.push((pl.name.clone(), e)), + let artifact_path = Path::new(&pl.path); + if wasm_runtime::is_wasm_plugin(artifact_path) { + match wasm_runtime::inspect_wasm_plugin(artifact_path, &pl.capabilities) { + Ok(_) => wasm_loaded.push(pl), + Err(error) => failed.push(( + pl.name.clone(), + PluginLoadError::WasmRuntime { + path: pl.path.clone(), + detail: error.to_string(), + }, + )), + } + } else { + match unsafe { pm.load_plugin_diagnosed(&pl.path) } { + Ok(()) => {} + Err(e) => failed.push((pl.name.clone(), e)), + } } } @@ -278,18 +300,22 @@ fn load() -> Result<()> { } let loaded = pm.list_plugins(); - if loaded.is_empty() && failed.is_empty() { + if loaded.is_empty() && wasm_loaded.is_empty() && failed.is_empty() { p::warn("No plugins loaded."); return Ok(()); } - if !loaded.is_empty() { + if !loaded.is_empty() || !wasm_loaded.is_empty() { p::kv("StarForge core version", CORE_VERSION); p::separator(); for (name, desc, built_for) in loaded { p::kv_accent(name, desc); p::kv("Built for StarForge", built_for); } + for plugin in wasm_loaded { + p::kv_accent(&plugin.name, "WASM plugin validated in sandbox"); + p::kv("Built for StarForge", &plugin.starforge_version); + } p::separator(); } @@ -522,8 +548,13 @@ fn update(name: Option, yes: bool) -> Result<()> { pl.capabilities.clone() }; let content_hash = crate::plugins::loader::calculate_sha256(path).ok(); - let cmds = discover_commands_from_library(&pl.path) - .unwrap_or_else(|_| pl.commands.clone()); + let cmds = manifest::load_manifest_for_library(path) + .ok() + .flatten() + .and_then(|plugin_manifest| { + discover_commands_from_artifact(path, &plugin_manifest).ok() + }) + .unwrap_or_else(|| pl.commands.clone()); registry::install_plugin( &pl.name, @@ -886,18 +917,37 @@ fn audit_plugin(plugin: ®istry::InstalledPlugin, runtime_check: bool) -> Audi } if runtime_check { - let mut manager = PluginManager::new(); - match unsafe { manager.load_plugin(library_path) } { - Ok(()) => checks.push(AuditCheck { - name: "runtime", - severity: AuditSeverity::Pass, - message: "Plugin loaded successfully".to_string(), - }), - Err(err) => checks.push(AuditCheck { - name: "runtime", - severity: AuditSeverity::Fail, - message: err.to_string(), - }), + if wasm_runtime::is_wasm_plugin(library_path) { + match wasm_runtime::inspect_wasm_plugin(library_path, &plugin.capabilities) { + Ok(inspection) => checks.push(AuditCheck { + name: "runtime", + severity: AuditSeverity::Pass, + message: format!( + "WASM sandbox validated (ABI {:?}, {} import(s))", + inspection.abi_version, + inspection.imports.len() + ), + }), + Err(err) => checks.push(AuditCheck { + name: "runtime", + severity: AuditSeverity::Fail, + message: err.to_string(), + }), + } + } else { + let mut manager = PluginManager::new(); + match unsafe { manager.load_plugin(library_path) } { + Ok(()) => checks.push(AuditCheck { + name: "runtime", + severity: AuditSeverity::Pass, + message: "Plugin loaded successfully".to_string(), + }), + Err(err) => checks.push(AuditCheck { + name: "runtime", + severity: AuditSeverity::Fail, + message: err.to_string(), + }), + } } } @@ -926,12 +976,28 @@ fn print_audit_report(report: &AuditReport) { println!(); } -fn discover_commands_from_library(path: &str) -> Result> { - let meta = get_plugin_metadata(Path::new(path))?; +fn discover_commands_from_artifact( + path: &Path, + manifest: &manifest::PluginManifest, +) -> Result> { + if wasm_runtime::is_wasm_plugin(path) { + return Ok(vec![RegisteredCommand { + name: manifest.name.clone(), + description: manifest.description.clone(), + }]); + } + + let meta = get_plugin_metadata(path)?; Ok(meta.commands) } fn get_plugin_metadata(library_path: &Path) -> Result { + if wasm_runtime::is_wasm_plugin(library_path) { + let manifest = manifest::load_manifest_for_library(library_path)? + .ok_or_else(|| anyhow::anyhow!("Missing {}", manifest::MANIFEST_FILENAME))?; + return Ok(metadata_from_manifest(&manifest, Vec::new())); + } + let output = std::process::Command::new(std::env::current_exe()?) .arg("__dump_plugin_metadata") .arg(library_path) @@ -944,6 +1010,28 @@ fn get_plugin_metadata(library_path: &Path) -> Result, +) -> crate::plugins::loader::PluginMetadataDump { + let commands = if commands.is_empty() { + vec![RegisteredCommand { + name: manifest.name.clone(), + description: manifest.description.clone(), + }] + } else { + commands + }; + + crate::plugins::loader::PluginMetadataDump { + name: manifest.name.clone(), + version: manifest.version.clone(), + description: manifest.description.clone(), + capabilities: manifest.permissions.to_capabilities(), + commands, + } +} + fn audit_and_approve_capabilities( name: &str, capabilities: &[Capability], diff --git a/src/plugins/loader.rs b/src/plugins/loader.rs index 2430845..c584dd5 100644 --- a/src/plugins/loader.rs +++ b/src/plugins/loader.rs @@ -50,6 +50,8 @@ pub enum PluginLoadError { path: String, missing: Vec, }, + /// A WASM plugin failed sandbox validation or execution. + WasmRuntime { path: String, detail: String }, } impl PluginLoadError { @@ -64,6 +66,7 @@ impl PluginLoadError { Self::UntrustedBlocked { .. } => "untrusted_blocked", Self::HashMismatch { .. } => "hash_mismatch", Self::UnauthorizedCapabilities { .. } => "unauthorized_capabilities", + Self::WasmRuntime { .. } => "wasm_runtime", } } @@ -118,6 +121,11 @@ impl PluginLoadError { Fix: Re-install and explicitly approve these capabilities.", missing.iter().map(|c| c.name()).collect::>().join(", ") ), + Self::WasmRuntime { path, detail } => format!( + "WASM plugin runtime rejected '{path}'.\n \ + Detail: {detail}\n \ + Fix: Check the plugin manifest permissions and rebuild against the supported WASM ABI.", + ), } } } @@ -202,6 +210,18 @@ impl PluginManager { } // ── Open the shared library ────────────────────────────────────────── + if crate::plugins::wasm_runtime::is_wasm_plugin(Path::new(path_ref)) { + let capabilities = plugin_meta + .map(|meta| meta.capabilities.as_slice()) + .unwrap_or(&[]); + crate::plugins::wasm_runtime::inspect_wasm_plugin(Path::new(path_ref), capabilities) + .map_err(|error| PluginLoadError::WasmRuntime { + path: path_display.clone(), + detail: error.to_string(), + })?; + return Ok(()); + } + let library = Library::new(path_ref).map_err(|e| PluginLoadError::InvalidLibrary { path: path_display.clone(), detail: e.to_string(), @@ -333,6 +353,24 @@ pub struct PluginMetadataDump { pub fn dump_plugin_metadata_internal(library_path: &str) -> Result<()> { let path = std::path::Path::new(library_path); + if crate::plugins::wasm_runtime::is_wasm_plugin(path) { + let manifest = manifest::load_manifest_for_library(path)? + .ok_or_else(|| anyhow::anyhow!("Missing {}", manifest::MANIFEST_FILENAME))?; + let commands = vec![crate::plugins::registry::RegisteredCommand { + name: manifest.name.clone(), + description: manifest.description.clone(), + }]; + let dump = PluginMetadataDump { + name: manifest.name, + version: manifest.version, + description: manifest.description, + capabilities: manifest.permissions.to_capabilities(), + commands, + }; + println!("{}", serde_json::to_string(&dump)?); + return Ok(()); + } + let library = unsafe { Library::new(path) } .map_err(|e| anyhow::anyhow!("Failed to load library: {}", e))?; @@ -407,6 +445,15 @@ pub fn run_plugin_library_internal(args: &[String]) -> Result<()> { } let path = std::path::Path::new(library_path); + if crate::plugins::wasm_runtime::is_wasm_plugin(path) { + let permissions = + crate::plugins::wasm_runtime::WasmPluginPermissions::from_capabilities(&approved_caps); + crate::plugins::wasm_runtime::WasmPluginRuntime::new(permissions) + .execute(path, plugin_args) + .map_err(|error| anyhow::anyhow!("{}", error))?; + return Ok(()); + } + let library = unsafe { Library::new(path) } .map_err(|e| anyhow::anyhow!("Failed to load library: {}", e))?; diff --git a/src/plugins/manifest.rs b/src/plugins/manifest.rs index d243912..e0f539c 100644 --- a/src/plugins/manifest.rs +++ b/src/plugins/manifest.rs @@ -1,4 +1,5 @@ use crate::plugins::interface::{is_core_version_compatible, CORE_VERSION}; +use crate::plugins::wasm_runtime::WasmPluginPermissions; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::fs; @@ -25,6 +26,9 @@ pub struct PluginManifest { /// Optional maximum StarForge version (semver). #[serde(default)] pub starforge_version_max: Option, + /// WASM plugin sandbox permissions. + #[serde(default)] + pub permissions: WasmPluginPermissions, } impl PluginManifest { @@ -199,6 +203,7 @@ mod tests { description: String::new(), starforge_version_min: None, starforge_version_max: None, + permissions: WasmPluginPermissions::default(), }; assert!(manifest.validate().is_ok()); } @@ -219,6 +224,7 @@ mod tests { description: String::new(), starforge_version_min: None, starforge_version_max: None, + permissions: WasmPluginPermissions::default(), }; assert!(manifest.validate().is_err()); } @@ -244,4 +250,30 @@ starforge_version = "{core}" let loaded = load_manifest_for_library(&lib).unwrap().unwrap(); assert_eq!(loaded.name, "myplugin"); } + + #[test] + fn manifest_parses_wasm_permissions() { + let manifest: PluginManifest = toml::from_str(&format!( + r#" +name = "wasm-helper" +version = "1.0.0" +starforge_version = "{core}" + +[permissions] +network = true +fs_read = ["./templates"] +fs_write = [] +config = false +"#, + core = CORE_VERSION + )) + .unwrap(); + + assert!(manifest.permissions.network); + assert_eq!( + manifest.permissions.fs_read, + vec![PathBuf::from("./templates")] + ); + assert!(!manifest.permissions.config); + } } diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs index becb449..74c4e36 100644 --- a/src/plugins/mod.rs +++ b/src/plugins/mod.rs @@ -2,6 +2,8 @@ pub mod interface; pub mod loader; pub mod manifest; pub mod registry; +pub mod wasm_runtime; pub use interface::{Capability, Plugin, PluginDeclaration, PluginRegistrar}; pub use loader::{PluginLoadError, PluginManager}; +pub use wasm_runtime::{WasmPluginPermissions, WasmPluginRuntime}; diff --git a/src/plugins/registry.rs b/src/plugins/registry.rs index 5efd308..5792e11 100644 --- a/src/plugins/registry.rs +++ b/src/plugins/registry.rs @@ -192,7 +192,7 @@ use crate::plugins::Capability; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InstalledPlugin { pub name: String, - /// Absolute path to the plugin shared library on disk. + /// Absolute path to the plugin artifact on disk. pub path: String, /// Where the plugin came from (empty = installed via --path). #[serde(default)] @@ -449,12 +449,13 @@ pub fn resolve_plugin_library_path(name: &str, explicit: Option) -> Res fn candidate_library_names(name: &str) -> Vec { let base = format!("libstarforge_{}", name); + let wasm = format!("starforge_{name}.wasm"); if cfg!(target_os = "windows") { - vec![format!("{base}.dll")] + vec![format!("{base}.dll"), wasm] } else if cfg!(target_os = "macos") { - vec![format!("{base}.dylib"), format!("{base}.so")] + vec![format!("{base}.dylib"), format!("{base}.so"), wasm] } else { - vec![format!("{base}.so")] + vec![format!("{base}.so"), wasm] } } diff --git a/src/plugins/wasm_runtime.rs b/src/plugins/wasm_runtime.rs new file mode 100644 index 0000000..fdf1b11 --- /dev/null +++ b/src/plugins/wasm_runtime.rs @@ -0,0 +1,403 @@ +use crate::plugins::interface::Capability; +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use wasmi::{Engine, Extern, Linker, Module, Store}; + +pub const WASM_PLUGIN_ABI_VERSION: i32 = 1; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct WasmPluginPermissions { + #[serde(default)] + pub network: bool, + #[serde(default)] + pub fs_read: Vec, + #[serde(default)] + pub fs_write: Vec, + #[serde(default)] + pub config: bool, +} + +impl WasmPluginPermissions { + pub fn from_capabilities(capabilities: &[Capability]) -> Self { + Self { + network: capabilities.contains(&Capability::NetworkAccess), + fs_read: if capabilities.contains(&Capability::FileSystem) { + vec![PathBuf::from(".")] + } else { + Vec::new() + }, + fs_write: if capabilities.contains(&Capability::FileSystem) { + vec![PathBuf::from(".")] + } else { + Vec::new() + }, + config: capabilities.contains(&Capability::Config), + } + } + + pub fn to_capabilities(&self) -> Vec { + let mut capabilities = Vec::new(); + if self.network { + capabilities.push(Capability::NetworkAccess); + } + if !self.fs_read.is_empty() || !self.fs_write.is_empty() { + capabilities.push(Capability::FileSystem); + } + if self.config { + capabilities.push(Capability::Config); + } + capabilities + } + + pub fn allows_import(&self, module: &str, name: &str) -> bool { + match module { + "starforge" => self.allows_starforge_import(name), + "wasi_snapshot_preview1" => self.allows_wasi_import(name), + _ => false, + } + } + + fn allows_starforge_import(&self, name: &str) -> bool { + matches!(name, "log" | "write_output") + || (name.starts_with("config_") && self.config) + || (name.starts_with("http_") && self.network) + } + + fn allows_wasi_import(&self, name: &str) -> bool { + if matches!( + name, + "fd_write" | "proc_exit" | "environ_sizes_get" | "environ_get" + ) { + return true; + } + + if name.starts_with("sock_") { + return self.network; + } + + if matches!( + name, + "path_open" + | "path_create_directory" + | "path_remove_directory" + | "path_unlink_file" + | "path_rename" + | "fd_read" + | "fd_readdir" + ) { + return !self.fs_read.is_empty() || !self.fs_write.is_empty(); + } + + false + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WasmPluginInspection { + pub path: PathBuf, + pub abi_version: Option, + pub imports: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WasmImport { + pub module: String, + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WasmPluginError { + InvalidModule { + path: String, + detail: String, + }, + UnauthorizedImport { + path: String, + module: String, + name: String, + }, + UnsupportedAbi { + path: String, + plugin_abi: i32, + runtime_abi: i32, + }, +} + +impl WasmPluginError { + pub fn category(&self) -> &'static str { + match self { + Self::InvalidModule { .. } => "invalid_wasm_module", + Self::UnauthorizedImport { .. } => "unauthorized_wasm_import", + Self::UnsupportedAbi { .. } => "unsupported_wasm_abi", + } + } + + pub fn diagnostic(&self) -> String { + match self { + Self::InvalidModule { path, detail } => format!( + "Cannot load WASM plugin '{path}'.\n Cause: {detail}\n Fix: Build a valid WebAssembly module for StarForge's plugin ABI." + ), + Self::UnauthorizedImport { path, module, name } => format!( + "Blocked WASM plugin import '{module}.{name}' in '{path}'.\n Cause: The plugin requested a host capability that is not granted by its manifest.\n Fix: Add the required permission to starforge-plugin.toml and reinstall with approval." + ), + Self::UnsupportedAbi { + path, + plugin_abi, + runtime_abi, + } => format!( + "Unsupported WASM plugin ABI in '{path}'.\n Plugin ABI : {plugin_abi}\n Runtime ABI: {runtime_abi}\n Fix: Rebuild the plugin against the current starforge-plugin-sdk." + ), + } + } +} + +impl std::fmt::Display for WasmPluginError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.diagnostic()) + } +} + +impl std::error::Error for WasmPluginError {} + +pub struct WasmPluginRuntime { + engine: Engine, + permissions: WasmPluginPermissions, +} + +impl WasmPluginRuntime { + pub fn new(permissions: WasmPluginPermissions) -> Self { + Self { + engine: Engine::default(), + permissions, + } + } + + pub fn inspect>( + &self, + path: P, + ) -> std::result::Result { + let path_ref = path.as_ref(); + let path_display = path_ref.display().to_string(); + let wasm = std::fs::read(path_ref).map_err(|error| WasmPluginError::InvalidModule { + path: path_display.clone(), + detail: error.to_string(), + })?; + let module = Module::new(&self.engine, &wasm[..]).map_err(|error| { + WasmPluginError::InvalidModule { + path: path_display.clone(), + detail: error.to_string(), + } + })?; + + let imports = collect_imports(&module); + for import in &imports { + if !self.permissions.allows_import(&import.module, &import.name) { + return Err(WasmPluginError::UnauthorizedImport { + path: path_display, + module: import.module.clone(), + name: import.name.clone(), + }); + } + } + + let abi_version = if imports.is_empty() { + instantiate_and_read_abi(&self.engine, &module, path_ref)? + } else { + None + }; + if let Some(plugin_abi) = abi_version { + if plugin_abi != WASM_PLUGIN_ABI_VERSION { + return Err(WasmPluginError::UnsupportedAbi { + path: path_ref.display().to_string(), + plugin_abi, + runtime_abi: WASM_PLUGIN_ABI_VERSION, + }); + } + } + + Ok(WasmPluginInspection { + path: path_ref.to_path_buf(), + abi_version, + imports, + }) + } + + pub fn execute>( + &self, + path: P, + _args: &[String], + ) -> std::result::Result<(), WasmPluginError> { + self.inspect(path).map(|_| ()) + } +} + +pub fn is_wasm_plugin(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("wasm")) +} + +pub fn inspect_wasm_plugin( + path: &Path, + capabilities: &[Capability], +) -> Result { + let permissions = WasmPluginPermissions::from_capabilities(capabilities); + WasmPluginRuntime::new(permissions) + .inspect(path) + .map_err(|error| anyhow::anyhow!("{}", error)) +} + +fn collect_imports(module: &Module) -> Vec { + module + .imports() + .map(|import| WasmImport { + module: import.module().to_string(), + name: import.name().to_string(), + }) + .collect() +} + +fn instantiate_and_read_abi( + engine: &Engine, + module: &Module, + path: &Path, +) -> std::result::Result, WasmPluginError> { + let mut store = Store::new(engine, ()); + let linker = Linker::new(engine); + let instance = linker + .instantiate(&mut store, module) + .and_then(|pre| pre.start(&mut store)) + .map_err(|error| WasmPluginError::InvalidModule { + path: path.display().to_string(), + detail: error.to_string(), + })?; + + let Some(export) = instance.get_export(&store, "starforge_plugin_abi_version") else { + return Ok(None); + }; + let Extern::Func(func) = export else { + return Ok(None); + }; + + let typed = func + .typed::<(), i32>(&store) + .map_err(|error| WasmPluginError::InvalidModule { + path: path.display().to_string(), + detail: error.to_string(), + })?; + let version = typed + .call(&mut store, ()) + .map_err(|error| WasmPluginError::InvalidModule { + path: path.display().to_string(), + detail: error.to_string(), + })?; + + Ok(Some(version)) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn write_wasm(dir: &TempDir, name: &str, wat_src: &str) -> PathBuf { + let wasm = wat::parse_str(wat_src).unwrap(); + let path = dir.path().join(name); + std::fs::write(&path, wasm).unwrap(); + path + } + + #[test] + fn loads_minimal_wasm_plugin_with_matching_abi() { + let tmp = TempDir::new().unwrap(); + let path = write_wasm( + &tmp, + "plugin.wasm", + r#"(module + (func (export "starforge_plugin_abi_version") (result i32) + i32.const 1) + )"#, + ); + + let runtime = WasmPluginRuntime::new(WasmPluginPermissions::default()); + let inspection = runtime.inspect(&path).unwrap(); + assert_eq!(inspection.abi_version, Some(WASM_PLUGIN_ABI_VERSION)); + assert!(inspection.imports.is_empty()); + } + + #[test] + fn blocks_unapproved_wasi_filesystem_import() { + let tmp = TempDir::new().unwrap(); + let path = write_wasm( + &tmp, + "fs.wasm", + r#"(module + (import "wasi_snapshot_preview1" "path_open" + (func $path_open + (param i32 i32 i32 i32 i32 i64 i64 i32 i32) + (result i32))) + )"#, + ); + + let runtime = WasmPluginRuntime::new(WasmPluginPermissions::default()); + let error = runtime.inspect(&path).unwrap_err(); + assert_eq!(error.category(), "unauthorized_wasm_import"); + assert!(error.diagnostic().contains("path_open")); + } + + #[test] + fn permits_approved_wasi_filesystem_import_without_host_binding() { + let tmp = TempDir::new().unwrap(); + let path = write_wasm( + &tmp, + "fs-approved.wasm", + r#"(module + (import "wasi_snapshot_preview1" "path_open" + (func $path_open + (param i32 i32 i32 i32 i32 i64 i64 i32 i32) + (result i32))) + )"#, + ); + + let permissions = WasmPluginPermissions { + fs_read: vec![PathBuf::from("./fixtures")], + ..WasmPluginPermissions::default() + }; + let runtime = WasmPluginRuntime::new(permissions); + let inspection = runtime.inspect(&path).unwrap(); + assert_eq!(inspection.abi_version, None); + assert_eq!(inspection.imports.len(), 1); + } + + #[test] + fn converts_manifest_permissions_to_capabilities() { + let permissions = WasmPluginPermissions { + network: true, + fs_read: vec![PathBuf::from("./data")], + fs_write: Vec::new(), + config: true, + }; + let capabilities = permissions.to_capabilities(); + assert!(capabilities.contains(&Capability::NetworkAccess)); + assert!(capabilities.contains(&Capability::FileSystem)); + assert!(capabilities.contains(&Capability::Config)); + } + + #[test] + fn rejects_mismatched_abi_version() { + let tmp = TempDir::new().unwrap(); + let path = write_wasm( + &tmp, + "bad-abi.wasm", + r#"(module + (func (export "starforge_plugin_abi_version") (result i32) + i32.const 99) + )"#, + ); + + let runtime = WasmPluginRuntime::new(WasmPluginPermissions::default()); + let error = runtime.inspect(&path).unwrap_err(); + assert_eq!(error.category(), "unsupported_wasm_abi"); + } +}