Skip to main content

barbacane_wasm/
manifest.rs

1//! Plugin manifest (plugin.toml) parsing and validation.
2//!
3//! Per SPEC-003 section 2.1, the plugin manifest defines:
4//! - Plugin metadata (name, version, type, description)
5//! - WASM binary path
6//! - Required capabilities (host functions)
7
8use serde::Deserialize;
9
10use crate::error::WasmError;
11
12/// A parsed and validated plugin manifest.
13#[derive(Debug, Clone, Deserialize)]
14pub struct PluginManifest {
15    /// Plugin metadata.
16    pub plugin: PluginMeta,
17
18    /// Plugin capabilities.
19    pub capabilities: Capabilities,
20}
21
22/// Plugin metadata from the [plugin] section.
23#[derive(Debug, Clone, Deserialize)]
24pub struct PluginMeta {
25    /// Unique identifier, lowercase, kebab-case.
26    pub name: String,
27
28    /// Semantic version string.
29    pub version: String,
30
31    /// Plugin type: "middleware" or "dispatcher".
32    #[serde(rename = "type")]
33    pub plugin_type: PluginType,
34
35    /// Optional description for registry display.
36    pub description: Option<String>,
37
38    /// Path to WASM binary, relative to plugin.toml.
39    pub wasm: String,
40}
41
42/// Plugin type.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
44#[serde(rename_all = "lowercase")]
45pub enum PluginType {
46    /// Middleware plugin (on_request, on_response).
47    Middleware,
48
49    /// Dispatcher plugin (dispatch).
50    Dispatcher,
51}
52
53impl PluginType {
54    /// Get the required WASM exports for this plugin type.
55    pub fn required_exports(&self) -> &'static [&'static str] {
56        match self {
57            PluginType::Middleware => &["init", "on_request", "on_response"],
58            PluginType::Dispatcher => &["init", "dispatch"],
59        }
60    }
61}
62
63/// Plugin capabilities from the [capabilities] section.
64#[derive(Debug, Clone, Deserialize)]
65pub struct Capabilities {
66    /// List of host functions this plugin requires.
67    #[serde(default)]
68    pub host_functions: Vec<String>,
69
70    /// Whether the middleware receives the request body in `on_request`.
71    /// Always implicitly true for dispatchers. Defaults to false for middleware.
72    #[serde(default)]
73    pub body_access: bool,
74}
75
76impl PluginManifest {
77    /// Parse a plugin manifest from TOML content.
78    pub fn from_toml(content: &str) -> Result<Self, WasmError> {
79        let manifest: Self = toml::from_str(content)?;
80        manifest.validate()?;
81        Ok(manifest)
82    }
83
84    /// Validate the manifest fields.
85    fn validate(&self) -> Result<(), WasmError> {
86        // Validate name: ^[a-z][a-z0-9-]*$, max 64 chars
87        if self.plugin.name.is_empty() || self.plugin.name.len() > 64 {
88            return Err(WasmError::ManifestValidation(
89                "plugin name must be 1-64 characters".into(),
90            ));
91        }
92
93        let name_regex = regex_lite::Regex::new(r"^[a-z][a-z0-9-]*$").expect("static regex");
94        if !name_regex.is_match(&self.plugin.name) {
95            return Err(WasmError::ManifestValidation(
96                "plugin name must be lowercase, kebab-case (^[a-z][a-z0-9-]*$)".into(),
97            ));
98        }
99
100        // Validate version: valid semver
101        if semver::Version::parse(&self.plugin.version).is_err() {
102            return Err(WasmError::ManifestValidation(format!(
103                "invalid semver version: {}",
104                self.plugin.version
105            )));
106        }
107
108        // Validate description length
109        if let Some(desc) = &self.plugin.description {
110            if desc.len() > 256 {
111                return Err(WasmError::ManifestValidation(
112                    "description must be at most 256 characters".into(),
113                ));
114            }
115        }
116
117        // Validate wasm path is not empty
118        if self.plugin.wasm.is_empty() {
119            return Err(WasmError::ManifestValidation(
120                "wasm path cannot be empty".into(),
121            ));
122        }
123
124        // Validate host functions are known
125        for func in &self.capabilities.host_functions {
126            if !is_known_capability(func) {
127                return Err(WasmError::UnknownCapability(func.clone()));
128            }
129        }
130
131        Ok(())
132    }
133
134    /// Check if this plugin declares a specific capability.
135    pub fn has_capability(&self, capability: &str) -> bool {
136        self.capabilities
137            .host_functions
138            .iter()
139            .any(|c| c == capability)
140    }
141}
142
143/// Known host function capability names.
144const KNOWN_CAPABILITIES: &[&str] = &[
145    "log",
146    "context_get",
147    "context_set",
148    "clock_now",
149    "get_secret",
150    "http_call",
151    "kafka_publish",
152    "nats_publish",
153    "telemetry",
154    "generate_uuid",
155    "verify_signature",
156    "ws_upgrade",
157];
158
159/// Check if a capability name is known.
160fn is_known_capability(name: &str) -> bool {
161    KNOWN_CAPABILITIES.contains(&name)
162}
163
164/// Get the host function names for a capability.
165pub fn capability_to_imports(capability: &str) -> &'static [&'static str] {
166    match capability {
167        "log" => &["host_log"],
168        "context_get" => &["host_context_get", "host_context_read_result"],
169        "context_set" => &["host_context_set"],
170        "clock_now" => &["host_clock_now"],
171        "get_secret" => &["host_get_secret", "host_secret_read_result"],
172        "http_call" => &["host_http_call", "host_http_read_result"],
173        "kafka_publish" => &["host_kafka_publish"],
174        "nats_publish" => &["host_nats_publish"],
175        "telemetry" => &[
176            "host_metric_counter_inc",
177            "host_metric_histogram_observe",
178            "host_span_start",
179            "host_span_end",
180            "host_span_set_attribute",
181        ],
182        "generate_uuid" => &["host_uuid_generate", "host_uuid_read_result"],
183        "verify_signature" => &["host_verify_signature"],
184        "ws_upgrade" => &["host_ws_upgrade", "host_http_read_result"],
185        _ => &[],
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    const VALID_MANIFEST: &str = r#"
194[plugin]
195name = "my-plugin"
196version = "1.0.0"
197type = "middleware"
198description = "A test plugin"
199wasm = "my_plugin.wasm"
200
201[capabilities]
202host_functions = ["log", "context_get"]
203"#;
204
205    #[test]
206    fn parse_valid_manifest() {
207        let manifest = PluginManifest::from_toml(VALID_MANIFEST).unwrap();
208        assert_eq!(manifest.plugin.name, "my-plugin");
209        assert_eq!(manifest.plugin.version, "1.0.0");
210        assert_eq!(manifest.plugin.plugin_type, PluginType::Middleware);
211        assert_eq!(manifest.plugin.description, Some("A test plugin".into()));
212        assert_eq!(manifest.plugin.wasm, "my_plugin.wasm");
213        assert_eq!(manifest.capabilities.host_functions.len(), 2);
214    }
215
216    #[test]
217    fn parse_dispatcher_manifest() {
218        let manifest_str = r#"
219[plugin]
220name = "http-upstream"
221version = "2.0.0"
222type = "dispatcher"
223wasm = "http_upstream.wasm"
224
225[capabilities]
226host_functions = ["http_call", "log"]
227"#;
228        let manifest = PluginManifest::from_toml(manifest_str).unwrap();
229        assert_eq!(manifest.plugin.plugin_type, PluginType::Dispatcher);
230    }
231
232    #[test]
233    fn reject_invalid_name() {
234        let manifest_str = r#"
235[plugin]
236name = "MyPlugin"
237version = "1.0.0"
238type = "middleware"
239wasm = "my_plugin.wasm"
240
241[capabilities]
242host_functions = []
243"#;
244        let result = PluginManifest::from_toml(manifest_str);
245        assert!(result.is_err());
246        assert!(result.unwrap_err().to_string().contains("kebab-case"));
247    }
248
249    #[test]
250    fn reject_invalid_version() {
251        let manifest_str = r#"
252[plugin]
253name = "my-plugin"
254version = "not-semver"
255type = "middleware"
256wasm = "my_plugin.wasm"
257
258[capabilities]
259host_functions = []
260"#;
261        let result = PluginManifest::from_toml(manifest_str);
262        assert!(result.is_err());
263        assert!(result.unwrap_err().to_string().contains("semver"));
264    }
265
266    #[test]
267    fn reject_unknown_capability() {
268        let manifest_str = r#"
269[plugin]
270name = "my-plugin"
271version = "1.0.0"
272type = "middleware"
273wasm = "my_plugin.wasm"
274
275[capabilities]
276host_functions = ["unknown_function"]
277"#;
278        let result = PluginManifest::from_toml(manifest_str);
279        assert!(result.is_err());
280        assert!(matches!(
281            result.unwrap_err(),
282            WasmError::UnknownCapability(_)
283        ));
284    }
285
286    #[test]
287    fn has_capability() {
288        let manifest = PluginManifest::from_toml(VALID_MANIFEST).unwrap();
289        assert!(manifest.has_capability("log"));
290        assert!(manifest.has_capability("context_get"));
291        assert!(!manifest.has_capability("http_call"));
292    }
293
294    #[test]
295    fn required_exports_middleware() {
296        let exports = PluginType::Middleware.required_exports();
297        assert!(exports.contains(&"init"));
298        assert!(exports.contains(&"on_request"));
299        assert!(exports.contains(&"on_response"));
300    }
301
302    #[test]
303    fn parse_ws_upgrade_capability() {
304        let manifest_str = r#"
305[plugin]
306name = "ws-upstream"
307version = "0.1.0"
308type = "dispatcher"
309wasm = "ws_upstream.wasm"
310
311[capabilities]
312host_functions = ["ws_upgrade", "log"]
313"#;
314        let manifest = PluginManifest::from_toml(manifest_str).unwrap();
315        assert!(manifest.has_capability("ws_upgrade"));
316        assert!(manifest.has_capability("log"));
317        assert_eq!(
318            capability_to_imports("ws_upgrade"),
319            &["host_ws_upgrade", "host_http_read_result"]
320        );
321    }
322
323    #[test]
324    fn required_exports_dispatcher() {
325        let exports = PluginType::Dispatcher.required_exports();
326        assert!(exports.contains(&"init"));
327        assert!(exports.contains(&"dispatch"));
328    }
329
330    #[test]
331    fn body_access_defaults_to_false() {
332        let manifest = PluginManifest::from_toml(VALID_MANIFEST).unwrap();
333        assert!(!manifest.capabilities.body_access);
334    }
335
336    #[test]
337    fn body_access_true_parses() {
338        let manifest_str = r#"
339[plugin]
340name = "request-transformer"
341version = "1.0.0"
342type = "middleware"
343wasm = "request_transformer.wasm"
344
345[capabilities]
346host_functions = ["log"]
347body_access = true
348"#;
349        let manifest = PluginManifest::from_toml(manifest_str).unwrap();
350        assert!(manifest.capabilities.body_access);
351    }
352}