//! Module package installer (port of `plugins/pluginInstaller.js`): bounded ZIP extraction //! into a private staging directory, no symlinks/traversal/absolute paths/foreign file //! types, manifest must match the catalog, previous same-version copy kept in `.rollback`. use crate::core::error::{CoreError, CoreResult}; use crate::core::modules::registry::{supports_module_api, validate_manifest}; use crate::core::storage::ensure_private_dir; use crate::core::version::compare_versions; use serde_json::{json, Value}; use std::fs; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; pub const MAX_ENTRIES: usize = 500; pub const MAX_UNCOMPRESSED_BYTES: u64 = 100 * 1024 * 1024; pub const ALLOWED_EXTENSIONS: &[&str] = &["css", "gif", "html", "jpeg", "jpg", "js", "json", "md", "png", "svg", "txt", "ttf", "webp", "woff", "woff2"]; pub fn safe_entry_name(value: &str) -> CoreResult { let name = value.replace('\\', "/"); let drive = name.len() >= 3 && name.as_bytes()[1] == b':' && name.as_bytes()[2] == b'/' && name.as_bytes()[0].is_ascii_alphabetic(); if name.is_empty() || name.contains('\0') || name.starts_with('/') || drive || name.split('/').any(|s| s == "..") { return Err(CoreError::plain(format!("Unsafe plugin archive path: {}", if name.is_empty() { "" } else { &name }))); } Ok(name.strip_prefix("./").unwrap_or(&name).to_string()) } pub struct InstallResult { pub manifest: Value, pub reused: bool, pub path: PathBuf, } pub struct ModuleInstaller { root: PathBuf, app_version: String, api_version: String, } fn assert_catalog_match(manifest: &Value, catalog: &Value) -> CoreResult<()> { if manifest["id"] != catalog["id"] || manifest["version"] != catalog["version"] { return Err(CoreError::plain("Plugin manifest identity does not match the catalog.")); } if manifest["pluginApi"] != catalog["pluginApi"] || manifest["minAppVersion"] != catalog["minAppVersion"] { return Err(CoreError::plain("Plugin compatibility metadata does not match the catalog.")); } let sorted = |v: &Value| { let mut list: Vec = v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default(); list.sort(); list }; if sorted(&manifest["permissions"]) != sorted(&catalog["permissions"]) { return Err(CoreError::plain("Plugin permissions do not match the catalog.")); } Ok(()) } impl ModuleInstaller { pub fn new(data_root: &Path, app_version: &str, api_version: &str) -> Self { Self { root: data_root.join("plugins"), app_version: app_version.into(), api_version: api_version.into() } } pub fn install(&self, package: &Path, catalog: &Value) -> CoreResult { let file = fs::File::open(package)?; let mut archive = zip::ZipArchive::new(file).map_err(|e| CoreError::plain(format!("Invalid plugin archive: {e}")))?; if archive.is_empty() || archive.len() > MAX_ENTRIES { return Err(CoreError::plain("Plugin archive has an invalid number of entries.")); } let mut names = Vec::with_capacity(archive.len()); let mut total = 0u64; let mut manifest_index = None; for index in 0..archive.len() { let entry = archive.by_index_raw(index).map_err(|e| CoreError::plain(e.to_string()))?; let name = safe_entry_name(entry.name())?; if entry.unix_mode().map(|m| m & 0o170000 == 0o120000).unwrap_or(false) { return Err(CoreError::plain(format!("Plugin archive contains a symbolic link: {name}"))); } total = total.saturating_add(entry.size()); if total > MAX_UNCOMPRESSED_BYTES { return Err(CoreError::plain("Plugin archive exceeds the uncompressed size limit.")); } let is_dir = entry.is_dir(); if !is_dir { let extension = Path::new(&name).extension().map(|e| e.to_string_lossy().to_lowercase()).unwrap_or_default(); if !ALLOWED_EXTENSIONS.contains(&extension.as_str()) { return Err(CoreError::plain(format!("Unsupported plugin file type: {name}"))); } } if name == "manifest.json" && !is_dir { if entry.size() > 1024 * 1024 { return Err(CoreError::plain("Plugin manifest is too large.")); } manifest_index = Some(index); } names.push((name, is_dir)); } let manifest_index = manifest_index.ok_or_else(|| CoreError::plain("Plugin archive has no root manifest.json."))?; let mut raw = String::new(); archive .by_index(manifest_index) .map_err(|e| CoreError::plain(e.to_string()))? .take(1024 * 1024 + 1) .read_to_string(&mut raw) .map_err(|e| CoreError::plain(format!("Invalid packaged plugin manifest: {e}")))?; let manifest = serde_json::from_str::(&raw) .map_err(|e| CoreError::plain(e.to_string())) .and_then(|v| validate_manifest(&v)) .map_err(|e| CoreError::plain(format!("Invalid packaged plugin manifest: {}", e.message)))?; assert_catalog_match(&manifest, catalog)?; let api = manifest["pluginApi"].as_str().unwrap_or(""); if !supports_module_api(api, &self.api_version) { return Err(CoreError::plain(format!("Plugin requires unsupported Plugin API {api}."))); } let min_app = manifest["minAppVersion"].as_str().unwrap_or("0.0.1"); if compare_versions(&self.app_version, min_app)? == std::cmp::Ordering::Less { return Err(CoreError::plain(format!("Plugin requires application {min_app} or newer."))); } let id = manifest["id"].as_str().unwrap_or("").to_string(); let version = manifest["version"].as_str().unwrap_or("").to_string(); let mut suffix = [0u8; 8]; rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut suffix); let staging = self.root.join(".staging").join(format!("{id}-{}", hex::encode(suffix))); let content = staging.join(&version); ensure_private_dir(&content)?; let result = (|| -> CoreResult { let mut extracted = 0u64; for (index, (name, is_dir)) in names.iter().enumerate() { let destination = content.join(name); if !destination.starts_with(&content) { return Err(CoreError::plain(format!("Plugin path escaped staging: {name}"))); } if *is_dir { ensure_private_dir(&destination)?; continue; } if let Some(parent) = destination.parent() { ensure_private_dir(parent)?; } let mut entry = archive.by_index(index).map_err(|e| CoreError::plain(e.to_string()))?; let mut data = Vec::new(); (&mut entry).take(MAX_UNCOMPRESSED_BYTES + 1).read_to_end(&mut data).map_err(|e| CoreError::plain(e.to_string()))?; extracted += data.len() as u64; if extracted > MAX_UNCOMPRESSED_BYTES { return Err(CoreError::plain("Plugin archive exceeds the extracted size limit.")); } let mut options = fs::OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } options.open(&destination)?.write_all(&data)?; } if !content.join(manifest["entry"].as_str().unwrap_or("")).is_file() { return Err(CoreError::plain("Packaged plugin entry file is missing.")); } let installation = json!({ "schemaVersion": 1, "releaseId": catalog["releaseId"], "sha512": catalog["sha512"], "installedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), "publishedAt": catalog["publishedAt"] }); fs::write(content.join(".installation.json"), serde_json::to_string_pretty(&installation)?)?; let id_root = self.root.join(&id); ensure_private_dir(&id_root)?; let destination = id_root.join(&version); if destination.exists() { let existing: Option = fs::read_to_string(destination.join(".installation.json")).ok().and_then(|t| serde_json::from_str(&t).ok()); if existing.as_ref().and_then(|e| e.get("sha512")) == catalog.get("sha512") { return Ok(InstallResult { manifest: manifest.clone(), reused: true, path: destination }); } let rollback = id_root.join(".rollback"); ensure_private_dir(&rollback)?; fs::rename(&destination, rollback.join(format!("{version}-{}", chrono::Utc::now().timestamp_millis())))?; } fs::rename(&content, &destination)?; Ok(InstallResult { manifest: manifest.clone(), reused: false, path: destination }) })(); let _ = fs::remove_dir_all(&staging); result } } #[cfg(test)] mod tests { use super::*; use zip::write::SimpleFileOptions; fn package(dir: &Path, files: &[(&str, &str)]) -> PathBuf { let path = dir.join("pkg.chjplugin"); let mut writer = zip::ZipWriter::new(fs::File::create(&path).unwrap()); for (name, content) in files { writer.start_file(*name, SimpleFileOptions::default()).unwrap(); writer.write_all(content.as_bytes()).unwrap(); } writer.finish().unwrap(); path } fn manifest() -> String { 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() } fn catalog() -> Value { 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"}) } #[test] fn installs_and_reuses() { let dir = tempfile::tempdir().unwrap(); let pkg = package(dir.path(), &[("manifest.json", &manifest()), ("ui/index.html", "

x

")]); let installer = ModuleInstaller::new(dir.path(), "0.1.1", "1.7.0"); let first = installer.install(&pkg, &catalog()).unwrap(); assert!(!first.reused && first.path.join("ui/index.html").is_file()); assert!(installer.install(&pkg, &catalog()).unwrap().reused); } #[test] fn rejects_traversal_types_and_mismatch() { let dir = tempfile::tempdir().unwrap(); let installer = ModuleInstaller::new(dir.path(), "0.1.1", "1.7.0"); let bad_type = package(dir.path(), &[("manifest.json", &manifest()), ("ui/index.html", "x"), ("run.sh", "x")]); assert!(installer.install(&bad_type, &catalog()).is_err()); assert!(safe_entry_name("../x").is_err()); assert!(safe_entry_name("C:/x").is_err()); let ok = package(dir.path(), &[("manifest.json", &manifest()), ("ui/index.html", "x")]); let mut wrong = catalog(); wrong["permissions"] = json!(["logs.read", "users.manage"]); assert!(installer.install(&ok, &wrong).is_err()); assert!(!dir.path().join("plugins/.staging").read_dir().map(|mut d| d.next().is_some()).unwrap_or(false)); } }