barbacane_wasm/
manifest.rs1use serde::Deserialize;
9
10use crate::error::WasmError;
11
12#[derive(Debug, Clone, Deserialize)]
14pub struct PluginManifest {
15 pub plugin: PluginMeta,
17
18 pub capabilities: Capabilities,
20}
21
22#[derive(Debug, Clone, Deserialize)]
24pub struct PluginMeta {
25 pub name: String,
27
28 pub version: String,
30
31 #[serde(rename = "type")]
33 pub plugin_type: PluginType,
34
35 pub description: Option<String>,
37
38 pub wasm: String,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
44#[serde(rename_all = "lowercase")]
45pub enum PluginType {
46 Middleware,
48
49 Dispatcher,
51}
52
53impl PluginType {
54 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#[derive(Debug, Clone, Deserialize)]
65pub struct Capabilities {
66 #[serde(default)]
68 pub host_functions: Vec<String>,
69
70 #[serde(default)]
73 pub body_access: bool,
74}
75
76impl PluginManifest {
77 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 fn validate(&self) -> Result<(), WasmError> {
86 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 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 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 if self.plugin.wasm.is_empty() {
119 return Err(WasmError::ManifestValidation(
120 "wasm path cannot be empty".into(),
121 ));
122 }
123
124 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 pub fn has_capability(&self, capability: &str) -> bool {
136 self.capabilities
137 .host_functions
138 .iter()
139 .any(|c| c == capability)
140 }
141}
142
143const 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
159fn is_known_capability(name: &str) -> bool {
161 KNOWN_CAPABILITIES.contains(&name)
162}
163
164pub 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}