use serde::Deserialize;
use crate::error::WasmError;
#[derive(Debug, Clone, Deserialize)]
pub struct PluginManifest {
pub plugin: PluginMeta,
pub capabilities: Capabilities,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PluginMeta {
pub name: String,
pub version: String,
#[serde(rename = "type")]
pub plugin_type: PluginType,
pub description: Option<String>,
pub wasm: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PluginType {
Middleware,
Dispatcher,
}
impl PluginType {
pub fn required_exports(&self) -> &'static [&'static str] {
match self {
PluginType::Middleware => &["init", "on_request", "on_response"],
PluginType::Dispatcher => &["init", "dispatch"],
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Capabilities {
#[serde(default)]
pub host_functions: Vec<String>,
#[serde(default)]
pub body_access: bool,
}
impl PluginManifest {
pub fn from_toml(content: &str) -> Result<Self, WasmError> {
let manifest: Self = toml::from_str(content)?;
manifest.validate()?;
Ok(manifest)
}
fn validate(&self) -> Result<(), WasmError> {
if self.plugin.name.is_empty() || self.plugin.name.len() > 64 {
return Err(WasmError::ManifestValidation(
"plugin name must be 1-64 characters".into(),
));
}
let name_regex = regex_lite::Regex::new(r"^[a-z][a-z0-9-]*$").expect("static regex");
if !name_regex.is_match(&self.plugin.name) {
return Err(WasmError::ManifestValidation(
"plugin name must be lowercase, kebab-case (^[a-z][a-z0-9-]*$)".into(),
));
}
if semver::Version::parse(&self.plugin.version).is_err() {
return Err(WasmError::ManifestValidation(format!(
"invalid semver version: {}",
self.plugin.version
)));
}
if let Some(desc) = &self.plugin.description {
if desc.len() > 256 {
return Err(WasmError::ManifestValidation(
"description must be at most 256 characters".into(),
));
}
}
if self.plugin.wasm.is_empty() {
return Err(WasmError::ManifestValidation(
"wasm path cannot be empty".into(),
));
}
for func in &self.capabilities.host_functions {
if !is_known_capability(func) {
return Err(WasmError::UnknownCapability(func.clone()));
}
}
Ok(())
}
pub fn has_capability(&self, capability: &str) -> bool {
self.capabilities
.host_functions
.iter()
.any(|c| c == capability)
}
}
const KNOWN_CAPABILITIES: &[&str] = &[
"log",
"context_get",
"context_set",
"clock_now",
"get_secret",
"http_call",
"kafka_publish",
"nats_publish",
"telemetry",
"generate_uuid",
"verify_signature",
"ws_upgrade",
];
fn is_known_capability(name: &str) -> bool {
KNOWN_CAPABILITIES.contains(&name)
}
pub fn capability_to_imports(capability: &str) -> &'static [&'static str] {
match capability {
"log" => &["host_log"],
"context_get" => &["host_context_get", "host_context_read_result"],
"context_set" => &["host_context_set"],
"clock_now" => &["host_clock_now"],
"get_secret" => &["host_get_secret", "host_secret_read_result"],
"http_call" => &["host_http_call", "host_http_read_result"],
"kafka_publish" => &["host_kafka_publish"],
"nats_publish" => &["host_nats_publish"],
"telemetry" => &[
"host_metric_counter_inc",
"host_metric_histogram_observe",
"host_span_start",
"host_span_end",
"host_span_set_attribute",
],
"generate_uuid" => &["host_uuid_generate", "host_uuid_read_result"],
"verify_signature" => &["host_verify_signature"],
"ws_upgrade" => &["host_ws_upgrade", "host_http_read_result"],
_ => &[],
}
}
#[cfg(test)]
mod tests {
use super::*;
const VALID_MANIFEST: &str = r#"
[plugin]
name = "my-plugin"
version = "1.0.0"
type = "middleware"
description = "A test plugin"
wasm = "my_plugin.wasm"
[capabilities]
host_functions = ["log", "context_get"]
"#;
#[test]
fn parse_valid_manifest() {
let manifest = PluginManifest::from_toml(VALID_MANIFEST).unwrap();
assert_eq!(manifest.plugin.name, "my-plugin");
assert_eq!(manifest.plugin.version, "1.0.0");
assert_eq!(manifest.plugin.plugin_type, PluginType::Middleware);
assert_eq!(manifest.plugin.description, Some("A test plugin".into()));
assert_eq!(manifest.plugin.wasm, "my_plugin.wasm");
assert_eq!(manifest.capabilities.host_functions.len(), 2);
}
#[test]
fn parse_dispatcher_manifest() {
let manifest_str = r#"
[plugin]
name = "http-upstream"
version = "2.0.0"
type = "dispatcher"
wasm = "http_upstream.wasm"
[capabilities]
host_functions = ["http_call", "log"]
"#;
let manifest = PluginManifest::from_toml(manifest_str).unwrap();
assert_eq!(manifest.plugin.plugin_type, PluginType::Dispatcher);
}
#[test]
fn reject_invalid_name() {
let manifest_str = r#"
[plugin]
name = "MyPlugin"
version = "1.0.0"
type = "middleware"
wasm = "my_plugin.wasm"
[capabilities]
host_functions = []
"#;
let result = PluginManifest::from_toml(manifest_str);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("kebab-case"));
}
#[test]
fn reject_invalid_version() {
let manifest_str = r#"
[plugin]
name = "my-plugin"
version = "not-semver"
type = "middleware"
wasm = "my_plugin.wasm"
[capabilities]
host_functions = []
"#;
let result = PluginManifest::from_toml(manifest_str);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("semver"));
}
#[test]
fn reject_unknown_capability() {
let manifest_str = r#"
[plugin]
name = "my-plugin"
version = "1.0.0"
type = "middleware"
wasm = "my_plugin.wasm"
[capabilities]
host_functions = ["unknown_function"]
"#;
let result = PluginManifest::from_toml(manifest_str);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
WasmError::UnknownCapability(_)
));
}
#[test]
fn has_capability() {
let manifest = PluginManifest::from_toml(VALID_MANIFEST).unwrap();
assert!(manifest.has_capability("log"));
assert!(manifest.has_capability("context_get"));
assert!(!manifest.has_capability("http_call"));
}
#[test]
fn required_exports_middleware() {
let exports = PluginType::Middleware.required_exports();
assert!(exports.contains(&"init"));
assert!(exports.contains(&"on_request"));
assert!(exports.contains(&"on_response"));
}
#[test]
fn parse_ws_upgrade_capability() {
let manifest_str = r#"
[plugin]
name = "ws-upstream"
version = "0.1.0"
type = "dispatcher"
wasm = "ws_upstream.wasm"
[capabilities]
host_functions = ["ws_upgrade", "log"]
"#;
let manifest = PluginManifest::from_toml(manifest_str).unwrap();
assert!(manifest.has_capability("ws_upgrade"));
assert!(manifest.has_capability("log"));
assert_eq!(
capability_to_imports("ws_upgrade"),
&["host_ws_upgrade", "host_http_read_result"]
);
}
#[test]
fn required_exports_dispatcher() {
let exports = PluginType::Dispatcher.required_exports();
assert!(exports.contains(&"init"));
assert!(exports.contains(&"dispatch"));
}
#[test]
fn body_access_defaults_to_false() {
let manifest = PluginManifest::from_toml(VALID_MANIFEST).unwrap();
assert!(!manifest.capabilities.body_access);
}
#[test]
fn body_access_true_parses() {
let manifest_str = r#"
[plugin]
name = "request-transformer"
version = "1.0.0"
type = "middleware"
wasm = "request_transformer.wasm"
[capabilities]
host_functions = ["log"]
body_access = true
"#;
let manifest = PluginManifest::from_toml(manifest_str).unwrap();
assert!(manifest.capabilities.body_access);
}
}