//! Upgrade from Electron 0.1.0 (and later version-to-version upgrades). //! //! Policy (owner requirements): //! * The 0.1.0 data directory is used in place as long as the data format stays backwards //! compatible. 0.1.1 reads and writes exactly the 0.1.0 formats (data format 1). //! * Two Cores must never write the same directory at the same time: start is refused while //! Electron 0.1.0 runs; while 0.1.1 runs it holds its own runtime lock and, on Windows, //! Chromium's `lockfile`, so a 0.1.0 started later cannot take the profile. //! * Before the first start of every new version a complete, SHA-256 verified backup of the //! directory (everything except regenerable Chromium caches and download caches) is written //! to `backups/pre--/` and atomically published. Originals are untouched. //! * A future, backwards-incompatible data format must be migrated into a separate store //! (`stores/format-/`), never in place; see [`DATA_FORMAT`] and [`separate_store_for`]. //! * Rollback: [`restore_backup`] moves the current state aside (never deletes it) and puts //! the verified backup back in place; reinstalling 0.1.0 then works on the restored data. use crate::core::error::{CoreError, CoreResult}; use crate::core::storage::{atomic_write_json, ensure_private_dir}; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use std::fs::{self, File}; use std::io::Read; use std::path::{Path, PathBuf}; pub const MARKER_FILE: &str = ".chj-core-runtime.json"; pub const RUNTIME_LOCK_FILE: &str = ".chj-core-runtime.lock"; pub const BACKUP_DIR: &str = "backups"; pub const STORES_DIR: &str = "stores"; /// On-disk data format written by this Core. 1 = format of Electron 0.1.0. pub const DATA_FORMAT: u32 = 1; const KEEP_BACKUPS: usize = 3; const MAX_BACKUP_BYTES: u64 = 2 * 1024 * 1024 * 1024; /// Top-level entries that are regenerable caches, locks or our own management folders. const EXCLUDED_TOP_LEVEL: &[&str] = &[ BACKUP_DIR, STORES_DIR, "webview", RUNTIME_LOCK_FILE, "SingletonLock", "SingletonSocket", "SingletonCookie", "lockfile", "Cache", "Code Cache", "GPUCache", "DawnCache", "DawnGraphiteCache", "DawnWebGPUCache", "GrShaderCache", "ShaderCache", "GraphiteDawnCache", "Crashpad", "blob_storage", ]; /// Download caches that the Core deletes at every start anyway. const EXCLUDED_NESTED: &[&str] = &["plugins/.downloads", "plugins/.staging", "updates/downloads"]; #[derive(Debug, Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct MigrationReport { pub previous_runtime: Option, pub previous_version: Option, pub data_format: u32, pub backup: Option, pub files: usize, pub bytes: u64, pub skipped_links: usize, pub vault: String, pub config: String, pub performed: bool, } pub fn sha256_file(path: &Path) -> std::io::Result { let mut file = File::open(path)?; let mut hasher = Sha256::new(); let mut buffer = vec![0u8; 64 * 1024]; loop { let n = file.read(&mut buffer)?; if n == 0 { break; } hasher.update(&buffer[..n]); } Ok(hex::encode(hasher.finalize())) } // --------------------------------------------------------------------------------------------- // Concurrency guard // --------------------------------------------------------------------------------------------- #[cfg(unix)] fn hostname() -> String { let mut buffer = [0u8; 256]; if unsafe { libc::gethostname(buffer.as_mut_ptr().cast(), buffer.len()) } != 0 { return String::new(); } let end = buffer.iter().position(|b| *b == 0).unwrap_or(buffer.len()); String::from_utf8_lossy(&buffer[..end]).to_string() } /// Detects a running Electron 0.1.0 Core (Chromium profile lock in the same directory). pub fn legacy_electron_running(data: &Path) -> bool { #[cfg(unix)] { let Ok(target) = fs::read_link(data.join("SingletonLock")) else { return false }; let target = target.to_string_lossy().to_string(); let Some((host, pid)) = target.rsplit_once('-') else { return false }; let Ok(pid) = pid.parse::() else { return false }; let local = hostname(); if pid <= 0 || (!local.is_empty() && host != local) || pid as u32 == std::process::id() { return false; } // kill(pid, 0) succeeds (or fails with EPERM) only for a live process. unsafe { libc::kill(pid, 0) == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) } } #[cfg(windows)] { use std::os::windows::fs::OpenOptionsExt; let lock = data.join("lockfile"); if !lock.exists() { return false; } // Chromium keeps `lockfile` open without write sharing while the browser process runs. fs::OpenOptions::new().write(true).share_mode(0x1 | 0x2).open(&lock).is_err() } } /// Locks held for the whole process lifetime. pub struct RuntimeLock { _runtime: File, #[cfg(windows)] _chromium: Option, } pub fn acquire_runtime_lock(data: &Path) -> CoreResult { use fs4::fs_std::FileExt; ensure_private_dir(data)?; let runtime = fs::OpenOptions::new().create(true).truncate(false).read(true).write(true).open(data.join(RUNTIME_LOCK_FILE))?; match runtime.try_lock_exclusive() { Ok(true) => {} _ => return Err(CoreError::new("CORE_ALREADY_RUNNING", "Another CH-J Server Manager Core is already using this data directory.")), } #[cfg(windows)] let chromium = { use std::os::windows::fs::OpenOptionsExt; // Same sharing as Chromium's ProcessSingleton (read only): a later Electron 0.1.0 start // cannot create its profile lock and its single-instance request fails. fs::OpenOptions::new().create(true).truncate(false).write(true).share_mode(0x1).open(data.join("lockfile")).ok() }; Ok(RuntimeLock { _runtime: runtime, #[cfg(windows)] _chromium: chromium, }) } // --------------------------------------------------------------------------------------------- // Backup // --------------------------------------------------------------------------------------------- fn is_excluded(relative: &Path) -> bool { let text = relative.to_string_lossy().replace('\\', "/"); let first = text.split('/').next().unwrap_or(""); if relative.components().count() == 1 && EXCLUDED_TOP_LEVEL.contains(&first) { return true; } if EXCLUDED_NESTED.iter().any(|prefix| text == *prefix || text.starts_with(&format!("{prefix}/"))) { return true; } let name = relative.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); name.contains(".tmp-") || name.ends_with(".replace-backup") || name.starts_with(".staging-") } fn collect(data: &Path, relative: &Path, files: &mut Vec, links: &mut usize) -> CoreResult<()> { if !relative.as_os_str().is_empty() && is_excluded(relative) { return Ok(()); } let full = data.join(relative); let meta = match fs::symlink_metadata(&full) { Ok(meta) => meta, Err(_) => return Ok(()), }; if meta.file_type().is_symlink() { *links += 1; // never follow links out of the data directory } else if meta.is_file() { files.push(relative.to_path_buf()); } else if meta.is_dir() { let mut names: Vec<_> = fs::read_dir(&full)?.flatten().map(|e| e.file_name()).collect(); names.sort(); for name in names { collect(data, &relative.join(name), files, links)?; } } Ok(()) } pub fn check_integrity(data: &Path) -> (String, String) { let config = match fs::read_to_string(data.join("config/config.json")) { Err(_) => "missing".to_string(), Ok(text) => match serde_json::from_str::(&text) { Ok(value) if value.get("schemaVersion").and_then(Value::as_u64) == Some(1) => "ok".to_string(), Ok(_) => "unsupported-schema".to_string(), Err(_) => "invalid-json".to_string(), }, }; let meta = data.join("vault/core-v1.meta.json"); let blob = data.join("vault/core-v1.data.json"); let read = |path: &Path| fs::read_to_string(path).ok().and_then(|t| serde_json::from_str::(&t).ok()); let vault = match (meta.exists(), blob.exists()) { (false, false) => "not-created".to_string(), (true, true) => { let meta_ok = read(&meta) .map(|m| { m.get("formatVersion").and_then(Value::as_u64) == Some(1) && m.get("cipher").and_then(Value::as_str) == Some("aes-256-gcm") && m.pointer("/kdf/name").and_then(Value::as_str) == Some("scrypt") && m.get("salt").and_then(Value::as_str).map(|s| !s.is_empty()).unwrap_or(false) }) .unwrap_or(false); let blob_ok = read(&blob) .map(|b| { b.get("formatVersion").and_then(Value::as_u64) == Some(1) && ["iv", "authTag", "ciphertext"].iter().all(|k| b.get(*k).and_then(Value::as_str).map(|s| !s.is_empty()).unwrap_or(false)) }) .unwrap_or(false); if meta_ok && blob_ok { "ok".to_string() } else { "damaged".to_string() } } _ => "incomplete".to_string(), }; (config, vault) } fn prune_backups(root: &Path) { let Ok(entries) = fs::read_dir(root) else { return }; let mut dirs: Vec = entries .flatten() .map(|e| e.path()) .filter(|p| p.is_dir() && p.file_name().map(|n| n.to_string_lossy().starts_with("pre-")).unwrap_or(false)) .collect(); dirs.sort_by_key(|p| fs::metadata(p).and_then(|m| m.modified()).ok()); while dirs.len() > KEEP_BACKUPS { let oldest = dirs.remove(0); let _ = fs::remove_dir_all(oldest); } } /// Complete verified backup. Returns (backup dir, files, bytes, skipped symlinks). pub fn create_backup(data: &Path, label: &str, metadata: Value) -> CoreResult<(PathBuf, usize, u64, usize)> { let mut files = Vec::new(); let mut links = 0usize; collect(data, Path::new(""), &mut files, &mut links)?; let total: u64 = files.iter().filter_map(|f| fs::metadata(data.join(f)).ok()).map(|m| m.len()).sum(); if total > MAX_BACKUP_BYTES { return Err(CoreError::new("MIGRATION_BACKUP_TOO_LARGE", "The data directory is too large for an automatic backup.")); } let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ").to_string(); let backup_root = data.join(BACKUP_DIR); ensure_private_dir(&backup_root)?; let target = backup_root.join(format!("{label}-{stamp}")); let staging = backup_root.join(format!(".staging-{label}-{stamp}")); let result = (|| -> CoreResult> { let mut manifest = Vec::with_capacity(files.len()); for relative in &files { let source = data.join(relative); let destination = staging.join(relative); if let Some(parent) = destination.parent() { ensure_private_dir(parent)?; } fs::copy(&source, &destination)?; let (original, copy) = (sha256_file(&source)?, sha256_file(&destination)?); if original != copy { return Err(CoreError::new("MIGRATION_BACKUP_VERIFY_FAILED", "A backup copy does not match its original.")); } manifest.push(json!({ "path": relative.to_string_lossy().replace('\\', "/"), "size": fs::metadata(&destination)?.len(), "sha256": copy })); } Ok(manifest) })(); let manifest = match result { Ok(manifest) => manifest, Err(error) => { let _ = fs::remove_dir_all(&staging); return Err(error); } }; let mut document = json!({ "schemaVersion": 1, "createdAt": chrono::Utc::now().to_rfc3339(), "dataFormat": DATA_FORMAT, "files": manifest, "skippedSymlinks": links }); if let (Some(doc), Value::Object(extra)) = (document.as_object_mut(), metadata) { doc.extend(extra); } ensure_private_dir(&staging)?; atomic_write_json(&staging.join("backup-manifest.json"), &document)?; fs::rename(&staging, &target)?; Ok((target, files.len(), total, links)) } /// Verifies every file of a backup against its manifest. pub fn verify_backup(backup: &Path) -> CoreResult> { let manifest: Value = serde_json::from_str(&fs::read_to_string(backup.join("backup-manifest.json"))?)?; let mut entries = Vec::new(); for entry in manifest["files"].as_array().cloned().unwrap_or_default() { let path = entry["path"].as_str().unwrap_or("").to_string(); let expected = entry["sha256"].as_str().unwrap_or("").to_string(); let relative = Path::new(&path); if path.is_empty() || relative.is_absolute() || relative.components().any(|c| matches!(c, std::path::Component::ParentDir)) { return Err(CoreError::new("MIGRATION_BACKUP_INVALID", "The backup manifest contains an unsafe path.")); } if sha256_file(&backup.join(relative)).map_err(|_| CoreError::new("MIGRATION_BACKUP_INVALID", format!("Backup file is missing: {path}")))? != expected { return Err(CoreError::new("MIGRATION_BACKUP_INVALID", format!("Backup file is damaged: {path}"))); } entries.push((path, expected)); } Ok(entries) } /// Rollback: verify the backup, move the current top-level entries it covers to /// `backups/pre-restore-/` (kept, not deleted) and copy the backup into place. pub fn restore_backup(data: &Path, backup: &Path) -> CoreResult { let entries = verify_backup(backup)?; let mut tops: Vec = entries.iter().map(|(p, _)| p.split('/').next().unwrap_or("").to_string()).collect(); tops.sort(); tops.dedup(); let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ").to_string(); let aside = data.join(BACKUP_DIR).join(format!("pre-restore-{stamp}")); ensure_private_dir(&aside)?; for top in &tops { if top.is_empty() || EXCLUDED_TOP_LEVEL.contains(&top.as_str()) { continue; } let current = data.join(top); if fs::symlink_metadata(¤t).is_ok() { fs::rename(¤t, aside.join(top))?; } } for (path, expected) in &entries { let destination = data.join(path); if let Some(parent) = destination.parent() { ensure_private_dir(parent)?; } fs::copy(backup.join(path), &destination)?; if &sha256_file(&destination)? != expected { return Err(CoreError::new("MIGRATION_RESTORE_FAILED", format!("Restored file does not match the backup: {path}"))); } } Ok(aside) } /// Location for a future, backwards-incompatible data format (never the 0.1.0 files). pub fn separate_store_for(data: &Path, format: u32) -> PathBuf { data.join(STORES_DIR).join(format!("format-{format}")) } /// Once-per-version upgrade step. Never modifies existing data files. pub fn prepare_data_directory(data: &Path, version: &str, build_id: &str) -> CoreResult { ensure_private_dir(data)?; let marker_path = data.join(MARKER_FILE); let marker: Option = fs::read_to_string(&marker_path).ok().and_then(|t| serde_json::from_str(&t).ok()); let previous_version = marker.as_ref().and_then(|m| m.get("lastVersion")).and_then(Value::as_str).map(str::to_string); let previous_format = marker.as_ref().and_then(|m| m.get("dataFormat")).and_then(Value::as_u64).unwrap_or(1) as u32; if previous_format > DATA_FORMAT { return Err(CoreError::new( "DATA_FORMAT_NEWER", "The data directory was written by a newer CH-J Server Manager. Install that version or restore a backup.", )); } let (config, vault) = check_integrity(data); let mut probe = Vec::new(); let mut probe_links = 0; collect(data, Path::new(""), &mut probe, &mut probe_links)?; let has_existing_data = probe.iter().any(|p| p != Path::new(MARKER_FILE)); let previous_runtime = marker .as_ref() .and_then(|m| m.get("runtime")) .and_then(Value::as_str) .map(str::to_string) .or_else(|| has_existing_data.then(|| "electron".to_string())); let mut report = MigrationReport { previous_runtime: previous_runtime.clone(), previous_version: previous_version.clone(), data_format: DATA_FORMAT, backup: None, files: 0, bytes: 0, skipped_links: 0, vault, config, performed: false, }; if previous_version.as_deref() == Some(version) { return Ok(report); } if has_existing_data { let (backup, files, bytes, links) = create_backup( data, &format!("pre-{version}"), json!({ "fromVersion": previous_version, "fromRuntime": previous_runtime, "toVersion": version, "toBuild": build_id, "vault": report.vault, "config": report.config }), )?; verify_backup(&backup)?; prune_backups(&data.join(BACKUP_DIR)); report.backup = Some(backup.to_string_lossy().to_string()); report.files = files; report.bytes = bytes; report.skipped_links = links; } let mut history = marker.as_ref().and_then(|m| m.get("history")).and_then(Value::as_array).cloned().unwrap_or_default(); history.push(json!({ "version": version, "buildId": build_id, "at": chrono::Utc::now().to_rfc3339(), "backup": report.backup, "vault": report.vault, "config": report.config, "fromRuntime": previous_runtime })); let keep = history.len().saturating_sub(20); history.drain(..keep); atomic_write_json( &marker_path, &json!({ "schemaVersion": 1, "runtime": "tauri", "lastVersion": version, "lastBuildId": build_id, "dataFormat": DATA_FORMAT, "history": history }), )?; report.performed = true; Ok(report) } #[cfg(test)] mod tests { use super::*; fn electron_like_directory(data: &Path) { for (path, content) in [ ("config/config.json", r#"{"schemaVersion":1}"#), ("vault/core-v1.meta.json", r#"{"formatVersion":1,"cipher":"aes-256-gcm","kdf":{"name":"scrypt"},"salt":"AAAA"}"#), ("vault/core-v1.data.json", r#"{"formatVersion":1,"iv":"a","authTag":"b","ciphertext":"c"}"#), ("logs/core.log", "{}\n"), ("plugins/chj.x/1.0.0/manifest.json", "{}"), ("plugins/.downloads/x.zip", "x"), ("updates/downloads/a.dmg", "x"), ("GPUCache/data", "cache"), ("Local Storage/leveldb/000003.log", "ls"), ("transfers.json", "{}"), ] { let full = data.join(path); fs::create_dir_all(full.parent().unwrap()).unwrap(); fs::write(full, content).unwrap(); } } #[test] fn complete_backup_once_per_version_keeps_originals() { let dir = tempfile::tempdir().unwrap(); let data = dir.path(); electron_like_directory(data); let first = prepare_data_directory(data, "0.1.1", "b1").unwrap(); assert!(first.performed); assert_eq!(first.previous_runtime.as_deref(), Some("electron")); assert_eq!(first.vault, "ok"); let backup = PathBuf::from(first.backup.clone().unwrap()); for kept in ["vault/core-v1.data.json", "logs/core.log", "plugins/chj.x/1.0.0/manifest.json", "Local Storage/leveldb/000003.log", "transfers.json"] { assert!(backup.join(kept).exists(), "{kept} missing in backup"); } for skipped in ["plugins/.downloads", "updates/downloads", "GPUCache"] { assert!(!backup.join(skipped).exists(), "{skipped} must not be backed up"); } assert_eq!(verify_backup(&backup).unwrap().len(), first.files); assert!(data.join("config/config.json").exists()); let second = prepare_data_directory(data, "0.1.1", "b1").unwrap(); assert!(!second.performed && second.backup.is_none()); } #[test] fn restore_moves_current_state_aside() { let dir = tempfile::tempdir().unwrap(); let data = dir.path(); electron_like_directory(data); let report = prepare_data_directory(data, "0.1.1", "b1").unwrap(); fs::write(data.join("config/config.json"), r#"{"schemaVersion":1,"changed":true}"#).unwrap(); fs::write(data.join("config/extra.json"), "{}").unwrap(); let aside = restore_backup(data, Path::new(report.backup.as_ref().unwrap())).unwrap(); assert_eq!(fs::read_to_string(data.join("config/config.json")).unwrap(), r#"{"schemaVersion":1}"#); assert!(!data.join("config/extra.json").exists()); assert!(aside.join("config/extra.json").exists()); } #[test] fn damaged_backup_is_rejected() { let dir = tempfile::tempdir().unwrap(); let data = dir.path(); electron_like_directory(data); let backup = PathBuf::from(prepare_data_directory(data, "0.1.1", "b").unwrap().backup.unwrap()); fs::write(backup.join("transfers.json"), "tampered").unwrap(); assert_eq!(restore_backup(data, &backup).unwrap_err().code, "MIGRATION_BACKUP_INVALID"); assert_eq!(fs::read_to_string(data.join("transfers.json")).unwrap(), "{}"); } #[test] fn newer_data_format_is_refused() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join(MARKER_FILE), r#"{"lastVersion":"9.0.0","dataFormat":99}"#).unwrap(); assert_eq!(prepare_data_directory(dir.path(), "0.1.1", "b").unwrap_err().code, "DATA_FORMAT_NEWER"); assert_eq!(separate_store_for(dir.path(), 2), dir.path().join("stores/format-2")); } #[test] fn fresh_install_has_no_backup() { let dir = tempfile::tempdir().unwrap(); let report = prepare_data_directory(dir.path(), "0.1.1", "b1").unwrap(); assert!(report.performed && report.backup.is_none()); assert_eq!(report.vault, "not-created"); } #[test] fn runtime_lock_is_exclusive() { let dir = tempfile::tempdir().unwrap(); let _lock = acquire_runtime_lock(dir.path()).unwrap(); assert_eq!(acquire_runtime_lock(dir.path()).err().unwrap().code, "CORE_ALREADY_RUNNING"); } #[cfg(unix)] #[test] fn stale_singleton_lock_is_not_running() { let dir = tempfile::tempdir().unwrap(); std::os::unix::fs::symlink(format!("{}-999999", hostname()), dir.path().join("SingletonLock")).unwrap(); assert!(!legacy_electron_running(dir.path())); } }