1use serde::{Deserialize, Serialize};
21use std::collections::HashSet;
22use std::path::Path;
23use thiserror::Error;
24
25#[derive(Debug, Error)]
27pub enum ManifestError {
28 #[error("Manifest IO error: {0}")]
29 Io(#[from] std::io::Error),
30
31 #[error("Manifest JSON error: {0}")]
32 Json(#[from] serde_json::Error),
33
34 #[error("Plugin '{plugin}' targets SDK API {required} which is incompatible with this SDK ({sdk})")]
35 IncompatibleSdkVersion {
36 plugin: String,
37 required: String,
38 sdk: String,
39 },
40
41 #[error("Plugin '{plugin}' missing capability for '{feature}'. Add it to capabilities in the manifest.")]
42 MissingCapability { plugin: String, feature: String },
43
44 #[error("Plugin '{plugin}' declares hook '{hook}' in code but not in manifest.hooks")]
45 HookNotDeclared { plugin: String, hook: String },
46}
47
48pub type ManifestResult<T> = Result<T, ManifestError>;
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct PluginManifest {
57 pub name: String,
59 pub version: String,
61 pub description: String,
63 pub author: String,
65 pub license: String,
67 #[serde(default)]
69 pub plugin_type: String,
70 #[serde(default = "default_runtime")]
72 pub runtime: String,
73 #[serde(default = "default_sdk_constraint")]
76 pub sdk_version: String,
77 #[serde(default)]
80 pub hooks: Vec<String>,
81 #[serde(default)]
84 pub config_schema: Option<serde_json::Value>,
85 #[serde(default)]
88 pub capabilities: Option<Capabilities>,
89 #[serde(default)]
91 pub homepage: Option<String>,
92}
93
94fn default_runtime() -> String {
95 "lua".to_string()
96}
97fn default_sdk_constraint() -> String {
98 ">=0.1.0".to_string()
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize, Default)]
107pub struct Capabilities {
108 #[serde(default)]
110 pub network: bool,
111 #[serde(default)]
114 pub network_allow: Vec<String>,
115 #[serde(default = "default_true")]
117 pub storage: bool,
118 #[serde(default = "default_true")]
120 pub env: bool,
121 #[serde(default)]
123 pub sober: bool,
124 #[serde(default = "default_memory_mb")]
126 pub memory_mb: u32,
127 #[serde(default = "default_instructions")]
129 pub max_instructions: u64,
130 #[serde(default = "default_http_timeout")]
132 pub http_timeout_secs: u64,
133 #[serde(default)]
135 pub jobs: bool,
136 #[serde(default)]
138 pub jobs_cancel: bool,
139 #[serde(default)]
141 pub commands_alias: bool,
142 #[serde(default)]
144 pub events_subscribe: bool,
145 #[serde(default)]
147 pub ui_modal: bool,
148 #[serde(default)]
150 pub activation_auto: bool,
151}
152
153fn default_true() -> bool {
154 true
155}
156fn default_memory_mb() -> u32 {
157 64
158}
159fn default_instructions() -> u64 {
160 10_000_000
161}
162fn default_http_timeout() -> u64 {
163 5
164}
165
166impl Capabilities {
167 pub fn legacy_default() -> Self {
171 Self {
172 network: false,
173 network_allow: Vec::new(),
174 storage: true,
175 env: true,
176 sober: false,
177 memory_mb: default_memory_mb(),
178 max_instructions: default_instructions(),
179 http_timeout_secs: default_http_timeout(),
180 jobs: false,
181 jobs_cancel: false,
182 commands_alias: false,
183 events_subscribe: false,
184 ui_modal: false,
185 activation_auto: false,
186 }
187 }
188
189 pub fn host_allowed(&self, host: &str) -> bool {
193 if !self.network {
194 return false;
195 }
196 if self.network_allow.is_empty() {
197 return true;
198 }
199 let h = host.to_lowercase();
200 self.network_allow
201 .iter()
202 .any(|allowed| h == allowed.to_lowercase() || h.ends_with(&format!(".{}", allowed.to_lowercase())))
203 }
204}
205
206impl PluginManifest {
207 pub fn load(path: &Path) -> ManifestResult<Self> {
209 let content = std::fs::read_to_string(path)?;
210 Self::from_str(&content)
211 }
212
213 pub fn from_str(json: &str) -> ManifestResult<Self> {
215 let m: PluginManifest = serde_json::from_str(json)?;
216 Ok(m)
217 }
218
219 pub fn effective_capabilities(&self) -> Capabilities {
222 self.capabilities.clone().unwrap_or_else(Capabilities::legacy_default)
223 }
224
225 pub fn capabilities_implicit(&self) -> bool {
228 self.capabilities.is_none()
229 }
230
231 pub fn check_sdk_compat(&self, sdk_api_version: &str) -> ManifestResult<()> {
234 if SdkVersion::parse(sdk_api_version).matches(&self.sdk_version) {
235 Ok(())
236 } else {
237 Err(ManifestError::IncompatibleSdkVersion {
238 plugin: self.name.clone(),
239 required: self.sdk_version.clone(),
240 sdk: sdk_api_version.to_string(),
241 })
242 }
243 }
244
245 pub fn check_hooks(&self, observed: &[&str]) -> ManifestResult<()> {
249 let declared: HashSet<&str> = self.hooks.iter().map(|s| s.as_str()).collect();
250 for h in observed {
251 if !declared.contains(*h) {
252 return Err(ManifestError::HookNotDeclared {
253 plugin: self.name.clone(),
254 hook: (*h).to_string(),
255 });
256 }
257 }
258 Ok(())
259 }
260}
261
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
267pub struct SdkVersion {
268 pub major: u32,
269 pub minor: u32,
270}
271
272impl SdkVersion {
273 pub fn parse(s: &str) -> Self {
275 let mut parts = s.trim().split('.');
276 let major = parts.next().and_then(|n| n.parse().ok()).unwrap_or(0);
277 let minor = parts.next().and_then(|n| n.parse().ok()).unwrap_or(0);
278 Self { major, minor }
279 }
280
281 pub fn matches(&self, constraint: &str) -> bool {
289 let c = constraint.trim();
290 if let Some(rest) = c.strip_prefix(">=") {
291 let want = Self::parse(rest);
292 return self.major > want.major
293 || (self.major == want.major && self.minor >= want.minor);
294 }
295 if let Some(rest) = c.strip_prefix('^') {
296 let want = Self::parse(rest);
297 return self.major == want.major && self.minor >= want.minor;
298 }
299 let want = Self::parse(c);
300 self.major == want.major && self.minor >= want.minor
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 #[test]
309 fn parses_minimal_v01_manifest() {
310 let json = r#"{
311 "name": "demo",
312 "version": "1.0.0",
313 "description": "x",
314 "author": "y",
315 "license": "Apache-2.0"
316 }"#;
317 let m = PluginManifest::from_str(json).unwrap();
318 assert_eq!(m.name, "demo");
319 assert_eq!(m.runtime, "lua");
320 assert_eq!(m.sdk_version, ">=0.1.0");
321 assert!(m.capabilities_implicit());
322 }
323
324 #[test]
325 fn sdk_version_constraint_matrix() {
326 let v02 = SdkVersion::parse("0.2");
327 assert!(v02.matches(">=0.1.0"));
328 assert!(v02.matches(">=0.2"));
329 assert!(v02.matches("^0.2"));
330 assert!(v02.matches("0.2"));
331 assert!(!v02.matches(">=0.3"));
332 assert!(!v02.matches("^0.3"));
333 assert!(!v02.matches("^1.0"));
335 }
336
337 #[test]
338 fn host_allowlist_suffix_match() {
339 let mut caps = Capabilities::legacy_default();
340 caps.network = true;
341 caps.network_allow = vec!["api.slack.com".into(), "hooks.example.org".into()];
342 assert!(caps.host_allowed("api.slack.com"));
343 assert!(caps.host_allowed("v2.api.slack.com"));
344 assert!(!caps.host_allowed("evil.com"));
345 assert!(!caps.host_allowed("slack.com")); }
347
348 #[test]
349 fn legacy_default_denies_network() {
350 let caps = Capabilities::legacy_default();
351 assert!(!caps.host_allowed("api.slack.com"));
352 assert!(!caps.sober);
353 }
354
355 #[test]
356 fn rejects_major_mismatch() {
357 let json = r#"{
358 "name": "future",
359 "version": "1.0.0",
360 "description": "x",
361 "author": "y",
362 "license": "MIT",
363 "sdk_version": "^1.0"
364 }"#;
365 let m = PluginManifest::from_str(json).unwrap();
366 assert!(m.check_sdk_compat("0.2").is_err());
367 }
368}