Nailathalang source
I-download ang source ZIP
CH-J Tools
I-browse ang mga direktoryo at file ng isang partikular na release ng application.
CH-J / Server Manager
1704 file
0.1.2-beta
Idinagdag: · Binuo:
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.4
use crate::core::error::{CoreError, CoreResult};5
use crate::core::version::{compare_versions, parse_version};6
use once_cell::sync::Lazy;7
use parking_lot::Mutex;8
use regex::Regex;9
use serde_json::{json, Value};10
use std::cmp::Ordering;11
use std::collections::HashMap;12
use std::fs;13
use std::path::{Path, PathBuf};15
pub static MODULE_ID: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[a-z0-9][a-z0-9.-]{2,63}$").unwrap());16
static API_RANGE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\^1\.(\d+)\.(\d+)$").unwrap());17
static API_VERSION: Lazy<Regex> = Lazy::new(|| Regex::new(r"^1\.(\d+)\.(\d+)$").unwrap());19
pub 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
];25
fn 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
}31
}33
fn truncate(value: &str, max: usize) -> String {34
value.trim().chars().take(max).collect()35
}37
pub 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": permissions88
}))89
}91
pub 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: ®ex::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
}99
}101
pub 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>,106
}108
pub struct ResolvedModule {109
pub manifest: Value,110
pub root: PathBuf,111
}113
impl 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 = self198
.locations199
.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
}218
}220
#[cfg(test)]221
mod 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
}260
}SHA-256
2c5fa4b083ab33efca55ba2c4ca57329c4af93700e5edde1fd4ba1bb1b55ee44CH-J Proprietary Software License 1.14 · Ibinibigay ang source sa ilalim ng CH-J Proprietary Software License 1.14. Hindi binabago ng pagiging available nito ang mga tuntunin ng lisensya at hindi ito nagbibigay ng karagdagang karapatan.
SHA-256 ng archive
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f