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}
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    /// Permissive defaults for legacy plugins that ship without a
150    /// capability block. Network is *off* by default — plugins must
151    /// opt in to the network surface.
152    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    /// Is `host` allowed by the `network_allow` list?
166    /// An empty list means *any* host. Suffix match: `api.slack.com`
167    /// in the list permits `api.slack.com` and `hooks.api.slack.com`.
168    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    /// Load and parse a manifest from disk.
184    pub fn load(path: &Path) -> ManifestResult<Self> {
185        let content = std::fs::read_to_string(path)?;
186        Self::from_str(&content)
187    }
188
189    /// Parse a manifest from a JSON string.
190    pub fn from_str(json: &str) -> ManifestResult<Self> {
191        let m: PluginManifest = serde_json::from_str(json)?;
192        Ok(m)
193    }
194
195    /// Effective capabilities — declared block, or the legacy permissive
196    /// default. Use this everywhere the runtime asks "may I?".
197    pub fn effective_capabilities(&self) -> Capabilities {
198        self.capabilities.clone().unwrap_or_else(Capabilities::legacy_default)
199    }
200
201    /// Was the capability block omitted? (Used to emit a one-shot
202    /// deprecation warning on plugin load.)
203    pub fn capabilities_implicit(&self) -> bool {
204        self.capabilities.is_none()
205    }
206
207    /// Reject the plugin if its `sdk_version` constraint is incompatible
208    /// with the running SDK's API version.
209    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    /// Confirm every hook implemented by the plugin's runtime side is also
222    /// declared in the manifest. Caller passes the hooks observed from
223    /// the Lua/Wasm side.
224    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/// Lightweight `major.minor` SDK version handle.
239///
240/// We avoid pulling in the full `semver` crate (≈170 KB) for what amounts
241/// to a major-equal / minor-at-least check.
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243pub struct SdkVersion {
244    pub major: u32,
245    pub minor: u32,
246}
247
248impl SdkVersion {
249    /// Parse `"0.2"` or `"0.2.0"` style strings. Trailing patch is ignored.
250    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    /// Does this SDK version satisfy a constraint like `">=0.2"`,
258    /// `"^0.2"`, or `"0.2"` exact?
259    ///
260    /// Rules:
261    /// - `^X.Y` and bare `X.Y`: same major, minor ≥ Y.
262    /// - `>=X.Y`: major ≥ X (when X > 0) or (major == X and minor ≥ Y) when X == 0.
263    /// - Anything else: try a parse and require equality on `major`.
264    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        // Major mismatch is always rejected with caret/bare.
310        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")); // not a suffix match
322    }
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}