//! Module catalog (port of `plugins/pluginCatalogProvider.js`): `api/plugins.php`, //! strict metadata validation, bounded streaming download with size + SHA-512 checks. //! As in 0.1.0, module packages carry no OpenPGP signature (catalog transport trust only). use crate::core::error::{CoreError, CoreResult}; use crate::core::modules::registry::{ALLOWED_PERMISSIONS, MODULE_ID}; use crate::core::net::https::HttpsClient; use crate::core::storage::{clean_download_cache, remove_cached_file}; use crate::core::updates::contract::{parse_date, server_channel, SHA512_PATTERN}; use crate::core::version::{compare_versions, parse_version}; use crate::security::url_policy::{assert_allowed_url, normalize_base_url, AllowOptions}; use serde_json::{json, Value}; use sha2::{Digest, Sha512}; use std::cmp::Ordering; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; pub const MAX_CATALOG_BYTES: usize = 512 * 1024; pub const MAX_PACKAGE_BYTES: u64 = 512 * 1024 * 1024; fn required(source: &Value, key: &str, fallback: Option<&str>) -> CoreResult { let value = match source.get(key) { Some(Value::String(s)) if !s.trim().is_empty() => s.trim().to_string(), Some(Value::Number(n)) => n.to_string(), _ => fallback.unwrap_or("").to_string(), }; if value.is_empty() { Err(CoreError::plain(format!("Missing {key}."))) } else { Ok(value) } } pub fn validate_catalog(payload: &Value, channel: &str) -> CoreResult> { let list = payload.get("plugins").and_then(Value::as_array).ok_or_else(|| CoreError::plain("Plugin catalog must contain a plugins array."))?; let expected_channel = server_channel(channel)?; let mut ids = std::collections::HashSet::new(); let mut result = Vec::new(); for source in list { let id = required(source, "plugin_id", None)?; if !MODULE_ID.is_match(&id) { return Err(CoreError::plain(format!("Invalid plugin ID: {id}"))); } if !ids.insert(id.clone()) { return Err(CoreError::plain(format!("Duplicate plugin ID: {id}"))); } let version = required(source, "version", None)?; parse_version(&version)?; let min_app = required(source, "min_app_version", Some("0.0.1"))?; parse_version(&min_app)?; let api = required(source, "plugin_api", Some("^1.0.0"))?; if !regex::Regex::new(r"^\^1\.\d+\.\d+$").unwrap().is_match(&api) { return Err(CoreError::plain(format!("Unsupported plugin API: {api}"))); } let mut permissions: Vec = Vec::new(); for p in source.get("permissions").and_then(Value::as_array).cloned().unwrap_or_default() { let p = p.as_str().map(str::to_string).unwrap_or_else(|| p.to_string()); if !ALLOWED_PERMISSIONS.contains(&p.as_str()) { return Err(CoreError::plain(format!("Unsupported plugin permission: {p}"))); } if !permissions.contains(&p) { permissions.push(p); } } let sha512 = required(source, "sha512", None)?.to_lowercase(); if !SHA512_PATTERN.is_match(&sha512) { return Err(CoreError::plain(format!("Invalid SHA-512 for {id}"))); } 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); if size.fract() != 0.0 || size <= 0.0 || size > MAX_PACKAGE_BYTES as f64 { return Err(CoreError::plain(format!("Invalid package size for {id}"))); } let item_channel = required(source, "channel", Some("stable"))?.to_lowercase(); if item_channel != expected_channel { return Err(CoreError::plain(format!("Unexpected plugin channel: {item_channel}"))); } let published_at = required(source, "published_at", None)?; if parse_date(&published_at).is_none() { return Err(CoreError::plain(format!("Invalid publication date for {id}"))); } let download = source.get("download_url").or(source.get("download_path")).cloned().unwrap_or(Value::Null); result.push(json!({ "releaseId": required(source, "id", None)?, "id": id, "name": required(source, "name", Some(&id))?, "version": version, "minAppVersion": min_app, "pluginApi": api, "permissions": permissions, "channel": item_channel, "notes": source.get("notes").and_then(Value::as_str).unwrap_or(""), "filename": required(source, "filename", None)?, "size": size as u64, "sha512": sha512, "downloadUrl": required(&json!({ "download_url": download }), "download_url", None)?, "publishedAt": published_at })); } Ok(result) } pub struct CatalogProvider { base_urls: Vec, client: Arc, download_root: PathBuf, logger: Arc, } fn sanitize(value: &str) -> String { value.chars().map(|c| if c.is_ascii_alphanumeric() || "._-".contains(c) { c } else { '-' }).collect() } impl CatalogProvider { pub fn new(base_urls: &[String], client: Arc, download_root: PathBuf, logger: Arc) -> CoreResult { let base_urls = base_urls.iter().map(|u| normalize_base_url(u).map(|u| u.to_string())).collect::>>()?; if base_urls.is_empty() { return Err(CoreError::plain("At least one plugin catalog URL is required.")); } Ok(Self { base_urls, client, download_root, logger }) } fn resolve_file_url(&self, value: &str, base: &str) -> CoreResult { let joined = url::Url::parse(base).and_then(|b| b.join(value.trim_start_matches('/'))).map_err(|_| CoreError::plain("Invalid plugin file URL."))?; assert_allowed_url(joined.as_str(), &self.base_urls, &AllowOptions { path_segment: Some("files/"), ..Default::default() }) } pub async fn list(&self, app_version: &str, api_version: &str, channel: &str) -> CoreResult<(Vec, String)> { let channels: Vec = if channel == "all" { vec!["alpha".into(), "beta".into(), "stable".into()] } else { vec![server_channel(channel)?] }; let mut errors = Vec::new(); for base in &self.base_urls { let attempt = async { let mut by_release: Vec = Vec::new(); for selected in &channels { let mut url = url::Url::parse(base).and_then(|b| b.join("api/plugins.php")).map_err(|_| CoreError::plain("Invalid catalog URL."))?; url.query_pairs_mut().append_pair("app_version", app_version).append_pair("plugin_api", api_version).append_pair("channel", selected); assert_allowed_url(url.as_str(), &self.base_urls, &AllowOptions { path_segment: Some("api/"), ..Default::default() })?; let (status, text) = self.client.get_text(&url, "application/json", Duration::from_secs(5), MAX_CATALOG_BYTES).await?; if status != 200 { return Err(CoreError::plain(format!("Plugin server returned HTTP {status}."))); } let payload: Value = serde_json::from_str(&text).map_err(|_| CoreError::plain("Plugin server returned invalid JSON."))?; for mut item in validate_catalog(&payload, selected)? { let resolved = self.resolve_file_url(item["downloadUrl"].as_str().unwrap_or(""), base)?; item["downloadUrl"] = Value::String(resolved.to_string()); by_release.retain(|existing| existing["releaseId"] != item["releaseId"]); by_release.push(item); } } by_release.sort_by(|a, b| { a["id"].as_str().cmp(&b["id"].as_str()) .then_with(|| compare_versions(b["version"].as_str().unwrap_or("0.0"), a["version"].as_str().unwrap_or("0.0")).unwrap_or(Ordering::Equal)) .then_with(|| parse_date(b["publishedAt"].as_str().unwrap_or("")).cmp(&parse_date(a["publishedAt"].as_str().unwrap_or("")))) }); Ok::<_, CoreError>(by_release) } .await; match attempt { Ok(list) => return Ok((list, base.clone())), Err(error) => { self.logger.warn("Plugin catalog endpoint failed.", json!({ "baseUrl": base, "message": error.message })); errors.push(json!({ "baseUrl": base, "message": error.message })); } } } Err(CoreError::new("PLUGIN_CATALOG_UNREACHABLE", "No configured plugin catalog endpoint is reachable.").with("details", Value::Array(errors))) } pub async fn download_and_verify(&self, module: &Value) -> CoreResult { let size = module["size"].as_u64().unwrap_or(0); if size == 0 || size > MAX_PACKAGE_BYTES { return Err(CoreError::plain("Plugin package size is outside the allowed range.")); } let sha512 = module["sha512"].as_str().unwrap_or("").to_string(); let target_dir = self.download_root.join(sanitize(module["id"].as_str().unwrap_or("x"))).join(sanitize(&format!( "{}-{}", module["version"].as_str().unwrap_or(""), module["releaseId"].as_str().unwrap_or("") ))); 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()); let destination = target_dir.join(file_name); std::fs::create_dir_all(&target_dir)?; if destination.is_file() && std::fs::metadata(&destination)?.len() == size && crate::core::hashing_util::sha512_file(&destination)? == sha512 { return Ok(destination); } let _ = std::fs::remove_file(&destination); let original = self.resolve_file_url(module["downloadUrl"].as_str().unwrap_or(""), &self.base_urls[0])?; 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."))?; let relative = format!("{}{}", &original.path()[source_base.path().len()..], original.query().map(|q| format!("?{q}")).unwrap_or_default()); let mut candidates = vec![original.clone()]; for base in &self.base_urls { let candidate = url::Url::parse(base).and_then(|b| b.join(&relative)).map_err(|_| CoreError::plain("Invalid plugin file URL."))?; assert_allowed_url(candidate.as_str(), &self.base_urls, &AllowOptions { path_segment: Some("files/"), ..Default::default() })?; if !candidates.contains(&candidate) { candidates.push(candidate); } } let invalid = |message: &str| CoreError::new("PLUGIN_ARTIFACT_INVALID", message); let mut failures = Vec::new(); for url in candidates { let mut suffix = [0u8; 6]; rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut suffix); let temp = destination.with_file_name(format!("{}.{}.part", destination.file_name().unwrap().to_string_lossy(), hex::encode(suffix))); let attempt = async { let mut response = self.client.get(&url, "application/octet-stream", Duration::from_secs(600)).await?; if response.status().as_u16() != 200 { return Err(CoreError::plain(format!("Plugin download returned HTTP {}.", response.status().as_u16()))); } if let Some(declared) = response.content_length() { if declared > 0 && declared != size { return Err(invalid("Plugin Content-Length does not match the catalog.")); } } let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&temp)?; let mut hasher = Sha512::new(); let mut received = 0u64; while let Some(chunk) = response.chunk().await.map_err(|e| CoreError::plain(e.to_string()))? { received += chunk.len() as u64; if received > size { return Err(invalid("Plugin package exceeds its catalog size.")); } hasher.update(&chunk); file.write_all(&chunk)?; } file.sync_all()?; if received != size { return Err(invalid("Plugin package size does not match the catalog.")); } if hex::encode(hasher.finalize()) != sha512 { return Err(invalid("Plugin package SHA-512 does not match the catalog.")); } std::fs::rename(&temp, &destination)?; Ok(()) } .await; match attempt { Ok(()) => return Ok(destination), Err(error) => { let _ = std::fs::remove_file(&temp); if error.code == "PLUGIN_ARTIFACT_INVALID" { return Err(error); } self.logger.warn("Plugin download endpoint failed; trying fallback.", json!({ "url": url.as_str(), "message": error.message })); failures.push(json!({ "url": url.as_str(), "message": error.message })); } } } Err(CoreError::new("PLUGIN_DOWNLOAD_UNREACHABLE", "Plugin download failed on all configured endpoints.").with("details", Value::Array(failures))) } pub fn cleanup_downloads(&self) -> CoreResult { Ok(clean_download_cache(&self.download_root)?.0) } pub fn remove_downloaded(&self, path: &Path) -> CoreResult { remove_cached_file(&self.download_root, path) } } #[cfg(test)] mod tests { use super::*; #[test] fn catalog_validation() { 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"}); let list = validate_catalog(&json!({"plugins": [entry.clone()]}), "alpha").unwrap(); assert_eq!(list[0]["sha512"], "a".repeat(128)); assert_eq!(list[0]["pluginApi"], "^1.0.0"); assert!(validate_catalog(&json!({"plugins": [entry.clone()]}), "beta").is_err()); let mut bad = entry.clone(); bad["permissions"] = json!(["shell.everything"]); assert!(validate_catalog(&json!({"plugins": [bad]}), "alpha").is_err()); assert!(validate_catalog(&json!({"plugins": [entry.clone(), entry]}), "alpha").is_err()); } }