use std::path::Path;
use std::sync::Arc;
use diaryx_core::fs::{RealFileSystem, SyncToAsyncFs};
use diaryx_core::plugin::Plugin;
use extism::{Manifest as ExtismManifest, PluginBuilder, UserData, Wasm};
use thiserror::Error;
use crate::adapter::ExtismPluginAdapter;
use crate::host_fns::{self, HostContext};
use crate::platform_wasmtime_config;
use crate::protocol::{CURRENT_PROTOCOL_VERSION, GuestManifest, MIN_SUPPORTED_PROTOCOL_VERSION};
#[derive(Debug, Error)]
pub enum ExtismLoadError {
#[error("Failed to read plugins directory: {0}")]
ReadDir(#[from] std::io::Error),
#[error("Failed to create Extism plugin '{plugin_name}': {source}")]
PluginCreate {
plugin_name: String,
source: extism::Error,
},
#[error("Failed to get manifest from plugin '{plugin_name}': {source}")]
ManifestCall {
plugin_name: String,
source: extism::Error,
},
#[error("Failed to parse manifest from plugin '{plugin_name}': {source}")]
ManifestParse {
plugin_name: String,
source: serde_json::Error,
},
#[error(
"Protocol version mismatch for plugin '{plugin_name}': \
guest has v{guest_version}, host supports v{min}..=v{max}"
)]
ProtocolMismatch {
plugin_name: String,
guest_version: u32,
min: u32,
max: u32,
},
#[error(
"Plugin '{plugin_name}' requires Diaryx v{required} or later, \
but this is v{running}"
)]
AppVersionTooOld {
plugin_name: String,
required: String,
running: String,
},
}
fn validate_protocol_version(
manifest: &GuestManifest,
plugin_name: &str,
) -> Result<(), ExtismLoadError> {
let v = manifest.protocol_version;
if v < MIN_SUPPORTED_PROTOCOL_VERSION || v > CURRENT_PROTOCOL_VERSION {
return Err(ExtismLoadError::ProtocolMismatch {
plugin_name: plugin_name.to_string(),
guest_version: v,
min: MIN_SUPPORTED_PROTOCOL_VERSION,
max: CURRENT_PROTOCOL_VERSION,
});
}
Ok(())
}
fn parse_version(v: &str) -> Option<(u32, u32, u32)> {
let mut parts = v.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
Some((major, minor, patch))
}
fn validate_app_version(
manifest: &GuestManifest,
plugin_name: &str,
) -> Result<(), ExtismLoadError> {
let required = match &manifest.min_app_version {
Some(v) => v,
None => return Ok(()),
};
let running = env!("CARGO_PKG_VERSION");
match (parse_version(required), parse_version(running)) {
(Some(req), Some(cur)) if cur >= req => Ok(()),
_ => Err(ExtismLoadError::AppVersionTooOld {
plugin_name: plugin_name.to_string(),
required: required.clone(),
running: running.to_string(),
}),
}
}
fn parse_guest_manifest(
plugin: &mut extism::Plugin,
plugin_name: &str,
) -> Result<GuestManifest, ExtismLoadError> {
let output =
plugin
.call::<&str, &[u8]>("manifest", "")
.map_err(|e| ExtismLoadError::ManifestCall {
plugin_name: plugin_name.to_string(),
source: e,
})?;
let output_str = String::from_utf8_lossy(output);
serde_json::from_str::<GuestManifest>(&output_str).map_err(|e| ExtismLoadError::ManifestParse {
plugin_name: plugin_name.to_string(),
source: e,
})
}
pub fn inspect_plugin_wasm_manifest(wasm_path: &Path) -> Result<GuestManifest, ExtismLoadError> {
let plugin_name = wasm_path
.file_stem()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".into());
let wasm = Wasm::file(wasm_path);
let extism_manifest = ExtismManifest::new([wasm]);
let fs = Arc::new(SyncToAsyncFs::new(RealFileSystem));
let user_data = UserData::new(HostContext {
plugin_id: plugin_name.clone(),
..HostContext::with_fs(fs)
});
let mut builder = PluginBuilder::new(extism_manifest).with_wasi(true);
if let Some(config) = platform_wasmtime_config() {
builder = builder.with_wasmtime_config(config);
}
let builder = host_fns::register_host_functions(builder, user_data);
let mut plugin = builder.build().map_err(|e| ExtismLoadError::PluginCreate {
plugin_name: plugin_name.clone(),
source: e,
})?;
let manifest = parse_guest_manifest(&mut plugin, &plugin_name)?;
if let Err(e) = validate_protocol_version(&manifest, &plugin_name) {
log::warn!("{e}");
}
if let Err(e) = validate_app_version(&manifest, &plugin_name) {
log::warn!("{e}");
}
Ok(manifest)
}
pub fn load_plugins_from_dir(
plugins_dir: &Path,
host_context: Arc<HostContext>,
) -> Result<Vec<ExtismPluginAdapter>, ExtismLoadError> {
let mut adapters = Vec::new();
let entries = std::fs::read_dir(plugins_dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let wasm_path = path.join("plugin.wasm");
if !wasm_path.exists() {
continue;
}
let plugin_name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".into());
match load_single_plugin(&path, &wasm_path, &plugin_name, &host_context) {
Ok(adapter) => {
log::info!(
"Loaded extism plugin: {} ({})",
adapter.manifest().name,
adapter.manifest().id
);
adapters.push(adapter);
}
Err(e) => {
log::warn!("Failed to load plugin from {}: {e}", path.display());
}
}
}
Ok(adapters)
}
pub fn load_plugin_from_wasm(
wasm_path: &Path,
host_context: Arc<HostContext>,
config_path: Option<&Path>,
) -> Result<ExtismPluginAdapter, ExtismLoadError> {
let plugin_name = wasm_path
.file_stem()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".into());
let wasm = Wasm::file(wasm_path);
let extism_manifest = ExtismManifest::new([wasm]);
let user_data = UserData::new(HostContext {
fs: host_context.fs.clone(),
storage: host_context.storage.clone(),
secret_store: host_context.secret_store.clone(),
event_emitter: host_context.event_emitter.clone(),
plugin_id: plugin_name.clone(),
permission_checker: host_context.permission_checker.clone(),
file_provider: host_context.file_provider.clone(),
ws_bridge: host_context.ws_bridge.clone(),
plugin_command_bridge: host_context.plugin_command_bridge.clone(),
runtime_context_provider: host_context.runtime_context_provider.clone(),
namespace_provider: host_context.namespace_provider.clone(),
});
let mut builder = PluginBuilder::new(extism_manifest).with_wasi(true);
if let Some(config) = platform_wasmtime_config() {
builder = builder.with_wasmtime_config(config);
}
let builder = host_fns::register_host_functions(builder, user_data.clone());
let mut plugin = builder.build().map_err(|e| ExtismLoadError::PluginCreate {
plugin_name: plugin_name.clone(),
source: e,
})?;
let guest_manifest = parse_guest_manifest(&mut plugin, &plugin_name)?;
validate_protocol_version(&guest_manifest, &plugin_name)?;
validate_app_version(&guest_manifest, &plugin_name)?;
if let Ok(ctx) = user_data.get()
&& let Ok(mut guard) = ctx.lock()
{
guard.plugin_id = guest_manifest.id.clone();
}
let manifest_path = wasm_path
.parent()
.unwrap_or(Path::new("."))
.join("manifest.json");
cache_manifest(&manifest_path, &guest_manifest);
let cfg_path = config_path.map(|p| p.to_path_buf()).unwrap_or_else(|| {
wasm_path
.parent()
.unwrap_or(Path::new("."))
.join("config.json")
});
let config = if cfg_path.exists() {
let json = std::fs::read_to_string(&cfg_path).map_err(ExtismLoadError::ReadDir)?;
serde_json::from_str(&json).unwrap_or(serde_json::Value::Object(Default::default()))
} else {
serde_json::Value::Object(Default::default())
};
Ok(ExtismPluginAdapter::new(
plugin,
guest_manifest,
config,
cfg_path,
))
}
fn load_single_plugin(
plugin_dir: &Path,
wasm_path: &Path,
plugin_name: &str,
host_context: &Arc<HostContext>,
) -> Result<ExtismPluginAdapter, ExtismLoadError> {
let wasm = Wasm::file(wasm_path);
let extism_manifest = ExtismManifest::new([wasm]);
let user_data = UserData::new(HostContext {
fs: host_context.fs.clone(),
storage: host_context.storage.clone(),
secret_store: host_context.secret_store.clone(),
event_emitter: host_context.event_emitter.clone(),
plugin_id: plugin_name.to_string(),
permission_checker: host_context.permission_checker.clone(),
file_provider: host_context.file_provider.clone(),
ws_bridge: host_context.ws_bridge.clone(),
plugin_command_bridge: host_context.plugin_command_bridge.clone(),
runtime_context_provider: host_context.runtime_context_provider.clone(),
namespace_provider: host_context.namespace_provider.clone(),
});
let mut builder = PluginBuilder::new(extism_manifest).with_wasi(true);
if let Some(config) = platform_wasmtime_config() {
builder = builder.with_wasmtime_config(config);
}
let builder = host_fns::register_host_functions(builder, user_data.clone());
let mut plugin = builder.build().map_err(|e| ExtismLoadError::PluginCreate {
plugin_name: plugin_name.into(),
source: e,
})?;
let manifest_path = plugin_dir.join("manifest.json");
let guest_manifest = if manifest_path.exists() {
let json = std::fs::read_to_string(&manifest_path).map_err(ExtismLoadError::ReadDir)?;
serde_json::from_str::<GuestManifest>(&json).map_err(|e| {
ExtismLoadError::ManifestParse {
plugin_name: plugin_name.into(),
source: e,
}
})?
} else {
let gm = parse_guest_manifest(&mut plugin, plugin_name)?;
cache_manifest(&manifest_path, &gm);
gm
};
validate_protocol_version(&guest_manifest, plugin_name)?;
validate_app_version(&guest_manifest, plugin_name)?;
if let Ok(ctx) = user_data.get()
&& let Ok(mut guard) = ctx.lock()
{
guard.plugin_id = guest_manifest.id.clone();
}
let config_path = plugin_dir.join("config.json");
let config = if config_path.exists() {
let json = std::fs::read_to_string(&config_path).map_err(ExtismLoadError::ReadDir)?;
serde_json::from_str(&json).unwrap_or(serde_json::Value::Object(Default::default()))
} else {
serde_json::Value::Object(Default::default())
};
Ok(ExtismPluginAdapter::new(
plugin,
guest_manifest,
config,
config_path,
))
}
fn cache_manifest(path: &Path, manifest: &GuestManifest) {
match serde_json::to_string_pretty(manifest) {
Ok(json) => {
if let Err(e) = std::fs::write(path, json) {
log::debug!("Could not cache manifest to {}: {e}", path.display());
}
}
Err(e) => {
log::debug!("Could not serialize manifest for caching: {e}");
}
}
}