CH-J Toolspengurusan pelayan melalui SSH
Menu
Sumber yang diterbitkan

CH-J Tools

Layari direktori dan fail untuk keluaran aplikasi tertentu.

Muat turun ZIP sumber
CH-J / Server Manager 1704 fail
0.1.2-beta Ditambah: · Dibina:
registry.rs Ditambah: Tarikh tidak direkodkan 12.5 KB · 260 baris
Kod Muat turun fail
1//! Module registry (port of `plugins/pluginRegistry.js`). Manifest schema 1 is unchanged,
2//! installed modules stay in `<data>/plugins/<id>/<version>/` for compatibility with 0.1.0.
4use crate::core::error::{CoreError, CoreResult};
5use crate::core::version::{compare_versions, parse_version};
6use once_cell::sync::Lazy;
7use parking_lot::Mutex;
8use regex::Regex;
9use serde_json::{json, Value};
10use std::cmp::Ordering;
11use std::collections::HashMap;
12use std::fs;
13use std::path::{Path, PathBuf};
15pub static MODULE_ID: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[a-z0-9][a-z0-9.-]{2,63}$").unwrap());
16static API_RANGE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\^1\.(\d+)\.(\d+)$").unwrap());
17static API_VERSION: Lazy<Regex> = Lazy::new(|| Regex::new(r"^1\.(\d+)\.(\d+)$").unwrap());
19pub const ALLOWED_PERMISSIONS: &[&str] = &[
20 "session.read", "system.metrics.read", "system.memory.manage", "disks.read", "disks.manage", "keys.generate", "keychain.storeGenerated", "keychain.readPublic",
21 "files.read", "files.write", "files.transfer", "files.local", "users.read", "users.manage", "nginx.read", "nginx.manage", "apache.read", "apache.manage", "rsync.read", "rsync.run", "network.read", "network.manage",
22 "certbot.manage", "mail.read", "mail.manage", "logs.read", "remote.exec", "local.hash",
23];
25fn text(value: &Value, key: &str) -> String {
26 match value.get(key) {
27 Some(Value::String(s)) => s.clone(),
28 Some(Value::Number(n)) => n.to_string(),
29 _ => String::new(),
30 }
33fn truncate(value: &str, max: usize) -> String {
34 value.trim().chars().take(max).collect()
37pub fn validate_manifest(input: &Value) -> CoreResult<Value> {
38 if !input.is_object() {
39 return Err(CoreError::plain("Plugin manifest must be an object."));
40 }
41 let id = text(input, "id").trim().to_string();
42 if !MODULE_ID.is_match(&id) {
43 return Err(CoreError::plain("Invalid plugin ID."));
44 }
45 let version = text(input, "version").trim().to_string();
46 parse_version(&version)?;
47 let entry = text(input, "entry").replace('\\', "/");
48 if entry.is_empty() || entry.starts_with('/') || entry.split('/').any(|s| s == "..") {
49 return Err(CoreError::plain("Invalid plugin entry path."));
50 }
51 let mut permissions: Vec<String> = Vec::new();
52 if let Some(Value::Array(items)) = input.get("permissions") {
53 for item in items {
54 let permission = match item { Value::String(s) => s.clone(), other => other.to_string() };
55 if !permissions.contains(&permission) {
56 permissions.push(permission);
57 }
58 }
59 }
60 for permission in &permissions {
61 if !ALLOWED_PERMISSIONS.contains(&permission.as_str()) {
62 return Err(CoreError::plain(format!("Unsupported plugin permission: {permission}")));
63 }
64 }
65 let schema = input.get("schemaVersion").and_then(Value::as_f64).unwrap_or(1.0);
66 if schema != 1.0 {
67 return Err(CoreError::plain(format!("Unsupported plugin manifest schema: {schema}")));
68 }
69 let min_app = { let v = text(input, "minAppVersion"); if v.is_empty() { "0.0.1".to_string() } else { v } };
70 parse_version(&min_app)?;
71 let api = { let v = text(input, "pluginApi"); if v.is_empty() { "^1.0.0".to_string() } else { v } };
72 if !API_RANGE.is_match(&api) {
73 return Err(CoreError::plain(format!("Unsupported plugin API: {api}")));
74 }
75 let name = { let n = truncate(&text(input, "name"), 100); if n.is_empty() { id.clone() } else { n } };
76 let publisher = { let p = truncate(&text(input, "publisher"), 100); if p.is_empty() { "Unknown".to_string() } else { p } };
77 Ok(json!({
78 "schemaVersion": 1,
79 "id": id,
80 "name": name,
81 "description": truncate(&text(input, "description"), 500),
82 "version": version,
83 "publisher": publisher,
84 "entry": entry,
85 "pluginApi": api,
86 "minAppVersion": min_app,
87 "permissions": permissions
88 }))
91pub fn supports_module_api(range: &str, supported: &str) -> bool {
92 match (API_RANGE.captures(range), API_VERSION.captures(supported)) {
93 (Some(r), Some(s)) => {
94 let n = |c: &regex::Captures, i: usize| c[i].parse::<u64>().unwrap_or(u64::MAX);
95 n(&r, 1) < n(&s, 1) || (n(&r, 1) == n(&s, 1) && n(&r, 2) <= n(&s, 2))
96 }
97 _ => false,
98 }
101pub struct ModuleRegistry {
102 pub root: PathBuf,
103 bundled_root: Option<PathBuf>,
104 locations: Mutex<HashMap<String, PathBuf>>,
105 logger: std::sync::Arc<crate::core::logging::Logger>,
108pub struct ResolvedModule {
109 pub manifest: Value,
110 pub root: PathBuf,
113impl ModuleRegistry {
114 pub fn new(data_root: &Path, bundled_root: Option<PathBuf>, logger: std::sync::Arc<crate::core::logging::Logger>) -> Self {
115 Self { root: data_root.join("plugins"), bundled_root, locations: Mutex::new(HashMap::new()), logger }
116 }
118 pub fn list_installed(&self) -> CoreResult<Vec<Value>> {
119 fs::create_dir_all(&self.root)?;
120 let mut locations = self.locations.lock();
121 locations.clear();
122 let mut sources = vec![(self.root.clone(), false)];
123 if let Some(bundled) = self.bundled_root.as_ref().filter(|b| b.is_dir()) {
124 sources.push((bundled.clone(), true));
125 }
126 let mut candidates: HashMap<String, Vec<(Value, PathBuf)>> = HashMap::new();
127 for (root, bundled) in sources {
128 let Ok(entries) = fs::read_dir(&root) else { continue };
129 for id_entry in entries.flatten() {
130 let id = id_entry.file_name().to_string_lossy().to_string();
131 if !id_entry.file_type().map(|t| t.is_dir()).unwrap_or(false) || !MODULE_ID.is_match(&id) {
132 continue;
133 }
134 let Ok(versions) = fs::read_dir(id_entry.path()) else { continue };
135 for version_entry in versions.flatten() {
136 let version_name = version_entry.file_name().to_string_lossy().to_string();
137 if !version_entry.file_type().map(|t| t.is_dir()).unwrap_or(false) || version_name.starts_with('.') {
138 continue;
139 }
140 let dir = version_entry.path();
141 let result = (|| -> CoreResult<Value> {
142 let raw: Value = serde_json::from_str(&fs::read_to_string(dir.join("manifest.json"))?)?;
143 let mut manifest = validate_manifest(&raw)?;
144 if manifest["id"] != Value::String(id.clone()) || manifest["version"] != Value::String(version_name.clone()) {
145 return Err(CoreError::plain("Plugin directory does not match its manifest."));
146 }
147 let entry_path = dir.join(manifest["entry"].as_str().unwrap_or(""));
148 if !entry_path.is_file() {
149 return Err(CoreError::plain("Plugin entry file is missing."));
150 }
151 let installation: Value = fs::read_to_string(dir.join(".installation.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({}));
152 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());
153 let obj = manifest.as_object_mut().expect("object");
154 obj.insert("bundled".into(), Value::Bool(bundled));
155 obj.insert("installedReleaseId".into(), installation.get("releaseId").filter(|v| v.is_string()).cloned().unwrap_or(Value::Null));
156 obj.insert("installedSha512".into(), sha.map(Value::String).unwrap_or(Value::Null));
157 obj.insert("installedAt".into(), installation.get("installedAt").filter(|v| v.is_string()).cloned().unwrap_or(Value::Null));
158 obj.insert("installedPublishedAt".into(), installation.get("publishedAt").filter(|v| v.is_string()).cloned().unwrap_or(Value::Null));
159 Ok(manifest)
160 })();
161 match result {
162 Ok(manifest) => candidates.entry(id.clone()).or_default().push((manifest, dir)),
163 Err(error) => self.logger.warn(
164 "Ignoring invalid installed plugin.",
165 json!({ "pluginId": id, "version": version_name, "message": error.message }),
166 ),
167 }
168 }
169 }
170 }
171 let mut modules = Vec::new();
172 for (_, mut list) in candidates {
173 list.sort_by(|(a, _), (b, _)| {
174 compare_versions(b["version"].as_str().unwrap_or("0.0"), a["version"].as_str().unwrap_or("0.0"))
175 .unwrap_or(Ordering::Equal)
176 .then_with(|| b["bundled"].as_bool().cmp(&a["bundled"].as_bool()))
177 });
178 if let Some((manifest, dir)) = list.into_iter().next() {
179 locations.insert(format!("{}@{}", manifest["id"].as_str().unwrap_or(""), manifest["version"].as_str().unwrap_or("")), dir);
180 modules.push(manifest);
181 }
182 }
183 modules.sort_by(|a, b| a["name"].as_str().unwrap_or("").cmp(b["name"].as_str().unwrap_or("")));
184 Ok(modules)
185 }
187 pub fn get_installed(&self, id: &str) -> CoreResult<Option<Value>> {
188 if !MODULE_ID.is_match(id) {
189 return Err(CoreError::plain("Invalid plugin ID."));
190 }
191 Ok(self.list_installed()?.into_iter().find(|m| m["id"].as_str() == Some(id)))
192 }
194 pub fn resolve(&self, id: &str) -> CoreResult<ResolvedModule> {
195 let manifest = self.get_installed(id)?.ok_or_else(|| CoreError::plain("Plugin is not installed."))?;
196 let key = format!("{}@{}", manifest["id"].as_str().unwrap_or(""), manifest["version"].as_str().unwrap_or(""));
197 let root = self
198 .locations
199 .lock()
200 .get(&key)
201 .cloned()
202 .unwrap_or_else(|| self.root.join(id).join(manifest["version"].as_str().unwrap_or("")));
203 Ok(ResolvedModule { manifest, root })
204 }
206 pub fn remove_installed(&self, id: &str) -> CoreResult<bool> {
207 if !MODULE_ID.is_match(id) {
208 return Err(CoreError::plain("Invalid plugin ID."));
209 }
210 let dir = self.root.join(id);
211 if !dir.exists() {
212 return Ok(false);
213 }
214 fs::remove_dir_all(&dir)?;
215 self.logger.info("Plugin removed.", json!({ "pluginId": id }));
216 Ok(true)
217 }
220#[cfg(test)]
221mod tests {
222 use super::*;
224 fn write_module(root: &Path, id: &str, version: &str, permissions: &[&str]) {
225 let dir = root.join(id).join(version);
226 fs::create_dir_all(dir.join("ui")).unwrap();
227 fs::write(dir.join("ui/index.html"), "<html></html>").unwrap();
228 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();
229 }
231 #[test]
232 fn highest_version_wins_and_bundled_breaks_ties() {
233 let data = tempfile::tempdir().unwrap();
234 let bundled = tempfile::tempdir().unwrap();
235 let logger = std::sync::Arc::new(crate::core::logging::Logger::new(data.path()));
236 write_module(&data.path().join("plugins"), "chj.users", "0.0.2", &["users.read"]);
237 write_module(bundled.path(), "chj.users", "0.0.1", &["users.read"]);
238 write_module(bundled.path(), "chj.logs", "0.0.1", &["logs.read"]);
239 write_module(&data.path().join("plugins"), "chj.logs", "0.0.1", &["logs.read"]);
240 write_module(&data.path().join("plugins"), "chj.bad", "0.0.1", &["root.everything"]);
241 let registry = ModuleRegistry::new(data.path(), Some(bundled.path().to_path_buf()), logger);
242 let list = registry.list_installed().unwrap();
243 assert_eq!(list.len(), 2);
244 let users = list.iter().find(|m| m["id"] == "chj.users").unwrap();
245 assert_eq!(users["version"], "0.0.2");
246 assert_eq!(users["bundled"], false);
247 let logs = list.iter().find(|m| m["id"] == "chj.logs").unwrap();
248 assert_eq!(logs["bundled"], true);
249 assert!(registry.resolve("chj.logs").unwrap().root.starts_with(bundled.path()));
250 }
252 #[test]
253 fn api_range() {
254 assert!(supports_module_api("^1.7.0", "1.7.0"));
255 assert!(supports_module_api("^1.2.5", "1.7.0"));
256 assert!(!supports_module_api("^1.8.0", "1.7.0"));
257 assert!(!supports_module_api("^2.0.0", "1.7.0"));
258 assert!(validate_manifest(&json!({"id": "chj.x", "version": "1.0.0", "entry": "../x.html"})).is_err());
259 }
SHA-2562c5fa4b083ab33efca55ba2c4ca57329c4af93700e5edde1fd4ba1bb1b55ee44

CH-J Proprietary Software License 1.14 · Kod sumber disediakan di bawah CH-J Proprietary Software License 1.14. Ketersediaannya tidak mengubah terma lesen atau memberikan hak tambahan.

SHA-256 arkibac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Fail