use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::Path;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ManifestError {
#[error("Manifest IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Manifest JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Plugin '{plugin}' targets SDK API {required} which is incompatible with this SDK ({sdk})")]
IncompatibleSdkVersion {
plugin: String,
required: String,
sdk: String,
},
#[error("Plugin '{plugin}' missing capability for '{feature}'. Add it to capabilities in the manifest.")]
MissingCapability { plugin: String, feature: String },
#[error("Plugin '{plugin}' declares hook '{hook}' in code but not in manifest.hooks")]
HookNotDeclared { plugin: String, hook: String },
}
pub type ManifestResult<T> = Result<T, ManifestError>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub name: String,
pub version: String,
pub description: String,
pub author: String,
pub license: String,
#[serde(default)]
pub plugin_type: String,
#[serde(default = "default_runtime")]
pub runtime: String,
#[serde(default = "default_sdk_constraint")]
pub sdk_version: String,
#[serde(default)]
pub hooks: Vec<String>,
#[serde(default)]
pub config_schema: Option<serde_json::Value>,
#[serde(default)]
pub capabilities: Option<Capabilities>,
#[serde(default)]
pub homepage: Option<String>,
}
fn default_runtime() -> String {
"lua".to_string()
}
fn default_sdk_constraint() -> String {
">=0.1.0".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Capabilities {
#[serde(default)]
pub network: bool,
#[serde(default)]
pub network_allow: Vec<String>,
#[serde(default = "default_true")]
pub storage: bool,
#[serde(default = "default_true")]
pub env: bool,
#[serde(default)]
pub sober: bool,
#[serde(default = "default_memory_mb")]
pub memory_mb: u32,
#[serde(default = "default_instructions")]
pub max_instructions: u64,
#[serde(default = "default_http_timeout")]
pub http_timeout_secs: u64,
#[serde(default)]
pub jobs: bool,
#[serde(default)]
pub jobs_cancel: bool,
#[serde(default)]
pub commands_alias: bool,
#[serde(default)]
pub events_subscribe: bool,
#[serde(default)]
pub ui_modal: bool,
#[serde(default)]
pub activation_auto: bool,
}
fn default_true() -> bool {
true
}
fn default_memory_mb() -> u32 {
64
}
fn default_instructions() -> u64 {
10_000_000
}
fn default_http_timeout() -> u64 {
5
}
impl Capabilities {
pub fn legacy_default() -> Self {
Self {
network: false,
network_allow: Vec::new(),
storage: true,
env: true,
sober: false,
memory_mb: default_memory_mb(),
max_instructions: default_instructions(),
http_timeout_secs: default_http_timeout(),
jobs: false,
jobs_cancel: false,
commands_alias: false,
events_subscribe: false,
ui_modal: false,
activation_auto: false,
}
}
pub fn host_allowed(&self, host: &str) -> bool {
if !self.network {
return false;
}
if self.network_allow.is_empty() {
return true;
}
let h = host.to_lowercase();
self.network_allow
.iter()
.any(|allowed| h == allowed.to_lowercase() || h.ends_with(&format!(".{}", allowed.to_lowercase())))
}
}
impl PluginManifest {
pub fn load(path: &Path) -> ManifestResult<Self> {
let content = std::fs::read_to_string(path)?;
Self::from_str(&content)
}
pub fn from_str(json: &str) -> ManifestResult<Self> {
let m: PluginManifest = serde_json::from_str(json)?;
Ok(m)
}
pub fn effective_capabilities(&self) -> Capabilities {
self.capabilities.clone().unwrap_or_else(Capabilities::legacy_default)
}
pub fn capabilities_implicit(&self) -> bool {
self.capabilities.is_none()
}
pub fn check_sdk_compat(&self, sdk_api_version: &str) -> ManifestResult<()> {
if SdkVersion::parse(sdk_api_version).matches(&self.sdk_version) {
Ok(())
} else {
Err(ManifestError::IncompatibleSdkVersion {
plugin: self.name.clone(),
required: self.sdk_version.clone(),
sdk: sdk_api_version.to_string(),
})
}
}
pub fn check_hooks(&self, observed: &[&str]) -> ManifestResult<()> {
let declared: HashSet<&str> = self.hooks.iter().map(|s| s.as_str()).collect();
for h in observed {
if !declared.contains(*h) {
return Err(ManifestError::HookNotDeclared {
plugin: self.name.clone(),
hook: (*h).to_string(),
});
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SdkVersion {
pub major: u32,
pub minor: u32,
}
impl SdkVersion {
pub fn parse(s: &str) -> Self {
let mut parts = s.trim().split('.');
let major = parts.next().and_then(|n| n.parse().ok()).unwrap_or(0);
let minor = parts.next().and_then(|n| n.parse().ok()).unwrap_or(0);
Self { major, minor }
}
pub fn matches(&self, constraint: &str) -> bool {
let c = constraint.trim();
if let Some(rest) = c.strip_prefix(">=") {
let want = Self::parse(rest);
return self.major > want.major
|| (self.major == want.major && self.minor >= want.minor);
}
if let Some(rest) = c.strip_prefix('^') {
let want = Self::parse(rest);
return self.major == want.major && self.minor >= want.minor;
}
let want = Self::parse(c);
self.major == want.major && self.minor >= want.minor
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_minimal_v01_manifest() {
let json = r#"{
"name": "demo",
"version": "1.0.0",
"description": "x",
"author": "y",
"license": "Apache-2.0"
}"#;
let m = PluginManifest::from_str(json).unwrap();
assert_eq!(m.name, "demo");
assert_eq!(m.runtime, "lua");
assert_eq!(m.sdk_version, ">=0.1.0");
assert!(m.capabilities_implicit());
}
#[test]
fn sdk_version_constraint_matrix() {
let v02 = SdkVersion::parse("0.2");
assert!(v02.matches(">=0.1.0"));
assert!(v02.matches(">=0.2"));
assert!(v02.matches("^0.2"));
assert!(v02.matches("0.2"));
assert!(!v02.matches(">=0.3"));
assert!(!v02.matches("^0.3"));
assert!(!v02.matches("^1.0"));
}
#[test]
fn host_allowlist_suffix_match() {
let mut caps = Capabilities::legacy_default();
caps.network = true;
caps.network_allow = vec!["api.slack.com".into(), "hooks.example.org".into()];
assert!(caps.host_allowed("api.slack.com"));
assert!(caps.host_allowed("v2.api.slack.com"));
assert!(!caps.host_allowed("evil.com"));
assert!(!caps.host_allowed("slack.com")); }
#[test]
fn legacy_default_denies_network() {
let caps = Capabilities::legacy_default();
assert!(!caps.host_allowed("api.slack.com"));
assert!(!caps.sober);
}
#[test]
fn rejects_major_mismatch() {
let json = r#"{
"name": "future",
"version": "1.0.0",
"description": "x",
"author": "y",
"license": "MIT",
"sdk_version": "^1.0"
}"#;
let m = PluginManifest::from_str(json).unwrap();
assert!(m.check_sdk_compat("0.2").is_err());
}
}