CH-J Toolsspráva serverů přes SSH
Menu
Vydané zdroje

CH-J Tools

Procházejte adresáře a soubory konkrétního vydání aplikace.

Stáhnout zdroje ZIP
CH-J / Server Manager 872 souborů
core-20261030T220507Z-61e4558d889f Přidáno: · Sestaveno:
catalog.rs Přidáno: Datum není evidováno 14,9 KB · 277 řádků
Kód Stáhnout soubor
1//! Module catalog (port of `plugins/pluginCatalogProvider.js`): `api/plugins.php`,
2//! strict metadata validation, bounded streaming download with size + SHA-512 checks.
3//! As in 0.1.0, module packages carry no OpenPGP signature (catalog transport trust only).
5use crate::core::error::{CoreError, CoreResult};
6use crate::core::modules::registry::{ALLOWED_PERMISSIONS, MODULE_ID};
7use crate::core::net::https::HttpsClient;
8use crate::core::storage::{clean_download_cache, remove_cached_file};
9use crate::core::updates::contract::{parse_date, server_channel, SHA512_PATTERN};
10use crate::core::version::{compare_versions, parse_version};
11use crate::security::url_policy::{assert_allowed_url, normalize_base_url, AllowOptions};
12use serde_json::{json, Value};
13use sha2::{Digest, Sha512};
14use std::cmp::Ordering;
15use std::io::Write;
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18use std::time::Duration;
20pub const MAX_CATALOG_BYTES: usize = 512 * 1024;
21pub const MAX_PACKAGE_BYTES: u64 = 512 * 1024 * 1024;
23fn required(source: &Value, key: &str, fallback: Option<&str>) -> CoreResult<String> {
24 let value = match source.get(key) {
25 Some(Value::String(s)) if !s.trim().is_empty() => s.trim().to_string(),
26 Some(Value::Number(n)) => n.to_string(),
27 _ => fallback.unwrap_or("").to_string(),
28 };
29 if value.is_empty() {
30 Err(CoreError::plain(format!("Missing {key}.")))
31 } else {
32 Ok(value)
33 }
36pub fn validate_catalog(payload: &Value, channel: &str) -> CoreResult<Vec<Value>> {
37 let list = payload.get("plugins").and_then(Value::as_array).ok_or_else(|| CoreError::plain("Plugin catalog must contain a plugins array."))?;
38 let expected_channel = server_channel(channel)?;
39 let mut ids = std::collections::HashSet::new();
40 let mut result = Vec::new();
41 for source in list {
42 let id = required(source, "plugin_id", None)?;
43 if !MODULE_ID.is_match(&id) {
44 return Err(CoreError::plain(format!("Invalid plugin ID: {id}")));
45 }
46 if !ids.insert(id.clone()) {
47 return Err(CoreError::plain(format!("Duplicate plugin ID: {id}")));
48 }
49 let version = required(source, "version", None)?;
50 parse_version(&version)?;
51 let min_app = required(source, "min_app_version", Some("0.0.1"))?;
52 parse_version(&min_app)?;
53 let api = required(source, "plugin_api", Some("^1.0.0"))?;
54 if !regex::Regex::new(r"^\^1\.\d+\.\d+$").unwrap().is_match(&api) {
55 return Err(CoreError::plain(format!("Unsupported plugin API: {api}")));
56 }
57 let mut permissions: Vec<String> = Vec::new();
58 for p in source.get("permissions").and_then(Value::as_array).cloned().unwrap_or_default() {
59 let p = p.as_str().map(str::to_string).unwrap_or_else(|| p.to_string());
60 if !ALLOWED_PERMISSIONS.contains(&p.as_str()) {
61 return Err(CoreError::plain(format!("Unsupported plugin permission: {p}")));
62 }
63 if !permissions.contains(&p) {
64 permissions.push(p);
65 }
66 }
67 let sha512 = required(source, "sha512", None)?.to_lowercase();
68 if !SHA512_PATTERN.is_match(&sha512) {
69 return Err(CoreError::plain(format!("Invalid SHA-512 for {id}")));
70 }
71 let size = source.get("size").and_then(|v| v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))).unwrap_or(0.0);
72 if size.fract() != 0.0 || size <= 0.0 || size > MAX_PACKAGE_BYTES as f64 {
73 return Err(CoreError::plain(format!("Invalid package size for {id}")));
74 }
75 let item_channel = required(source, "channel", Some("stable"))?.to_lowercase();
76 if item_channel != expected_channel {
77 return Err(CoreError::plain(format!("Unexpected plugin channel: {item_channel}")));
78 }
79 let published_at = required(source, "published_at", None)?;
80 if parse_date(&published_at).is_none() {
81 return Err(CoreError::plain(format!("Invalid publication date for {id}")));
82 }
83 let download = source.get("download_url").or(source.get("download_path")).cloned().unwrap_or(Value::Null);
84 result.push(json!({
85 "releaseId": required(source, "id", None)?,
86 "id": id,
87 "name": required(source, "name", Some(&id))?,
88 "version": version,
89 "minAppVersion": min_app,
90 "pluginApi": api,
91 "permissions": permissions,
92 "channel": item_channel,
93 "notes": source.get("notes").and_then(Value::as_str).unwrap_or(""),
94 "filename": required(source, "filename", None)?,
95 "size": size as u64,
96 "sha512": sha512,
97 "downloadUrl": required(&json!({ "download_url": download }), "download_url", None)?,
98 "publishedAt": published_at
99 }));
100 }
101 Ok(result)
104pub struct CatalogProvider {
105 base_urls: Vec<String>,
106 client: Arc<HttpsClient>,
107 download_root: PathBuf,
108 logger: Arc<crate::core::logging::Logger>,
111fn sanitize(value: &str) -> String {
112 value.chars().map(|c| if c.is_ascii_alphanumeric() || "._-".contains(c) { c } else { '-' }).collect()
115impl CatalogProvider {
116 pub fn new(base_urls: &[String], client: Arc<HttpsClient>, download_root: PathBuf, logger: Arc<crate::core::logging::Logger>) -> CoreResult<Self> {
117 let base_urls = base_urls.iter().map(|u| normalize_base_url(u).map(|u| u.to_string())).collect::<CoreResult<Vec<_>>>()?;
118 if base_urls.is_empty() {
119 return Err(CoreError::plain("At least one plugin catalog URL is required."));
120 }
121 Ok(Self { base_urls, client, download_root, logger })
122 }
124 fn resolve_file_url(&self, value: &str, base: &str) -> CoreResult<url::Url> {
125 let joined = url::Url::parse(base).and_then(|b| b.join(value.trim_start_matches('/'))).map_err(|_| CoreError::plain("Invalid plugin file URL."))?;
126 assert_allowed_url(joined.as_str(), &self.base_urls, &AllowOptions { path_segment: Some("files/"), ..Default::default() })
127 }
129 pub async fn list(&self, app_version: &str, api_version: &str, channel: &str) -> CoreResult<(Vec<Value>, String)> {
130 let channels: Vec<String> = if channel == "all" { vec!["alpha".into(), "beta".into(), "stable".into()] } else { vec![server_channel(channel)?] };
131 let mut errors = Vec::new();
132 for base in &self.base_urls {
133 let attempt = async {
134 let mut by_release: Vec<Value> = Vec::new();
135 for selected in &channels {
136 let mut url = url::Url::parse(base).and_then(|b| b.join("api/plugins.php")).map_err(|_| CoreError::plain("Invalid catalog URL."))?;
137 url.query_pairs_mut().append_pair("app_version", app_version).append_pair("plugin_api", api_version).append_pair("channel", selected);
138 assert_allowed_url(url.as_str(), &self.base_urls, &AllowOptions { path_segment: Some("api/"), ..Default::default() })?;
139 let (status, text) = self.client.get_text(&url, "application/json", Duration::from_secs(5), MAX_CATALOG_BYTES).await?;
140 if status != 200 {
141 return Err(CoreError::plain(format!("Plugin server returned HTTP {status}.")));
142 }
143 let payload: Value = serde_json::from_str(&text).map_err(|_| CoreError::plain("Plugin server returned invalid JSON."))?;
144 for mut item in validate_catalog(&payload, selected)? {
145 let resolved = self.resolve_file_url(item["downloadUrl"].as_str().unwrap_or(""), base)?;
146 item["downloadUrl"] = Value::String(resolved.to_string());
147 by_release.retain(|existing| existing["releaseId"] != item["releaseId"]);
148 by_release.push(item);
149 }
150 }
151 by_release.sort_by(|a, b| {
152 a["id"].as_str().cmp(&b["id"].as_str())
153 .then_with(|| compare_versions(b["version"].as_str().unwrap_or("0.0"), a["version"].as_str().unwrap_or("0.0")).unwrap_or(Ordering::Equal))
154 .then_with(|| parse_date(b["publishedAt"].as_str().unwrap_or("")).cmp(&parse_date(a["publishedAt"].as_str().unwrap_or(""))))
155 });
156 Ok::<_, CoreError>(by_release)
157 }
158 .await;
159 match attempt {
160 Ok(list) => return Ok((list, base.clone())),
161 Err(error) => {
162 self.logger.warn("Plugin catalog endpoint failed.", json!({ "baseUrl": base, "message": error.message }));
163 errors.push(json!({ "baseUrl": base, "message": error.message }));
164 }
165 }
166 }
167 Err(CoreError::new("PLUGIN_CATALOG_UNREACHABLE", "No configured plugin catalog endpoint is reachable.").with("details", Value::Array(errors)))
168 }
170 pub async fn download_and_verify(&self, module: &Value) -> CoreResult<PathBuf> {
171 let size = module["size"].as_u64().unwrap_or(0);
172 if size == 0 || size > MAX_PACKAGE_BYTES {
173 return Err(CoreError::plain("Plugin package size is outside the allowed range."));
174 }
175 let sha512 = module["sha512"].as_str().unwrap_or("").to_string();
176 let target_dir = self.download_root.join(sanitize(module["id"].as_str().unwrap_or("x"))).join(sanitize(&format!(
177 "{}-{}",
178 module["version"].as_str().unwrap_or(""),
179 module["releaseId"].as_str().unwrap_or("")
180 )));
181 let file_name = Path::new(module["filename"].as_str().unwrap_or("")).file_name().map(|n| sanitize(&n.to_string_lossy())).filter(|n| !n.is_empty()).unwrap_or_else(|| "plugin.chjplugin".into());
182 let destination = target_dir.join(file_name);
183 std::fs::create_dir_all(&target_dir)?;
184 if destination.is_file() && std::fs::metadata(&destination)?.len() == size && crate::core::hashing_util::sha512_file(&destination)? == sha512 {
185 return Ok(destination);
186 }
187 let _ = std::fs::remove_file(&destination);
188 let original = self.resolve_file_url(module["downloadUrl"].as_str().unwrap_or(""), &self.base_urls[0])?;
189 let source_base = self.base_urls.iter().filter_map(|b| url::Url::parse(b).ok()).find(|b| b.origin() == original.origin() && original.path().starts_with(b.path())).ok_or_else(|| CoreError::plain("Plugin file URL is outside configured base paths."))?;
190 let relative = format!("{}{}", &original.path()[source_base.path().len()..], original.query().map(|q| format!("?{q}")).unwrap_or_default());
191 let mut candidates = vec![original.clone()];
192 for base in &self.base_urls {
193 let candidate = url::Url::parse(base).and_then(|b| b.join(&relative)).map_err(|_| CoreError::plain("Invalid plugin file URL."))?;
194 assert_allowed_url(candidate.as_str(), &self.base_urls, &AllowOptions { path_segment: Some("files/"), ..Default::default() })?;
195 if !candidates.contains(&candidate) {
196 candidates.push(candidate);
197 }
198 }
199 let invalid = |message: &str| CoreError::new("PLUGIN_ARTIFACT_INVALID", message);
200 let mut failures = Vec::new();
201 for url in candidates {
202 let mut suffix = [0u8; 6];
203 rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut suffix);
204 let temp = destination.with_file_name(format!("{}.{}.part", destination.file_name().unwrap().to_string_lossy(), hex::encode(suffix)));
205 let attempt = async {
206 let mut response = self.client.get(&url, "application/octet-stream", Duration::from_secs(600)).await?;
207 if response.status().as_u16() != 200 {
208 return Err(CoreError::plain(format!("Plugin download returned HTTP {}.", response.status().as_u16())));
209 }
210 if let Some(declared) = response.content_length() {
211 if declared > 0 && declared != size {
212 return Err(invalid("Plugin Content-Length does not match the catalog."));
213 }
214 }
215 let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&temp)?;
216 let mut hasher = Sha512::new();
217 let mut received = 0u64;
218 while let Some(chunk) = response.chunk().await.map_err(|e| CoreError::plain(e.to_string()))? {
219 received += chunk.len() as u64;
220 if received > size {
221 return Err(invalid("Plugin package exceeds its catalog size."));
222 }
223 hasher.update(&chunk);
224 file.write_all(&chunk)?;
225 }
226 file.sync_all()?;
227 if received != size {
228 return Err(invalid("Plugin package size does not match the catalog."));
229 }
230 if hex::encode(hasher.finalize()) != sha512 {
231 return Err(invalid("Plugin package SHA-512 does not match the catalog."));
232 }
233 std::fs::rename(&temp, &destination)?;
234 Ok(())
235 }
236 .await;
237 match attempt {
238 Ok(()) => return Ok(destination),
239 Err(error) => {
240 let _ = std::fs::remove_file(&temp);
241 if error.code == "PLUGIN_ARTIFACT_INVALID" {
242 return Err(error);
243 }
244 self.logger.warn("Plugin download endpoint failed; trying fallback.", json!({ "url": url.as_str(), "message": error.message }));
245 failures.push(json!({ "url": url.as_str(), "message": error.message }));
246 }
247 }
248 }
249 Err(CoreError::new("PLUGIN_DOWNLOAD_UNREACHABLE", "Plugin download failed on all configured endpoints.").with("details", Value::Array(failures)))
250 }
252 pub fn cleanup_downloads(&self) -> CoreResult<bool> {
253 Ok(clean_download_cache(&self.download_root)?.0)
254 }
256 pub fn remove_downloaded(&self, path: &Path) -> CoreResult<bool> {
257 remove_cached_file(&self.download_root, path)
258 }
261#[cfg(test)]
262mod tests {
263 use super::*;
265 #[test]
266 fn catalog_validation() {
267 let entry = json!({"id": "r1", "plugin_id": "chj.demo", "name": "Demo", "version": "1.0.0", "permissions": ["logs.read"], "sha512": "A".repeat(128), "size": 100, "channel": "alpha", "published_at": "2026-10-24T10:00:00Z", "filename": "demo.chjplugin", "download_url": "files/plugins/demo.chjplugin"});
268 let list = validate_catalog(&json!({"plugins": [entry.clone()]}), "alpha").unwrap();
269 assert_eq!(list[0]["sha512"], "a".repeat(128));
270 assert_eq!(list[0]["pluginApi"], "^1.0.0");
271 assert!(validate_catalog(&json!({"plugins": [entry.clone()]}), "beta").is_err());
272 let mut bad = entry.clone();
273 bad["permissions"] = json!(["shell.everything"]);
274 assert!(validate_catalog(&json!({"plugins": [bad]}), "alpha").is_err());
275 assert!(validate_catalog(&json!({"plugins": [entry.clone(), entry]}), "alpha").is_err());
276 }
SHA-25699c4a01af740fce558a5a6a7f4e952614116f4b6c3cc292ede1ee20e1fe72f60

CH-J Proprietary Software License 1.14 · Zdroje jsou zveřejněny pod CH-J Proprietary Software License 1.14. Jejich dostupnost nemění licenční podmínky ani neposkytuje další oprávnění.

SHA-256 archivu77380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df
Soubory