//! WASM channel loader for loading channels from files or directories. //! //! Loads WASM channel modules from the filesystem (default: ~/.ironclaw/channels/). //! Each channel consists of: //! - `.wasm` - The compiled WASM component //! - `.capabilities.json` - Channel capabilities and configuration use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::fs; use crate::bootstrap::ironclaw_base_dir; use crate::channels::wasm::capabilities::ChannelCapabilities; use crate::channels::wasm::error::WasmChannelError; use crate::channels::wasm::runtime::WasmChannelRuntime; use crate::channels::wasm::schema::ChannelCapabilitiesFile; use crate::channels::wasm::wrapper::WasmChannel; use crate::db::SettingsStore; use crate::pairing::PairingStore; use crate::secrets::SecretsStore; /// Loads WASM channels from the filesystem. pub struct WasmChannelLoader { runtime: Arc, pairing_store: Arc, settings_store: Option>, secrets_store: Option>, owner_scope_id: String, } impl WasmChannelLoader { /// Create a new loader with the given runtime and pairing store. pub fn new( runtime: Arc, pairing_store: Arc, settings_store: Option>, owner_scope_id: impl Into, ) -> Self { Self { runtime, pairing_store, settings_store, secrets_store: None, owner_scope_id: owner_scope_id.into(), } } /// Set the secrets store for host-based credential injection in WASM channels. pub fn with_secrets_store(mut self, store: Arc) -> Self { self.secrets_store = Some(store); self } /// Load a single WASM channel from a file pair. /// /// Expects: /// - `wasm_path`: Path to the `.wasm` file /// - `capabilities_path`: Path to the `.capabilities.json` file (optional) /// /// If no capabilities file is provided, the channel gets minimal capabilities. pub async fn load_from_files( &self, name: &str, wasm_path: &Path, capabilities_path: Option<&Path>, ) -> Result { // Validate name if name.is_empty() || name.contains('/') || name.contains('\\') || name.contains("..") { return Err(WasmChannelError::InvalidName(name.to_string())); } // Read WASM bytes if !wasm_path.exists() { return Err(WasmChannelError::WasmNotFound(wasm_path.to_path_buf())); } let wasm_bytes = fs::read(wasm_path).await?; // Read capabilities file let (capabilities, config_json, description, cap_file) = if let Some(cap_path) = capabilities_path { if cap_path.exists() { let cap_bytes = fs::read(cap_path).await?; let cap_file = ChannelCapabilitiesFile::from_bytes(&cap_bytes) .map_err(|e| WasmChannelError::InvalidCapabilities(e.to_string()))?; cap_file.validate(); // Debug: log raw capabilities tracing::debug!( channel = name, raw_capabilities = ?cap_file.capabilities, "Parsed capabilities file" ); // Check WIT version compatibility crate::tools::wasm::loader::check_wit_version_compat( name, cap_file.wit_version.as_deref(), crate::tools::wasm::WIT_CHANNEL_VERSION, ) .map_err(|e| WasmChannelError::IncompatibleWitVersion(e.to_string()))?; let caps = cap_file.to_capabilities(); // Debug: log resulting capabilities tracing::info!( channel = name, http_allowed = caps.tool_capabilities.http.is_some(), http_allowlist_count = caps .tool_capabilities .http .as_ref() .map(|h| h.allowlist.len()) .unwrap_or(0), "Channel capabilities loaded" ); let config = cap_file.config_json(); let desc = cap_file.description.clone(); (caps, config, desc, Some(cap_file)) } else { tracing::warn!( path = %cap_path.display(), "Capabilities file not found, using defaults" ); ( ChannelCapabilities::for_channel(name), "{}".to_string(), None, None, ) } } else { ( ChannelCapabilities::for_channel(name), "{}".to_string(), None, None, ) }; // Prepare the module let prepared = self .runtime .prepare(name, &wasm_bytes, None, description) .await?; // Create the channel let mut channel = WasmChannel::new( self.runtime.clone(), prepared, capabilities, self.owner_scope_id.clone(), config_json, self.pairing_store.clone(), self.settings_store.clone(), ); if let Some(ref secrets) = self.secrets_store { channel = channel.with_secrets_store(Arc::clone(secrets)); } tracing::info!( name = name, wasm_path = %wasm_path.display(), "Loaded WASM channel from file" ); Ok(LoadedChannel { channel, capabilities_file: cap_file, }) } /// Load all WASM channels from a directory. /// /// Scans the directory for `*.wasm` files and loads each one, looking for /// a matching `*.capabilities.json` sidecar file. /// /// # Directory Layout /// /// ```text /// channels/ /// ├── slack.wasm <- Channel WASM component /// ├── slack.capabilities.json <- Capabilities (optional) /// ├── telegram.wasm /// └── telegram.capabilities.json /// ``` pub async fn load_from_dir(&self, dir: &Path) -> Result { match fs::metadata(dir).await { Ok(meta) if meta.is_dir() => {} Ok(_) => { return Err(WasmChannelError::Io(std::io::Error::new( std::io::ErrorKind::NotADirectory, format!("{} is not a directory", dir.display()), ))); } Err(e) if e.kind() == std::io::ErrorKind::NotFound => { return Ok(LoadResults::default()); } Err(e) => return Err(WasmChannelError::Io(e)), } let mut results = LoadResults::default(); // Collect all .wasm entries first, then load in parallel let mut channel_entries = Vec::new(); // Handle TOCTOU: if read_dir fails with NotFound, treat as empty let mut entries = match fs::read_dir(dir).await { Ok(entries) => entries, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { return Ok(LoadResults::default()); } Err(e) => return Err(WasmChannelError::Io(e)), }; while let Some(entry) = entries.next_entry().await? { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("wasm") { continue; } let name = match path.file_stem().and_then(|s| s.to_str()) { Some(n) => n.to_string(), None => { results.errors.push(( path.clone(), WasmChannelError::InvalidName("invalid filename".to_string()), )); continue; } }; let cap_path = path.with_extension("capabilities.json"); let has_cap = cap_path.exists(); channel_entries.push((name, path, if has_cap { Some(cap_path) } else { None })); } // Load all channels in parallel (file I/O + WASM compilation) let load_futures = channel_entries .iter() .map(|(name, path, cap_path)| self.load_from_files(name, path, cap_path.as_deref())); let load_results = futures::future::join_all(load_futures).await; for ((name, path, _), result) in channel_entries.into_iter().zip(load_results) { match result { Ok(loaded) => { results.loaded.push(loaded); } Err(e) => { tracing::error!( name = name, path = %path.display(), error = %e, "Failed to load WASM channel" ); results.errors.push((path, e)); } } } if !results.loaded.is_empty() { tracing::info!( count = results.loaded.len(), channels = ?results.loaded.iter().map(|c| c.name()).collect::>(), "Loaded WASM channels from directory" ); } Ok(results) } } /// A loaded WASM channel with its capabilities file. pub struct LoadedChannel { /// The loaded channel. pub channel: WasmChannel, /// The parsed capabilities file (if present). pub capabilities_file: Option, } impl LoadedChannel { /// Get the channel name. pub fn name(&self) -> &str { self.channel.channel_name() } /// Get the webhook secret header name from capabilities. pub fn webhook_secret_header(&self) -> Option<&str> { self.capabilities_file .as_ref() .and_then(|f| f.webhook_secret_header()) } /// Get the signature verification key secret name from capabilities. pub fn signature_key_secret_name(&self) -> Option { self.capabilities_file .as_ref() .and_then(|f| f.signature_key_secret_name().map(|s| s.to_string())) } /// Get the HMAC-SHA256 signing secret name from capabilities. pub fn hmac_secret_name(&self) -> Option { self.capabilities_file .as_ref() .and_then(|f| f.hmac_secret_name().map(|s| s.to_string())) } /// Get the webhook secret name from capabilities. pub fn webhook_secret_name(&self) -> String { self.capabilities_file .as_ref() .map(|f| f.webhook_secret_name()) .unwrap_or_else(|| format!("{}_webhook_secret", self.channel.channel_name())) } /// Whether the host should enforce generic webhook-secret validation. pub fn webhook_secret_managed_by_host(&self) -> bool { self.capabilities_file .as_ref() .map(|f| f.webhook_secret_managed_by_host()) .unwrap_or(true) } } /// Results from loading multiple channels. #[derive(Default)] pub struct LoadResults { /// Successfully loaded channels with their capabilities. pub loaded: Vec, /// Errors encountered (path, error). pub errors: Vec<(PathBuf, WasmChannelError)>, } impl LoadResults { /// Check if all channels loaded successfully. pub fn all_succeeded(&self) -> bool { self.errors.is_empty() } /// Get the count of successfully loaded channels. pub fn success_count(&self) -> usize { self.loaded.len() } /// Get the count of failed channels. pub fn error_count(&self) -> usize { self.errors.len() } /// Take ownership of loaded channels (extracts just the WasmChannel). pub fn take_channels(self) -> Vec { self.loaded.into_iter().map(|l| l.channel).collect() } } /// Discover WASM channel files in a directory without loading them. /// /// Returns a map of channel name -> (wasm_path, capabilities_path). #[allow(dead_code)] pub async fn discover_channels( dir: &Path, ) -> Result, std::io::Error> { let mut channels = HashMap::new(); if !dir.is_dir() { return Ok(channels); } let mut entries = fs::read_dir(dir).await?; while let Some(entry) = entries.next_entry().await? { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("wasm") { continue; } let name = match path.file_stem().and_then(|s| s.to_str()) { Some(n) => n.to_string(), None => continue, }; let cap_path = path.with_extension("capabilities.json"); channels.insert( name, DiscoveredChannel { wasm_path: path, capabilities_path: if cap_path.exists() { Some(cap_path) } else { None }, }, ); } Ok(channels) } /// A discovered WASM channel (not yet loaded). #[derive(Debug)] pub struct DiscoveredChannel { /// Path to the WASM file. pub wasm_path: PathBuf, /// Path to the capabilities file (if present). pub capabilities_path: Option, } /// Get the default channels directory path. /// /// Returns ~/.ironclaw/channels/ #[allow(dead_code)] pub fn default_channels_dir() -> PathBuf { ironclaw_base_dir().join("channels") } #[cfg(test)] mod tests { use std::io::Write; use tempfile::TempDir; use crate::channels::wasm::loader::{WasmChannelLoader, discover_channels}; use crate::channels::wasm::runtime::{WasmChannelRuntime, WasmChannelRuntimeConfig}; use crate::pairing::PairingStore; use std::sync::Arc; #[tokio::test] async fn test_discover_channels_empty_dir() { let dir = TempDir::new().unwrap(); let channels = discover_channels(dir.path()).await.unwrap(); assert!(channels.is_empty()); } #[tokio::test] async fn test_discover_channels_with_wasm() { let dir = TempDir::new().unwrap(); // Create a fake .wasm file let wasm_path = dir.path().join("slack.wasm"); std::fs::File::create(&wasm_path).unwrap(); let channels = discover_channels(dir.path()).await.unwrap(); assert_eq!(channels.len(), 1); assert!(channels.contains_key("slack")); assert!(channels["slack"].capabilities_path.is_none()); } #[tokio::test] async fn test_discover_channels_with_capabilities() { let dir = TempDir::new().unwrap(); // Create wasm and capabilities files std::fs::File::create(dir.path().join("telegram.wasm")).unwrap(); let mut cap_file = std::fs::File::create(dir.path().join("telegram.capabilities.json")).unwrap(); cap_file.write_all(b"{}").unwrap(); let channels = discover_channels(dir.path()).await.unwrap(); assert_eq!(channels.len(), 1); assert!(channels["telegram"].capabilities_path.is_some()); } #[tokio::test] async fn test_discover_channels_ignores_non_wasm() { let dir = TempDir::new().unwrap(); // Create non-wasm files std::fs::File::create(dir.path().join("readme.md")).unwrap(); std::fs::File::create(dir.path().join("config.json")).unwrap(); std::fs::File::create(dir.path().join("channel.wasm")).unwrap(); let channels = discover_channels(dir.path()).await.unwrap(); assert_eq!(channels.len(), 1); assert!(channels.contains_key("channel")); } #[test] fn test_loaded_channel_signature_key_none_without_caps() { // We can't easily construct a WasmChannel without a runtime, so test // the delegation logic directly: when capabilities_file is None, the // chain returns None (same logic as LoadedChannel::signature_key_secret_name). let cap_file: Option = None; let result = cap_file .as_ref() .and_then(|f| f.signature_key_secret_name().map(|s| s.to_string())); assert_eq!(result, None); } #[tokio::test] async fn test_loader_invalid_name() { let config = WasmChannelRuntimeConfig::for_testing(); let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap()); let loader = WasmChannelLoader::new(runtime, Arc::new(PairingStore::new()), None, "default"); let dir = TempDir::new().unwrap(); let wasm_path = dir.path().join("test.wasm"); // Invalid name with path separator let result = loader.load_from_files("../escape", &wasm_path, None).await; assert!(result.is_err()); // Empty name let result = loader.load_from_files("", &wasm_path, None).await; assert!(result.is_err()); } #[tokio::test] async fn load_from_dir_returns_empty_when_dir_missing() { let config = WasmChannelRuntimeConfig::for_testing(); let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap()); let loader = WasmChannelLoader::new(runtime, Arc::new(PairingStore::new()), None, "default"); let dir = TempDir::new().unwrap(); let missing = dir.path().join("nonexistent_channels_dir"); let results = loader.load_from_dir(&missing).await; // Must succeed with empty results, not error let results = results.expect("missing dir should return Ok, not Err"); assert!(results.loaded.is_empty()); assert!(results.errors.is_empty()); } }