CH-J Toolsupravljanje strežnikov prek SSH
Meni
Objavljen vir

CH-J Tools

Brskajte po imenikih in datotekah za določeno izdajo programa.

Prenos vir ZIP
CH-J / Server Manager 1704 datoteke
0.1.2-beta Dodano: · Zgrajena:
installer.rs Dodano: Datum, ki ni zabeležen 11,5 KB · 233 vrstice
Oznaka Prenesi datoteko
1//! Module package installer (port of `plugins/pluginInstaller.js`): bounded ZIP extraction
2//! into a private staging directory, no symlinks/traversal/absolute paths/foreign file
3//! types, manifest must match the catalog, previous same-version copy kept in `.rollback`.
5use crate::core::error::{CoreError, CoreResult};
6use crate::core::modules::registry::{supports_module_api, validate_manifest};
7use crate::core::storage::ensure_private_dir;
8use crate::core::version::compare_versions;
9use serde_json::{json, Value};
10use std::fs;
11use std::io::{Read, Write};
12use std::path::{Path, PathBuf};
14pub const MAX_ENTRIES: usize = 500;
15pub const MAX_UNCOMPRESSED_BYTES: u64 = 100 * 1024 * 1024;
16pub const ALLOWED_EXTENSIONS: &[&str] = &["css", "gif", "html", "jpeg", "jpg", "js", "json", "md", "png", "svg", "txt", "ttf", "webp", "woff", "woff2"];
18pub 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())
27pub struct InstallResult {
28 pub manifest: Value,
29 pub reused: bool,
30 pub path: PathBuf,
33pub struct ModuleInstaller {
34 root: PathBuf,
35 app_version: String,
36 api_version: String,
39fn 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 list
50 };
51 if sorted(&manifest["permissions"]) != sorted(&catalog["permissions"]) {
52 return Err(CoreError::plain("Plugin permissions do not match the catalog."));
53 }
54 Ok(())
57impl 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 archive
99 .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 result
182 }
185#[cfg(test)]
186mod 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 path
199 }
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 }
SHA-2567a6266c76920ad05a4b1d2e5711de05c86579f4b149828cf98581bba175b7922

CH-J Proprietary Software License 1.14 · Vir je na voljo pod CH-J Proprietary Software License 1.14. Njena razpoložljivost ne spreminja pogojev licence ali dodeljuje dodatnih pravic.

Arhiv SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Datoteke