Skip to main content

progit_plugin_sdk/
manifest.rs

1// SPDX-License-Identifier: LSL-1.0
2// Copyright (c) 2025 Markus Maiwald
3
4//! Plugin manifest (`.progit-plugin.json`) — typed mirror.
5//!
6//! [ARCH] Plugins ship a manifest next to their entry point. The manifest
7//! is the *trusted declaration* — the Lua/Wasm code must match it. The host
8//! cross-checks at load time. If the Lua side declares hooks that aren't
9//! in the manifest, that is a packaging bug.
10//!
11//! ## Why a typed manifest matters
12//!
13//! - `sdk_version` lets the SDK refuse plugins built against an
14//!   incompatible major API version (forward-proofing).
15//! - `capabilities` is the security surface for the runtime sandbox —
16//!   without it we cannot promise Doctrine 4 ("Sandboxed plugins").
17//! - `config_schema` is JSON Schema and lets the host validate user
18//!   config in `.project/config.kdl` *before* the plugin runs.
19
20use serde::{Deserialize, Serialize};
21use std::collections::HashSet;
22use std::path::Path;
23use thiserror::Error;
24
25/// Errors raised by manifest parsing and compatibility checks.
26#[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
48/// Result alias for manifest operations.
49pub type ManifestResult<T> = Result<T, ManifestError>;
50
51/// Plugin manifest — the on-disk `.progit-plugin.json`.
52///
53/// Backwards compatible with v0.1 manifests: missing fields fall back to
54/// permissive defaults. New plugins should set `capabilities` explicitly.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct PluginManifest {
57    /// Unique plugin id (also the directory name).
58    pub name: String,
59    /// Semver of the plugin itself.
60    pub version: String,
61    /// One-line summary, shown in the plugin browser.
62    pub description: String,
63    /// Author handle or organisation.
64    pub author: String,
65    /// SPDX identifier (`LSL-1.0`, `LUL-1.0`, `Apache-2.0`, ...).
66    pub license: String,
67    /// Free-form classification: `notifier` | `integration` | `analytics` | ...
68    #[serde(default)]
69    pub plugin_type: String,
70    /// `lua` or `wasm`.
71    #[serde(default = "default_runtime")]
72    pub runtime: String,
73    /// Required SDK API version (`">=0.2"`, `"^0.2"`, `"0.2"` exact).
74    /// Defaults to `">=0.1.0"` for legacy manifests.
75    #[serde(default = "default_sdk_constraint")]
76    pub sdk_version: String,
77    /// Hooks the plugin claims to implement. Cross-checked against the
78    /// hooks the Lua / Wasm side actually registers.
79    #[serde(default)]
80    pub hooks: Vec<String>,
81    /// JSON Schema for the plugin's slice of `.project/config.kdl`.
82    /// Treated as opaque JSON until a host-side schema validator lands.
83    #[serde(default)]
84    pub config_schema: Option<serde_json::Value>,
85    /// Capability declaration — what this plugin is allowed to do.
86    /// Missing = fully permissive (with a deprecation warning).
87    #[serde(default)]
88    pub capabilities: Option<Capabilities>,
89    /// Optional homepage / source URL for the marketplace.
90    #[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/// What the plugin is allowed to touch at runtime.
102///
103/// Defaults are *permissive* on purpose for back-compat. Future major
104/// versions will flip this to deny-by-default; the migration path is
105/// "add a `capabilities` block to your manifest".
106#[derive(Debug, Clone, Serialize, Deserialize, Default)]
107pub struct Capabilities {
108    /// May the plugin make outbound HTTP requests via the injected `http` table?
109    #[serde(default)]
110    pub network: bool,
111    /// If set, restricts `http` to these hosts (suffix match — `api.slack.com`
112    /// allows `api.slack.com` and `*.api.slack.com`). Empty = any host.
113    #[serde(default)]
114    pub network_allow: Vec<String>,
115    /// May the plugin read/write its private storage via the `storage` table?
116    #[serde(default = "default_true")]
117    pub storage: bool,
118    /// May the plugin read environment variables via `os.getenv`?
119    #[serde(default = "default_true")]
120    pub env: bool,
121    /// May the plugin call the host-provided Sober governance bridge?
122    #[serde(default)]
123    pub sober: bool,
124    /// Maximum heap memory the plugin VM can allocate, in megabytes.
125    #[serde(default = "default_memory_mb")]
126    pub memory_mb: u32,
127    /// Maximum Lua instructions a single hook call may execute.
128    #[serde(default = "default_instructions")]
129    pub max_instructions: u64,
130    /// HTTP request timeout in seconds.
131    #[serde(default = "default_http_timeout")]
132    pub http_timeout_secs: u64,
133    /// May the plugin spawn background jobs via the host-managed async primitive?
134    #[serde(default)]
135    pub jobs: bool,
136    /// May the plugin cancel jobs it previously spawned?
137    #[serde(default)]
138    pub jobs_cancel: bool,
139    /// May the plugin register top-level command aliases?
140    #[serde(default)]
141    pub commands_alias: bool,
142    /// May the plugin subscribe to host events (job progress, file changes)?
143    #[serde(default)]
144    pub events_subscribe: bool,
145    /// May the plugin request a streaming modal surface?
146    #[serde(default)]
147    pub ui_modal: bool,
148    /// May the plugin request auto-activation based on file patterns?
149    #[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    /// Permissive defaults for legacy plugins that ship without a
168    /// capability block. Network is *off* by default — plugins must
169    /// opt in to the network surface.
170    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    /// Is `host` allowed by the `network_allow` list?
190    /// An empty list means *any* host. Suffix match: `api.slack.com`
191    /// in the list permits `api.slack.com` and `hooks.api.slack.com`.
192    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    /// Load and parse a manifest from disk.
208    pub fn load(path: &Path) -> ManifestResult<Self> {
209        let content = std::fs::read_to_string(path)?;
210        Self::from_str(&content)
211    }
212
213    /// Parse a manifest from a JSON string.
214    pub fn from_str(json: &str) -> ManifestResult<Self> {
215        let m: PluginManifest = serde_json::from_str(json)?;
216        Ok(m)
217    }
218
219    /// Effective capabilities — declared block, or the legacy permissive
220    /// default. Use this everywhere the runtime asks "may I?".
221    pub fn effective_capabilities(&self) -> Capabilities {
222        self.capabilities.clone().unwrap_or_else(Capabilities::legacy_default)
223    }
224
225    /// Was the capability block omitted? (Used to emit a one-shot
226    /// deprecation warning on plugin load.)
227    pub fn capabilities_implicit(&self) -> bool {
228        self.capabilities.is_none()
229    }
230
231    /// Reject the plugin if its `sdk_version` constraint is incompatible
232    /// with the running SDK's API version.
233    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    /// Confirm every hook implemented by the plugin's runtime side is also
246    /// declared in the manifest. Caller passes the hooks observed from
247    /// the Lua/Wasm side.
248    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/// Lightweight `major.minor` SDK version handle.
263///
264/// We avoid pulling in the full `semver` crate (≈170 KB) for what amounts
265/// to a major-equal / minor-at-least check.
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
267pub struct SdkVersion {
268    pub major: u32,
269    pub minor: u32,
270}
271
272impl SdkVersion {
273    /// Parse `"0.2"` or `"0.2.0"` style strings. Trailing patch is ignored.
274    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    /// Does this SDK version satisfy a constraint like `">=0.2"`,
282    /// `"^0.2"`, or `"0.2"` exact?
283    ///
284    /// Rules:
285    /// - `^X.Y` and bare `X.Y`: same major, minor ≥ Y.
286    /// - `>=X.Y`: major ≥ X (when X > 0) or (major == X and minor ≥ Y) when X == 0.
287    /// - Anything else: try a parse and require equality on `major`.
288    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        // Major mismatch is always rejected with caret/bare.
334        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")); // not a suffix match
346    }
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}