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 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).5
use crate::core::error::{CoreError, CoreResult};6
use crate::core::modules::registry::{ALLOWED_PERMISSIONS, MODULE_ID};7
use crate::core::net::https::HttpsClient;8
use crate::core::storage::{clean_download_cache, remove_cached_file};9
use crate::core::updates::contract::{parse_date, server_channel, SHA512_PATTERN};10
use crate::core::version::{compare_versions, parse_version};11
use crate::security::url_policy::{assert_allowed_url, normalize_base_url, AllowOptions};12
use serde_json::{json, Value};13
use sha2::{Digest, Sha512};14
use std::cmp::Ordering;15
use std::io::Write;16
use std::path::{Path, PathBuf};17
use std::sync::Arc;18
use std::time::Duration;20
pub const MAX_CATALOG_BYTES: usize = 512 * 1024;21
pub const MAX_PACKAGE_BYTES: u64 = 512 * 1024 * 1024;23
fn 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
}34
}36
pub 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_at99
}));100
}101
Ok(result)102
}104
pub struct CatalogProvider {105
base_urls: Vec<String>,106
client: Arc<HttpsClient>,107
download_root: PathBuf,108
logger: Arc<crate::core::logging::Logger>,109
}111
fn sanitize(value: &str) -> String {112
value.chars().map(|c| if c.is_ascii_alphanumeric() || "._-".contains(c) { c } else { '-' }).collect()113
}115
impl 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
}259
}261
#[cfg(test)]262
mod 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
}277
}SHA-256
99c4a01af740fce558a5a6a7f4e952614116f4b6c3cc292ede1ee20e1fe72f60CH-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