Opublikowane źródło
Pobierz źródło ZIP
CH-J Tools
Przeglądaj katalogi i pliki dla konkretnego wydania aplikacji.
CH-J / Server Manager
1704 pliki
0.1.2-beta
Dodano: · Built:
1
//! Module package installer (port of `plugins/pluginInstaller.js`): bounded ZIP extraction2
//! into a private staging directory, no symlinks/traversal/absolute paths/foreign file3
//! types, manifest must match the catalog, previous same-version copy kept in `.rollback`.5
use crate::core::error::{CoreError, CoreResult};6
use crate::core::modules::registry::{supports_module_api, validate_manifest};7
use crate::core::storage::ensure_private_dir;8
use crate::core::version::compare_versions;9
use serde_json::{json, Value};10
use std::fs;11
use std::io::{Read, Write};12
use std::path::{Path, PathBuf};14
pub const MAX_ENTRIES: usize = 500;15
pub const MAX_UNCOMPRESSED_BYTES: u64 = 100 * 1024 * 1024;16
pub const ALLOWED_EXTENSIONS: &[&str] = &["css", "gif", "html", "jpeg", "jpg", "js", "json", "md", "png", "svg", "txt", "ttf", "webp", "woff", "woff2"];18
pub fn safe_entry_name(value: &str) -> CoreResult<String> {19
let name = value.replace('\\', "/");20
let drive = name.len() >= 3 && name.as_bytes()[1] == b':' && name.as_bytes()[2] == b'/' && name.as_bytes()[0].is_ascii_alphabetic();21
if name.is_empty() || name.contains('\0') || name.starts_with('/') || drive || name.split('/').any(|s| s == "..") {22
return Err(CoreError::plain(format!("Unsafe plugin archive path: {}", if name.is_empty() { "<empty>" } else { &name })));23
}24
Ok(name.strip_prefix("./").unwrap_or(&name).to_string())25
}27
pub struct InstallResult {28
pub manifest: Value,29
pub reused: bool,30
pub path: PathBuf,31
}33
pub struct ModuleInstaller {34
root: PathBuf,35
app_version: String,36
api_version: String,37
}39
fn assert_catalog_match(manifest: &Value, catalog: &Value) -> CoreResult<()> {40
if manifest["id"] != catalog["id"] || manifest["version"] != catalog["version"] {41
return Err(CoreError::plain("Plugin manifest identity does not match the catalog."));42
}43
if manifest["pluginApi"] != catalog["pluginApi"] || manifest["minAppVersion"] != catalog["minAppVersion"] {44
return Err(CoreError::plain("Plugin compatibility metadata does not match the catalog."));45
}46
let sorted = |v: &Value| {47
let mut list: Vec<String> = v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();48
list.sort();49
list50
};51
if sorted(&manifest["permissions"]) != sorted(&catalog["permissions"]) {52
return Err(CoreError::plain("Plugin permissions do not match the catalog."));53
}54
Ok(())55
}57
impl ModuleInstaller {58
pub fn new(data_root: &Path, app_version: &str, api_version: &str) -> Self {59
Self { root: data_root.join("plugins"), app_version: app_version.into(), api_version: api_version.into() }60
}62
pub fn install(&self, package: &Path, catalog: &Value) -> CoreResult<InstallResult> {63
let file = fs::File::open(package)?;64
let mut archive = zip::ZipArchive::new(file).map_err(|e| CoreError::plain(format!("Invalid plugin archive: {e}")))?;65
if archive.is_empty() || archive.len() > MAX_ENTRIES {66
return Err(CoreError::plain("Plugin archive has an invalid number of entries."));67
}68
let mut names = Vec::with_capacity(archive.len());69
let mut total = 0u64;70
let mut manifest_index = None;71
for index in 0..archive.len() {72
let entry = archive.by_index_raw(index).map_err(|e| CoreError::plain(e.to_string()))?;73
let name = safe_entry_name(entry.name())?;74
if entry.unix_mode().map(|m| m & 0o170000 == 0o120000).unwrap_or(false) {75
return Err(CoreError::plain(format!("Plugin archive contains a symbolic link: {name}")));76
}77
total = total.saturating_add(entry.size());78
if total > MAX_UNCOMPRESSED_BYTES {79
return Err(CoreError::plain("Plugin archive exceeds the uncompressed size limit."));80
}81
let is_dir = entry.is_dir();82
if !is_dir {83
let extension = Path::new(&name).extension().map(|e| e.to_string_lossy().to_lowercase()).unwrap_or_default();84
if !ALLOWED_EXTENSIONS.contains(&extension.as_str()) {85
return Err(CoreError::plain(format!("Unsupported plugin file type: {name}")));86
}87
}88
if name == "manifest.json" && !is_dir {89
if entry.size() > 1024 * 1024 {90
return Err(CoreError::plain("Plugin manifest is too large."));91
}92
manifest_index = Some(index);93
}94
names.push((name, is_dir));95
}96
let manifest_index = manifest_index.ok_or_else(|| CoreError::plain("Plugin archive has no root manifest.json."))?;97
let mut raw = String::new();98
archive99
.by_index(manifest_index)100
.map_err(|e| CoreError::plain(e.to_string()))?101
.take(1024 * 1024 + 1)102
.read_to_string(&mut raw)103
.map_err(|e| CoreError::plain(format!("Invalid packaged plugin manifest: {e}")))?;104
let manifest = serde_json::from_str::<Value>(&raw)105
.map_err(|e| CoreError::plain(e.to_string()))106
.and_then(|v| validate_manifest(&v))107
.map_err(|e| CoreError::plain(format!("Invalid packaged plugin manifest: {}", e.message)))?;108
assert_catalog_match(&manifest, catalog)?;109
let api = manifest["pluginApi"].as_str().unwrap_or("");110
if !supports_module_api(api, &self.api_version) {111
return Err(CoreError::plain(format!("Plugin requires unsupported Plugin API {api}.")));112
}113
let min_app = manifest["minAppVersion"].as_str().unwrap_or("0.0.1");114
if compare_versions(&self.app_version, min_app)? == std::cmp::Ordering::Less {115
return Err(CoreError::plain(format!("Plugin requires application {min_app} or newer.")));116
}117
let id = manifest["id"].as_str().unwrap_or("").to_string();118
let version = manifest["version"].as_str().unwrap_or("").to_string();119
let mut suffix = [0u8; 8];120
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut suffix);121
let staging = self.root.join(".staging").join(format!("{id}-{}", hex::encode(suffix)));122
let content = staging.join(&version);123
ensure_private_dir(&content)?;124
let result = (|| -> CoreResult<InstallResult> {125
let mut extracted = 0u64;126
for (index, (name, is_dir)) in names.iter().enumerate() {127
let destination = content.join(name);128
if !destination.starts_with(&content) {129
return Err(CoreError::plain(format!("Plugin path escaped staging: {name}")));130
}131
if *is_dir {132
ensure_private_dir(&destination)?;133
continue;134
}135
if let Some(parent) = destination.parent() {136
ensure_private_dir(parent)?;137
}138
let mut entry = archive.by_index(index).map_err(|e| CoreError::plain(e.to_string()))?;139
let mut data = Vec::new();140
(&mut entry).take(MAX_UNCOMPRESSED_BYTES + 1).read_to_end(&mut data).map_err(|e| CoreError::plain(e.to_string()))?;141
extracted += data.len() as u64;142
if extracted > MAX_UNCOMPRESSED_BYTES {143
return Err(CoreError::plain("Plugin archive exceeds the extracted size limit."));144
}145
let mut options = fs::OpenOptions::new();146
options.write(true).create_new(true);147
#[cfg(unix)]148
{149
use std::os::unix::fs::OpenOptionsExt;150
options.mode(0o600);151
}152
options.open(&destination)?.write_all(&data)?;153
}154
if !content.join(manifest["entry"].as_str().unwrap_or("")).is_file() {155
return Err(CoreError::plain("Packaged plugin entry file is missing."));156
}157
let installation = json!({158
"schemaVersion": 1,159
"releaseId": catalog["releaseId"],160
"sha512": catalog["sha512"],161
"installedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),162
"publishedAt": catalog["publishedAt"]163
});164
fs::write(content.join(".installation.json"), serde_json::to_string_pretty(&installation)?)?;165
let id_root = self.root.join(&id);166
ensure_private_dir(&id_root)?;167
let destination = id_root.join(&version);168
if destination.exists() {169
let existing: Option<Value> = fs::read_to_string(destination.join(".installation.json")).ok().and_then(|t| serde_json::from_str(&t).ok());170
if existing.as_ref().and_then(|e| e.get("sha512")) == catalog.get("sha512") {171
return Ok(InstallResult { manifest: manifest.clone(), reused: true, path: destination });172
}173
let rollback = id_root.join(".rollback");174
ensure_private_dir(&rollback)?;175
fs::rename(&destination, rollback.join(format!("{version}-{}", chrono::Utc::now().timestamp_millis())))?;176
}177
fs::rename(&content, &destination)?;178
Ok(InstallResult { manifest: manifest.clone(), reused: false, path: destination })179
})();180
let _ = fs::remove_dir_all(&staging);181
result182
}183
}185
#[cfg(test)]186
mod tests {187
use super::*;188
use zip::write::SimpleFileOptions;190
fn package(dir: &Path, files: &[(&str, &str)]) -> PathBuf {191
let path = dir.join("pkg.chjplugin");192
let mut writer = zip::ZipWriter::new(fs::File::create(&path).unwrap());193
for (name, content) in files {194
writer.start_file(*name, SimpleFileOptions::default()).unwrap();195
writer.write_all(content.as_bytes()).unwrap();196
}197
writer.finish().unwrap();198
path199
}201
fn manifest() -> String {202
json!({"schemaVersion": 1, "id": "chj.demo", "name": "Demo", "version": "1.0.0", "entry": "ui/index.html", "pluginApi": "^1.0.0", "minAppVersion": "0.0.1", "permissions": ["logs.read"]}).to_string()203
}205
fn catalog() -> Value {206
json!({"id": "chj.demo", "version": "1.0.0", "pluginApi": "^1.0.0", "minAppVersion": "0.0.1", "permissions": ["logs.read"], "releaseId": "r1", "sha512": "a".repeat(128), "publishedAt": "2026-10-24T00:00:00Z"})207
}209
#[test]210
fn installs_and_reuses() {211
let dir = tempfile::tempdir().unwrap();212
let pkg = package(dir.path(), &[("manifest.json", &manifest()), ("ui/index.html", "<p>x</p>")]);213
let installer = ModuleInstaller::new(dir.path(), "0.1.1", "1.7.0");214
let first = installer.install(&pkg, &catalog()).unwrap();215
assert!(!first.reused && first.path.join("ui/index.html").is_file());216
assert!(installer.install(&pkg, &catalog()).unwrap().reused);217
}219
#[test]220
fn rejects_traversal_types_and_mismatch() {221
let dir = tempfile::tempdir().unwrap();222
let installer = ModuleInstaller::new(dir.path(), "0.1.1", "1.7.0");223
let bad_type = package(dir.path(), &[("manifest.json", &manifest()), ("ui/index.html", "x"), ("run.sh", "x")]);224
assert!(installer.install(&bad_type, &catalog()).is_err());225
assert!(safe_entry_name("../x").is_err());226
assert!(safe_entry_name("C:/x").is_err());227
let ok = package(dir.path(), &[("manifest.json", &manifest()), ("ui/index.html", "x")]);228
let mut wrong = catalog();229
wrong["permissions"] = json!(["logs.read", "users.manage"]);230
assert!(installer.install(&ok, &wrong).is_err());231
assert!(!dir.path().join("plugins/.staging").read_dir().map(|mut d| d.next().is_some()).unwrap_or(false));232
}233
}SHA-256
7a6266c76920ad05a4b1d2e5711de05c86579f4b149828cf98581bba175b7922CH-J Proprietary Software License 1.14 · Źródło jest dostarczane pod CH-J Proprietary Software License 1.14. Jego dostępność nie zmienia warunków licencji ani nie przyznaje dodatkowych praw.
SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f