progit_plugin_sdk/
manifest.rs1use 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}
134
135fn default_true() -> bool {
136 true
137}
138fn default_memory_mb() -> u32 {
139 64
140}
141fn default_instructions() -> u64 {
142 10_000_000
143}
144fn default_http_timeout() -> u64 {
145 5
146}
147
148impl Capabilities {
149 pub fn legacy_default() -> Self {
153 Self {
154 network: false,
155 network_allow: Vec::new(),
156 storage: true,
157 env: true,
158 sober: false,
159 memory_mb: default_memory_mb(),
160 max_instructions: default_instructions(),
161 http_timeout_secs: default_http_timeout(),
162 }
163 }
164
165 pub fn host_allowed(&self, host: &str) -> bool {
169 if !self.network {
170 return false;
171 }
172 if self.network_allow.is_empty() {
173 return true;
174 }
175 let h = host.to_lowercase();
176 self.network_allow
177 .iter()
178 .any(|allowed| h == allowed.to_lowercase() || h.ends_with(&format!(".{}", allowed.to_lowercase())))
179 }
180}
181
182impl PluginManifest {
183 pub fn load(path: &Path) -> ManifestResult<Self> {
185 let content = std::fs::read_to_string(path)?;
186 Self::from_str(&content)
187 }
188
189 pub fn from_str(json: &str) -> ManifestResult<Self> {
191 let m: PluginManifest = serde_json::from_str(json)?;
192 Ok(m)
193 }
194
195 pub fn effective_capabilities(&self) -> Capabilities {
198 self.capabilities.clone().unwrap_or_else(Capabilities::legacy_default)
199 }
200
201 pub fn capabilities_implicit(&self) -> bool {
204 self.capabilities.is_none()
205 }
206
207 pub fn check_sdk_compat(&self, sdk_api_version: &str) -> ManifestResult<()> {
210 if SdkVersion::parse(sdk_api_version).matches(&self.sdk_version) {
211 Ok(())
212 } else {
213 Err(ManifestError::IncompatibleSdkVersion {
214 plugin: self.name.clone(),
215 required: self.sdk_version.clone(),
216 sdk: sdk_api_version.to_string(),
217 })
218 }
219 }
220
221 pub fn check_hooks(&self, observed: &[&str]) -> ManifestResult<()> {
225 let declared: HashSet<&str> = self.hooks.iter().map(|s| s.as_str()).collect();
226 for h in observed {
227 if !declared.contains(*h) {
228 return Err(ManifestError::HookNotDeclared {
229 plugin: self.name.clone(),
230 hook: (*h).to_string(),
231 });
232 }
233 }
234 Ok(())
235 }
236}
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243pub struct SdkVersion {
244 pub major: u32,
245 pub minor: u32,
246}
247
248impl SdkVersion {
249 pub fn parse(s: &str) -> Self {
251 let mut parts = s.trim().split('.');
252 let major = parts.next().and_then(|n| n.parse().ok()).unwrap_or(0);
253 let minor = parts.next().and_then(|n| n.parse().ok()).unwrap_or(0);
254 Self { major, minor }
255 }
256
257 pub fn matches(&self, constraint: &str) -> bool {
265 let c = constraint.trim();
266 if let Some(rest) = c.strip_prefix(">=") {
267 let want = Self::parse(rest);
268 return self.major > want.major
269 || (self.major == want.major && self.minor >= want.minor);
270 }
271 if let Some(rest) = c.strip_prefix('^') {
272 let want = Self::parse(rest);
273 return self.major == want.major && self.minor >= want.minor;
274 }
275 let want = Self::parse(c);
276 self.major == want.major && self.minor >= want.minor
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 #[test]
285 fn parses_minimal_v01_manifest() {
286 let json = r#"{
287 "name": "demo",
288 "version": "1.0.0",
289 "description": "x",
290 "author": "y",
291 "license": "Apache-2.0"
292 }"#;
293 let m = PluginManifest::from_str(json).unwrap();
294 assert_eq!(m.name, "demo");
295 assert_eq!(m.runtime, "lua");
296 assert_eq!(m.sdk_version, ">=0.1.0");
297 assert!(m.capabilities_implicit());
298 }
299
300 #[test]
301 fn sdk_version_constraint_matrix() {
302 let v02 = SdkVersion::parse("0.2");
303 assert!(v02.matches(">=0.1.0"));
304 assert!(v02.matches(">=0.2"));
305 assert!(v02.matches("^0.2"));
306 assert!(v02.matches("0.2"));
307 assert!(!v02.matches(">=0.3"));
308 assert!(!v02.matches("^0.3"));
309 assert!(!v02.matches("^1.0"));
311 }
312
313 #[test]
314 fn host_allowlist_suffix_match() {
315 let mut caps = Capabilities::legacy_default();
316 caps.network = true;
317 caps.network_allow = vec!["api.slack.com".into(), "hooks.example.org".into()];
318 assert!(caps.host_allowed("api.slack.com"));
319 assert!(caps.host_allowed("v2.api.slack.com"));
320 assert!(!caps.host_allowed("evil.com"));
321 assert!(!caps.host_allowed("slack.com")); }
323
324 #[test]
325 fn legacy_default_denies_network() {
326 let caps = Capabilities::legacy_default();
327 assert!(!caps.host_allowed("api.slack.com"));
328 assert!(!caps.sober);
329 }
330
331 #[test]
332 fn rejects_major_mismatch() {
333 let json = r#"{
334 "name": "future",
335 "version": "1.0.0",
336 "description": "x",
337 "author": "y",
338 "license": "MIT",
339 "sdk_version": "^1.0"
340 }"#;
341 let m = PluginManifest::from_str(json).unwrap();
342 assert!(m.check_sdk_compat("0.2").is_err());
343 }
344}