//! Module registry (port of `plugins/pluginRegistry.js`). Manifest schema 1 is unchanged, //! installed modules stay in `/plugins///` for compatibility with 0.1.0. use crate::core::error::{CoreError, CoreResult}; use crate::core::version::{compare_versions, parse_version}; use once_cell::sync::Lazy; use parking_lot::Mutex; use regex::Regex; use serde_json::{json, Value}; use std::cmp::Ordering; use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; pub static MODULE_ID: Lazy = Lazy::new(|| Regex::new(r"^[a-z0-9][a-z0-9.-]{2,63}$").unwrap()); static API_RANGE: Lazy = Lazy::new(|| Regex::new(r"^\^1\.(\d+)\.(\d+)$").unwrap()); static API_VERSION: Lazy = Lazy::new(|| Regex::new(r"^1\.(\d+)\.(\d+)$").unwrap()); pub const ALLOWED_PERMISSIONS: &[&str] = &[ "session.read", "system.metrics.read", "system.memory.manage", "disks.read", "disks.manage", "keys.generate", "files.read", "files.write", "files.transfer", "files.local", "users.read", "users.manage", "nginx.read", "nginx.manage", "certbot.manage", "mail.read", "mail.manage", "logs.read", "remote.exec", "local.hash", ]; fn text(value: &Value, key: &str) -> String { match value.get(key) { Some(Value::String(s)) => s.clone(), Some(Value::Number(n)) => n.to_string(), _ => String::new(), } } fn truncate(value: &str, max: usize) -> String { value.trim().chars().take(max).collect() } pub fn validate_manifest(input: &Value) -> CoreResult { if !input.is_object() { return Err(CoreError::plain("Plugin manifest must be an object.")); } let id = text(input, "id").trim().to_string(); if !MODULE_ID.is_match(&id) { return Err(CoreError::plain("Invalid plugin ID.")); } let version = text(input, "version").trim().to_string(); parse_version(&version)?; let entry = text(input, "entry").replace('\\', "/"); if entry.is_empty() || entry.starts_with('/') || entry.split('/').any(|s| s == "..") { return Err(CoreError::plain("Invalid plugin entry path.")); } let mut permissions: Vec = Vec::new(); if let Some(Value::Array(items)) = input.get("permissions") { for item in items { let permission = match item { Value::String(s) => s.clone(), other => other.to_string() }; if !permissions.contains(&permission) { permissions.push(permission); } } } for permission in &permissions { if !ALLOWED_PERMISSIONS.contains(&permission.as_str()) { return Err(CoreError::plain(format!("Unsupported plugin permission: {permission}"))); } } let schema = input.get("schemaVersion").and_then(Value::as_f64).unwrap_or(1.0); if schema != 1.0 { return Err(CoreError::plain(format!("Unsupported plugin manifest schema: {schema}"))); } let min_app = { let v = text(input, "minAppVersion"); if v.is_empty() { "0.0.1".to_string() } else { v } }; parse_version(&min_app)?; let api = { let v = text(input, "pluginApi"); if v.is_empty() { "^1.0.0".to_string() } else { v } }; if !API_RANGE.is_match(&api) { return Err(CoreError::plain(format!("Unsupported plugin API: {api}"))); } let name = { let n = truncate(&text(input, "name"), 100); if n.is_empty() { id.clone() } else { n } }; let publisher = { let p = truncate(&text(input, "publisher"), 100); if p.is_empty() { "Unknown".to_string() } else { p } }; Ok(json!({ "schemaVersion": 1, "id": id, "name": name, "description": truncate(&text(input, "description"), 500), "version": version, "publisher": publisher, "entry": entry, "pluginApi": api, "minAppVersion": min_app, "permissions": permissions })) } pub fn supports_module_api(range: &str, supported: &str) -> bool { match (API_RANGE.captures(range), API_VERSION.captures(supported)) { (Some(r), Some(s)) => { let n = |c: ®ex::Captures, i: usize| c[i].parse::().unwrap_or(u64::MAX); n(&r, 1) < n(&s, 1) || (n(&r, 1) == n(&s, 1) && n(&r, 2) <= n(&s, 2)) } _ => false, } } pub struct ModuleRegistry { pub root: PathBuf, bundled_root: Option, locations: Mutex>, logger: std::sync::Arc, } pub struct ResolvedModule { pub manifest: Value, pub root: PathBuf, } impl ModuleRegistry { pub fn new(data_root: &Path, bundled_root: Option, logger: std::sync::Arc) -> Self { Self { root: data_root.join("plugins"), bundled_root, locations: Mutex::new(HashMap::new()), logger } } pub fn list_installed(&self) -> CoreResult> { fs::create_dir_all(&self.root)?; let mut locations = self.locations.lock(); locations.clear(); let mut sources = vec![(self.root.clone(), false)]; if let Some(bundled) = self.bundled_root.as_ref().filter(|b| b.is_dir()) { sources.push((bundled.clone(), true)); } let mut candidates: HashMap> = HashMap::new(); for (root, bundled) in sources { let Ok(entries) = fs::read_dir(&root) else { continue }; for id_entry in entries.flatten() { let id = id_entry.file_name().to_string_lossy().to_string(); if !id_entry.file_type().map(|t| t.is_dir()).unwrap_or(false) || !MODULE_ID.is_match(&id) { continue; } let Ok(versions) = fs::read_dir(id_entry.path()) else { continue }; for version_entry in versions.flatten() { let version_name = version_entry.file_name().to_string_lossy().to_string(); if !version_entry.file_type().map(|t| t.is_dir()).unwrap_or(false) || version_name.starts_with('.') { continue; } let dir = version_entry.path(); let result = (|| -> CoreResult { let raw: Value = serde_json::from_str(&fs::read_to_string(dir.join("manifest.json"))?)?; let mut manifest = validate_manifest(&raw)?; if manifest["id"] != Value::String(id.clone()) || manifest["version"] != Value::String(version_name.clone()) { return Err(CoreError::plain("Plugin directory does not match its manifest.")); } let entry_path = dir.join(manifest["entry"].as_str().unwrap_or("")); if !entry_path.is_file() { return Err(CoreError::plain("Plugin entry file is missing.")); } let installation: Value = fs::read_to_string(dir.join(".installation.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({})); let sha = installation.get("sha512").and_then(Value::as_str).filter(|s| s.len() == 128 && s.chars().all(|c| c.is_ascii_hexdigit())).map(|s| s.to_lowercase()); let obj = manifest.as_object_mut().expect("object"); obj.insert("bundled".into(), Value::Bool(bundled)); obj.insert("installedReleaseId".into(), installation.get("releaseId").filter(|v| v.is_string()).cloned().unwrap_or(Value::Null)); obj.insert("installedSha512".into(), sha.map(Value::String).unwrap_or(Value::Null)); obj.insert("installedAt".into(), installation.get("installedAt").filter(|v| v.is_string()).cloned().unwrap_or(Value::Null)); obj.insert("installedPublishedAt".into(), installation.get("publishedAt").filter(|v| v.is_string()).cloned().unwrap_or(Value::Null)); Ok(manifest) })(); match result { Ok(manifest) => candidates.entry(id.clone()).or_default().push((manifest, dir)), Err(error) => self.logger.warn( "Ignoring invalid installed plugin.", json!({ "pluginId": id, "version": version_name, "message": error.message }), ), } } } } let mut modules = Vec::new(); for (_, mut list) in candidates { list.sort_by(|(a, _), (b, _)| { compare_versions(b["version"].as_str().unwrap_or("0.0"), a["version"].as_str().unwrap_or("0.0")) .unwrap_or(Ordering::Equal) .then_with(|| b["bundled"].as_bool().cmp(&a["bundled"].as_bool())) }); if let Some((manifest, dir)) = list.into_iter().next() { locations.insert(format!("{}@{}", manifest["id"].as_str().unwrap_or(""), manifest["version"].as_str().unwrap_or("")), dir); modules.push(manifest); } } modules.sort_by(|a, b| a["name"].as_str().unwrap_or("").cmp(b["name"].as_str().unwrap_or(""))); Ok(modules) } pub fn get_installed(&self, id: &str) -> CoreResult> { if !MODULE_ID.is_match(id) { return Err(CoreError::plain("Invalid plugin ID.")); } Ok(self.list_installed()?.into_iter().find(|m| m["id"].as_str() == Some(id))) } pub fn resolve(&self, id: &str) -> CoreResult { let manifest = self.get_installed(id)?.ok_or_else(|| CoreError::plain("Plugin is not installed."))?; let key = format!("{}@{}", manifest["id"].as_str().unwrap_or(""), manifest["version"].as_str().unwrap_or("")); let root = self .locations .lock() .get(&key) .cloned() .unwrap_or_else(|| self.root.join(id).join(manifest["version"].as_str().unwrap_or(""))); Ok(ResolvedModule { manifest, root }) } pub fn remove_installed(&self, id: &str) -> CoreResult { if !MODULE_ID.is_match(id) { return Err(CoreError::plain("Invalid plugin ID.")); } let dir = self.root.join(id); if !dir.exists() { return Ok(false); } fs::remove_dir_all(&dir)?; self.logger.info("Plugin removed.", json!({ "pluginId": id })); Ok(true) } } #[cfg(test)] mod tests { use super::*; fn write_module(root: &Path, id: &str, version: &str, permissions: &[&str]) { let dir = root.join(id).join(version); fs::create_dir_all(dir.join("ui")).unwrap(); fs::write(dir.join("ui/index.html"), "").unwrap(); fs::write(dir.join("manifest.json"), json!({"schemaVersion": 1, "id": id, "name": id, "version": version, "entry": "ui/index.html", "pluginApi": "^1.0.0", "permissions": permissions}).to_string()).unwrap(); } #[test] fn highest_version_wins_and_bundled_breaks_ties() { let data = tempfile::tempdir().unwrap(); let bundled = tempfile::tempdir().unwrap(); let logger = std::sync::Arc::new(crate::core::logging::Logger::new(data.path())); write_module(&data.path().join("plugins"), "chj.users", "0.0.2", &["users.read"]); write_module(bundled.path(), "chj.users", "0.0.1", &["users.read"]); write_module(bundled.path(), "chj.logs", "0.0.1", &["logs.read"]); write_module(&data.path().join("plugins"), "chj.logs", "0.0.1", &["logs.read"]); write_module(&data.path().join("plugins"), "chj.bad", "0.0.1", &["root.everything"]); let registry = ModuleRegistry::new(data.path(), Some(bundled.path().to_path_buf()), logger); let list = registry.list_installed().unwrap(); assert_eq!(list.len(), 2); let users = list.iter().find(|m| m["id"] == "chj.users").unwrap(); assert_eq!(users["version"], "0.0.2"); assert_eq!(users["bundled"], false); let logs = list.iter().find(|m| m["id"] == "chj.logs").unwrap(); assert_eq!(logs["bundled"], true); assert!(registry.resolve("chj.logs").unwrap().root.starts_with(bundled.path())); } #[test] fn api_range() { assert!(supports_module_api("^1.7.0", "1.7.0")); assert!(supports_module_api("^1.2.5", "1.7.0")); assert!(!supports_module_api("^1.8.0", "1.7.0")); assert!(!supports_module_api("^2.0.0", "1.7.0")); assert!(validate_manifest(&json!({"id": "chj.x", "version": "1.0.0", "entry": "../x.html"})).is_err()); } }