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 1704 souborů
0.1.2-beta Přidáno: · Sestaveno:
migration.rs Přidáno: Datum není evidováno 30,0 KB · 648 řádků
Kód Stáhnout soubor
1//! Upgrade from Electron 0.1.0 (and later version-to-version upgrades).
2//!
3//! Policy (owner requirements):
4//! * The 0.1.0 data directory is used in place as long as the data format stays backwards
5//! compatible. 0.1.1 reads and writes exactly the 0.1.0 formats (data format 1).
6//! * Two Cores must never write the same directory at the same time: start is refused while
7//! Electron 0.1.0 runs; while 0.1.1 runs it holds its own runtime lock and, on Windows,
8//! Chromium's `lockfile`, so a 0.1.0 started later cannot take the profile.
9//! * Before the first start of every new version a complete, SHA-256 verified backup of the
10//! directory (everything except regenerable Chromium caches and download caches) is written
11//! to `backups/pre-<version>-<timestamp>/` and atomically published. Originals are untouched.
12//! * A future, backwards-incompatible data format must be migrated into a separate store
13//! (`stores/format-<n>/`), never in place; see [`DATA_FORMAT`] and [`separate_store_for`].
14//! * Rollback: [`restore_backup`] moves the current state aside (never deletes it) and puts
15//! the verified backup back in place; reinstalling 0.1.0 then works on the restored data.
17use crate::core::error::{CoreError, CoreResult};
18use crate::core::storage::{atomic_write_json, ensure_private_dir};
19use serde_json::{json, Value};
20use sha2::{Digest, Sha256};
21use std::fs::{self, File};
22use std::io::Read;
23use std::path::{Path, PathBuf};
25pub const MARKER_FILE: &str = ".chj-core-runtime.json";
26pub const RUNTIME_LOCK_FILE: &str = ".chj-core-runtime.lock";
27pub const BACKUP_DIR: &str = "backups";
28pub const STORES_DIR: &str = "stores";
29/// On-disk data format written by this Core. 1 = format of Electron 0.1.0.
30pub const DATA_FORMAT: u32 = 1;
31const KEEP_BACKUPS: usize = 3;
32const MAX_BACKUP_BYTES: u64 = 2 * 1024 * 1024 * 1024;
34/// Top-level entries that are regenerable caches, locks or our own management folders.
35const EXCLUDED_TOP_LEVEL: &[&str] = &[
36 BACKUP_DIR, STORES_DIR, "webview", RUNTIME_LOCK_FILE, "SingletonLock", "SingletonSocket", "SingletonCookie", "lockfile",
37 "Cache", "Code Cache", "GPUCache", "DawnCache", "DawnGraphiteCache", "DawnWebGPUCache", "GrShaderCache", "ShaderCache",
38 "GraphiteDawnCache", "Crashpad", "blob_storage",
39];
40/// Download caches that the Core deletes at every start anyway.
41const EXCLUDED_NESTED: &[&str] = &["plugins/.downloads", "plugins/.staging", "updates/downloads"];
43#[derive(Debug, Clone, serde::Serialize)]
44#[serde(rename_all = "camelCase")]
45pub struct MigrationReport {
46 pub previous_runtime: Option<String>,
47 pub previous_version: Option<String>,
48 pub data_format: u32,
49 pub backup: Option<String>,
50 pub files: usize,
51 pub bytes: u64,
52 pub skipped_links: usize,
53 pub vault: String,
54 pub config: String,
55 pub performed: bool,
58pub fn sha256_file(path: &Path) -> std::io::Result<String> {
59 let mut file = File::open(path)?;
60 let mut hasher = Sha256::new();
61 let mut buffer = vec![0u8; 64 * 1024];
62 loop {
63 let n = file.read(&mut buffer)?;
64 if n == 0 {
65 break;
66 }
67 hasher.update(&buffer[..n]);
68 }
69 Ok(hex::encode(hasher.finalize()))
72// ---------------------------------------------------------------------------------------------
73// Concurrency guard
74// ---------------------------------------------------------------------------------------------
76#[cfg(unix)]
77fn hostname() -> String {
78 let mut buffer = [0u8; 256];
79 if unsafe { libc::gethostname(buffer.as_mut_ptr().cast(), buffer.len()) } != 0 {
80 return String::new();
81 }
82 let end = buffer.iter().position(|b| *b == 0).unwrap_or(buffer.len());
83 String::from_utf8_lossy(&buffer[..end]).to_string()
86/// Detects a running Electron 0.1.0 Core (Chromium profile lock in the same directory).
87pub fn legacy_electron_running(data: &Path) -> bool {
88 #[cfg(unix)]
89 {
90 let Ok(target) = fs::read_link(data.join("SingletonLock")) else { return false };
91 let target = target.to_string_lossy().to_string();
92 let Some((host, pid)) = target.rsplit_once('-') else { return false };
93 let Ok(pid) = pid.parse::<i32>() else { return false };
94 let local = hostname();
95 if pid <= 0 || (!local.is_empty() && host != local) || pid as u32 == std::process::id() {
96 return false;
97 }
98 // kill(pid, 0) succeeds (or fails with EPERM) only for a live process.
99 unsafe { libc::kill(pid, 0) == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) }
100 }
101 #[cfg(windows)]
102 {
103 use std::os::windows::fs::OpenOptionsExt;
104 let lock = data.join("lockfile");
105 if !lock.exists() {
106 return false;
107 }
108 // Chromium keeps `lockfile` open without write sharing while the browser process runs.
109 fs::OpenOptions::new().write(true).share_mode(0x1 | 0x2).open(&lock).is_err()
110 }
113/// Locks held for the whole process lifetime.
114pub struct RuntimeLock {
115 _runtime: File,
116 #[cfg(windows)]
117 _chromium: Option<File>,
120pub fn acquire_runtime_lock(data: &Path) -> CoreResult<RuntimeLock> {
121 use fs4::fs_std::FileExt;
122 ensure_private_dir(data)?;
123 let runtime = fs::OpenOptions::new().create(true).truncate(false).read(true).write(true).open(data.join(RUNTIME_LOCK_FILE))?;
124 match runtime.try_lock_exclusive() {
125 Ok(true) => {}
126 _ => return Err(CoreError::new("CORE_ALREADY_RUNNING", "Another CH-J Tools Core is already using this data directory.")),
127 }
128 #[cfg(windows)]
129 let chromium = {
130 use std::os::windows::fs::OpenOptionsExt;
131 // Same sharing as Chromium's ProcessSingleton (read only): a later Electron 0.1.0 start
132 // cannot create its profile lock and its single-instance request fails.
133 fs::OpenOptions::new().create(true).truncate(false).write(true).share_mode(0x1).open(data.join("lockfile")).ok()
134 };
135 Ok(RuntimeLock {
136 _runtime: runtime,
137 #[cfg(windows)]
138 _chromium: chromium,
139 })
142// ---------------------------------------------------------------------------------------------
143// Product rename: "CH-J Server Manager" → "CH-J Tools"
144// ---------------------------------------------------------------------------------------------
146/// Outcome of [`adopt_renamed_directory`], logged at start.
147#[derive(Debug, Clone, serde::Serialize)]
148#[serde(rename_all = "camelCase")]
149pub struct Relocation {
150 /// `moved`, `kept-legacy-in-use`, `kept-legacy-move-failed` or `both-exist`.
151 pub outcome: &'static str,
152 pub from: String,
153 pub to: String,
154 #[serde(skip_serializing_if = "Option::is_none")]
155 pub error: Option<String>,
158/// Picks the data folder after the rename and moves the old one into place on the first start.
159///
160/// * The new folder exists: it is used; an old folder next to it is never merged or touched (`both-exist`).
161/// * Only the old folder exists (a symlink to a folder counts; then the link itself is renamed and its target stays
162/// where it is): it is renamed in one step (same parent, so nothing is copied and nothing can be half moved).
163/// While another Core (0.1.1/0.1.2 with the old name) or Electron 0.1.0 still uses it, it is kept in place, so the
164/// usual "already running" checks stop this start on the old folder. On Unix the old folder's runtime lock is held
165/// across the rename, so an old Core cannot take the folder in between; Windows cannot rename a folder with an open
166/// file, so there it is released just before.
167/// * A running Standby Helper keeps absolute paths into the old folder, so `stop_helper` is called before the
168/// rename on every platform (the Core's start-up `apply` starts it again on the new folder when it is enabled).
169/// * The rename fails: one retry after a short wait; if it fails again the old folder is used in place and the move
170/// is tried at the next start.
171/// * Neither exists: the new folder (created later by the Core).
172pub fn adopt_renamed_directory(base: &Path, name: &str, legacy_name: &str, stop_helper: impl Fn(&Path)) -> (PathBuf, Option<Relocation>) {
173 let target = base.join(name);
174 let legacy = base.join(legacy_name);
175 let report = |outcome, error: Option<String>| Some(Relocation { outcome, from: legacy.to_string_lossy().into(), to: target.to_string_lossy().into(), error });
176 let legacy_is_dir = fs::metadata(&legacy).map(|m| m.is_dir()).unwrap_or(false);
177 // An empty new folder (created by something before the first Core start) does not block the move. When it cannot
178 // be removed (e.g. it is a symlink to an empty folder), the old data stays in use, as the helper's `choose` does.
179 if legacy_is_dir && fs::read_dir(&target).map(|mut entries| entries.next().is_none()).unwrap_or(false) {
180 if let Err(error) = fs::remove_dir(&target) {
181 return (legacy.clone(), report("kept-legacy-move-failed", Some(error.to_string())));
182 }
183 }
184 if fs::symlink_metadata(&target).is_ok() {
185 return (target.clone(), if legacy_is_dir { report("both-exist", None) } else { None });
186 }
187 if !legacy_is_dir {
188 return (target, None);
189 }
190 if legacy_electron_running(&legacy) {
191 return (legacy.clone(), report("kept-legacy-in-use", None));
192 }
193 let lock = match acquire_runtime_lock(&legacy) {
194 Ok(lock) => lock,
195 Err(_) => return (legacy.clone(), report("kept-legacy-in-use", None)),
196 };
197 stop_helper(&legacy);
198 #[cfg(windows)]
199 drop(lock);
200 let result = fs::rename(&legacy, &target).or_else(|_| {
201 std::thread::sleep(std::time::Duration::from_millis(500));
202 fs::rename(&legacy, &target)
203 });
204 #[cfg(not(windows))]
205 drop(lock);
206 match result {
207 Ok(()) => (target.clone(), report("moved", None)),
208 Err(error) => (legacy.clone(), report("kept-legacy-move-failed", Some(error.to_string()))),
209 }
212// ---------------------------------------------------------------------------------------------
213// Backup
214// ---------------------------------------------------------------------------------------------
216fn is_excluded(relative: &Path) -> bool {
217 let text = relative.to_string_lossy().replace('\\', "/");
218 let first = text.split('/').next().unwrap_or("");
219 if relative.components().count() == 1 && EXCLUDED_TOP_LEVEL.contains(&first) {
220 return true;
221 }
222 if EXCLUDED_NESTED.iter().any(|prefix| text == *prefix || text.starts_with(&format!("{prefix}/"))) {
223 return true;
224 }
225 let name = relative.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
226 name.contains(".tmp-") || name.ends_with(".replace-backup") || name.starts_with(".staging-")
229fn collect(data: &Path, relative: &Path, files: &mut Vec<PathBuf>, links: &mut usize) -> CoreResult<()> {
230 if !relative.as_os_str().is_empty() && is_excluded(relative) {
231 return Ok(());
232 }
233 let full = data.join(relative);
234 let meta = match fs::symlink_metadata(&full) {
235 Ok(meta) => meta,
236 Err(_) => return Ok(()),
237 };
238 if meta.file_type().is_symlink() {
239 *links += 1; // never follow links out of the data directory
240 } else if meta.is_file() {
241 files.push(relative.to_path_buf());
242 } else if meta.is_dir() {
243 let mut names: Vec<_> = fs::read_dir(&full)?.flatten().map(|e| e.file_name()).collect();
244 names.sort();
245 for name in names {
246 collect(data, &relative.join(name), files, links)?;
247 }
248 }
249 Ok(())
252pub fn check_integrity(data: &Path) -> (String, String) {
253 let config = match fs::read_to_string(data.join("config/config.json")) {
254 Err(_) => "missing".to_string(),
255 Ok(text) => match serde_json::from_str::<Value>(&text) {
256 Ok(value) if value.get("schemaVersion").and_then(Value::as_u64) == Some(1) => "ok".to_string(),
257 Ok(_) => "unsupported-schema".to_string(),
258 Err(_) => "invalid-json".to_string(),
259 },
260 };
261 let meta = data.join("vault/core-v1.meta.json");
262 let blob = data.join("vault/core-v1.data.json");
263 let read = |path: &Path| fs::read_to_string(path).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok());
264 let vault = match (meta.exists(), blob.exists()) {
265 (false, false) => "not-created".to_string(),
266 (true, true) => {
267 let meta_ok = read(&meta)
268 .map(|m| {
269 m.get("formatVersion").and_then(Value::as_u64) == Some(1)
270 && m.get("cipher").and_then(Value::as_str) == Some("aes-256-gcm")
271 && m.pointer("/kdf/name").and_then(Value::as_str) == Some("scrypt")
272 && m.get("salt").and_then(Value::as_str).map(|s| !s.is_empty()).unwrap_or(false)
273 })
274 .unwrap_or(false);
275 let blob_ok = read(&blob)
276 .map(|b| {
277 b.get("formatVersion").and_then(Value::as_u64) == Some(1)
278 && ["iv", "authTag", "ciphertext"].iter().all(|k| b.get(*k).and_then(Value::as_str).map(|s| !s.is_empty()).unwrap_or(false))
279 })
280 .unwrap_or(false);
281 if meta_ok && blob_ok { "ok".to_string() } else { "damaged".to_string() }
282 }
283 _ => "incomplete".to_string(),
284 };
285 (config, vault)
288fn prune_backups(root: &Path) {
289 let Ok(entries) = fs::read_dir(root) else { return };
290 let mut dirs: Vec<PathBuf> = entries
291 .flatten()
292 .map(|e| e.path())
293 .filter(|p| p.is_dir() && p.file_name().map(|n| n.to_string_lossy().starts_with("pre-")).unwrap_or(false))
294 .collect();
295 dirs.sort_by_key(|p| fs::metadata(p).and_then(|m| m.modified()).ok());
296 while dirs.len() > KEEP_BACKUPS {
297 let oldest = dirs.remove(0);
298 let _ = fs::remove_dir_all(oldest);
299 }
302/// Complete verified backup. Returns (backup dir, files, bytes, skipped symlinks).
303pub fn create_backup(data: &Path, label: &str, metadata: Value) -> CoreResult<(PathBuf, usize, u64, usize)> {
304 let mut files = Vec::new();
305 let mut links = 0usize;
306 collect(data, Path::new(""), &mut files, &mut links)?;
307 let total: u64 = files.iter().filter_map(|f| fs::metadata(data.join(f)).ok()).map(|m| m.len()).sum();
308 if total > MAX_BACKUP_BYTES {
309 return Err(CoreError::new("MIGRATION_BACKUP_TOO_LARGE", "The data directory is too large for an automatic backup."));
310 }
311 let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ").to_string();
312 let backup_root = data.join(BACKUP_DIR);
313 ensure_private_dir(&backup_root)?;
314 let target = backup_root.join(format!("{label}-{stamp}"));
315 let staging = backup_root.join(format!(".staging-{label}-{stamp}"));
316 let result = (|| -> CoreResult<Vec<Value>> {
317 let mut manifest = Vec::with_capacity(files.len());
318 for relative in &files {
319 let source = data.join(relative);
320 let destination = staging.join(relative);
321 if let Some(parent) = destination.parent() {
322 ensure_private_dir(parent)?;
323 }
324 fs::copy(&source, &destination)?;
325 let (original, copy) = (sha256_file(&source)?, sha256_file(&destination)?);
326 if original != copy {
327 return Err(CoreError::new("MIGRATION_BACKUP_VERIFY_FAILED", "A backup copy does not match its original."));
328 }
329 manifest.push(json!({ "path": relative.to_string_lossy().replace('\\', "/"), "size": fs::metadata(&destination)?.len(), "sha256": copy }));
330 }
331 Ok(manifest)
332 })();
333 let manifest = match result {
334 Ok(manifest) => manifest,
335 Err(error) => {
336 let _ = fs::remove_dir_all(&staging);
337 return Err(error);
338 }
339 };
340 let mut document = json!({ "schemaVersion": 1, "createdAt": chrono::Utc::now().to_rfc3339(), "dataFormat": DATA_FORMAT, "files": manifest, "skippedSymlinks": links });
341 if let (Some(doc), Value::Object(extra)) = (document.as_object_mut(), metadata) {
342 doc.extend(extra);
343 }
344 ensure_private_dir(&staging)?;
345 atomic_write_json(&staging.join("backup-manifest.json"), &document)?;
346 fs::rename(&staging, &target)?;
347 Ok((target, files.len(), total, links))
350/// Verifies every file of a backup against its manifest.
351pub fn verify_backup(backup: &Path) -> CoreResult<Vec<(String, String)>> {
352 let manifest: Value = serde_json::from_str(&fs::read_to_string(backup.join("backup-manifest.json"))?)?;
353 let mut entries = Vec::new();
354 for entry in manifest["files"].as_array().cloned().unwrap_or_default() {
355 let path = entry["path"].as_str().unwrap_or("").to_string();
356 let expected = entry["sha256"].as_str().unwrap_or("").to_string();
357 let relative = Path::new(&path);
358 if path.is_empty() || relative.is_absolute() || relative.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
359 return Err(CoreError::new("MIGRATION_BACKUP_INVALID", "The backup manifest contains an unsafe path."));
360 }
361 if sha256_file(&backup.join(relative)).map_err(|_| CoreError::new("MIGRATION_BACKUP_INVALID", format!("Backup file is missing: {path}")))? != expected {
362 return Err(CoreError::new("MIGRATION_BACKUP_INVALID", format!("Backup file is damaged: {path}")));
363 }
364 entries.push((path, expected));
365 }
366 Ok(entries)
369/// Rollback: verify the backup, move the current top-level entries it covers to
370/// `backups/pre-restore-<timestamp>/` (kept, not deleted) and copy the backup into place.
371pub fn restore_backup(data: &Path, backup: &Path) -> CoreResult<PathBuf> {
372 let entries = verify_backup(backup)?;
373 let mut tops: Vec<String> = entries.iter().map(|(p, _)| p.split('/').next().unwrap_or("").to_string()).collect();
374 tops.sort();
375 tops.dedup();
376 let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ").to_string();
377 let aside = data.join(BACKUP_DIR).join(format!("pre-restore-{stamp}"));
378 ensure_private_dir(&aside)?;
379 for top in &tops {
380 if top.is_empty() || EXCLUDED_TOP_LEVEL.contains(&top.as_str()) {
381 continue;
382 }
383 let current = data.join(top);
384 if fs::symlink_metadata(&current).is_ok() {
385 fs::rename(&current, aside.join(top))?;
386 }
387 }
388 for (path, expected) in &entries {
389 let destination = data.join(path);
390 if let Some(parent) = destination.parent() {
391 ensure_private_dir(parent)?;
392 }
393 fs::copy(backup.join(path), &destination)?;
394 if &sha256_file(&destination)? != expected {
395 return Err(CoreError::new("MIGRATION_RESTORE_FAILED", format!("Restored file does not match the backup: {path}")));
396 }
397 }
398 Ok(aside)
401/// Location for a future, backwards-incompatible data format (never the 0.1.0 files).
402pub fn separate_store_for(data: &Path, format: u32) -> PathBuf {
403 data.join(STORES_DIR).join(format!("format-{format}"))
406/// Once-per-version upgrade step. Never modifies existing data files.
407pub fn prepare_data_directory(data: &Path, version: &str, build_id: &str) -> CoreResult<MigrationReport> {
408 ensure_private_dir(data)?;
409 let marker_path = data.join(MARKER_FILE);
410 let marker: Option<Value> = fs::read_to_string(&marker_path).ok().and_then(|t| serde_json::from_str(&t).ok());
411 let previous_version = marker.as_ref().and_then(|m| m.get("lastVersion")).and_then(Value::as_str).map(str::to_string);
412 let previous_format = marker.as_ref().and_then(|m| m.get("dataFormat")).and_then(Value::as_u64).unwrap_or(1) as u32;
413 if previous_format > DATA_FORMAT {
414 return Err(CoreError::new(
415 "DATA_FORMAT_NEWER",
416 "The data directory was written by a newer CH-J Tools. Install that version or restore a backup.",
417 ));
418 }
419 let (config, vault) = check_integrity(data);
420 let mut probe = Vec::new();
421 let mut probe_links = 0;
422 collect(data, Path::new(""), &mut probe, &mut probe_links)?;
423 let has_existing_data = probe.iter().any(|p| p != Path::new(MARKER_FILE));
424 let previous_runtime = marker
425 .as_ref()
426 .and_then(|m| m.get("runtime"))
427 .and_then(Value::as_str)
428 .map(str::to_string)
429 .or_else(|| has_existing_data.then(|| "electron".to_string()));
430 let mut report = MigrationReport {
431 previous_runtime: previous_runtime.clone(),
432 previous_version: previous_version.clone(),
433 data_format: DATA_FORMAT,
434 backup: None,
435 files: 0,
436 bytes: 0,
437 skipped_links: 0,
438 vault,
439 config,
440 performed: false,
441 };
442 if previous_version.as_deref() == Some(version) {
443 return Ok(report);
444 }
445 if has_existing_data {
446 let (backup, files, bytes, links) = create_backup(
447 data,
448 &format!("pre-{version}"),
449 json!({ "fromVersion": previous_version, "fromRuntime": previous_runtime, "toVersion": version, "toBuild": build_id, "vault": report.vault, "config": report.config }),
450 )?;
451 verify_backup(&backup)?;
452 prune_backups(&data.join(BACKUP_DIR));
453 report.backup = Some(backup.to_string_lossy().to_string());
454 report.files = files;
455 report.bytes = bytes;
456 report.skipped_links = links;
457 }
458 let mut history = marker.as_ref().and_then(|m| m.get("history")).and_then(Value::as_array).cloned().unwrap_or_default();
459 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 }));
460 let keep = history.len().saturating_sub(20);
461 history.drain(..keep);
462 atomic_write_json(
463 &marker_path,
464 &json!({ "schemaVersion": 1, "runtime": "tauri", "lastVersion": version, "lastBuildId": build_id, "dataFormat": DATA_FORMAT, "history": history }),
465 )?;
466 report.performed = true;
467 Ok(report)
470#[cfg(test)]
471mod tests {
472 use super::*;
474 fn electron_like_directory(data: &Path) {
475 for (path, content) in [
476 ("config/config.json", r#"{"schemaVersion":1}"#),
477 ("vault/core-v1.meta.json", r#"{"formatVersion":1,"cipher":"aes-256-gcm","kdf":{"name":"scrypt"},"salt":"AAAA"}"#),
478 ("vault/core-v1.data.json", r#"{"formatVersion":1,"iv":"a","authTag":"b","ciphertext":"c"}"#),
479 ("logs/core.log", "{}\n"),
480 ("plugins/chj.x/1.0.0/manifest.json", "{}"),
481 ("plugins/.downloads/x.zip", "x"),
482 ("updates/downloads/a.dmg", "x"),
483 ("GPUCache/data", "cache"),
484 ("Local Storage/leveldb/000003.log", "ls"),
485 ("transfers.json", "{}"),
486 ] {
487 let full = data.join(path);
488 fs::create_dir_all(full.parent().unwrap()).unwrap();
489 fs::write(full, content).unwrap();
490 }
491 }
493 #[test]
494 fn complete_backup_once_per_version_keeps_originals() {
495 let dir = tempfile::tempdir().unwrap();
496 let data = dir.path();
497 electron_like_directory(data);
498 let first = prepare_data_directory(data, "0.1.1", "b1").unwrap();
499 assert!(first.performed);
500 assert_eq!(first.previous_runtime.as_deref(), Some("electron"));
501 assert_eq!(first.vault, "ok");
502 let backup = PathBuf::from(first.backup.clone().unwrap());
503 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"] {
504 assert!(backup.join(kept).exists(), "{kept} missing in backup");
505 }
506 for skipped in ["plugins/.downloads", "updates/downloads", "GPUCache"] {
507 assert!(!backup.join(skipped).exists(), "{skipped} must not be backed up");
508 }
509 assert_eq!(verify_backup(&backup).unwrap().len(), first.files);
510 assert!(data.join("config/config.json").exists());
511 let second = prepare_data_directory(data, "0.1.1", "b1").unwrap();
512 assert!(!second.performed && second.backup.is_none());
513 }
515 #[test]
516 fn restore_moves_current_state_aside() {
517 let dir = tempfile::tempdir().unwrap();
518 let data = dir.path();
519 electron_like_directory(data);
520 let report = prepare_data_directory(data, "0.1.1", "b1").unwrap();
521 fs::write(data.join("config/config.json"), r#"{"schemaVersion":1,"changed":true}"#).unwrap();
522 fs::write(data.join("config/extra.json"), "{}").unwrap();
523 let aside = restore_backup(data, Path::new(report.backup.as_ref().unwrap())).unwrap();
524 assert_eq!(fs::read_to_string(data.join("config/config.json")).unwrap(), r#"{"schemaVersion":1}"#);
525 assert!(!data.join("config/extra.json").exists());
526 assert!(aside.join("config/extra.json").exists());
527 }
529 #[test]
530 fn damaged_backup_is_rejected() {
531 let dir = tempfile::tempdir().unwrap();
532 let data = dir.path();
533 electron_like_directory(data);
534 let backup = PathBuf::from(prepare_data_directory(data, "0.1.1", "b").unwrap().backup.unwrap());
535 fs::write(backup.join("transfers.json"), "tampered").unwrap();
536 assert_eq!(restore_backup(data, &backup).unwrap_err().code, "MIGRATION_BACKUP_INVALID");
537 assert_eq!(fs::read_to_string(data.join("transfers.json")).unwrap(), "{}");
538 }
540 #[test]
541 fn newer_data_format_is_refused() {
542 let dir = tempfile::tempdir().unwrap();
543 fs::write(dir.path().join(MARKER_FILE), r#"{"lastVersion":"9.0.0","dataFormat":99}"#).unwrap();
544 assert_eq!(prepare_data_directory(dir.path(), "0.1.1", "b").unwrap_err().code, "DATA_FORMAT_NEWER");
545 assert_eq!(separate_store_for(dir.path(), 2), dir.path().join("stores/format-2"));
546 }
548 #[test]
549 fn fresh_install_has_no_backup() {
550 let dir = tempfile::tempdir().unwrap();
551 let report = prepare_data_directory(dir.path(), "0.1.1", "b1").unwrap();
552 assert!(report.performed && report.backup.is_none());
553 assert_eq!(report.vault, "not-created");
554 }
556 #[test]
557 fn renamed_directory_is_moved_once_and_never_merged() {
558 let base = tempfile::tempdir().unwrap();
559 let legacy = base.path().join("Old");
560 fs::create_dir_all(legacy.join("vault")).unwrap();
561 fs::write(legacy.join("vault").join("v.json"), b"secret-free fixture").unwrap();
562 let stops = std::cell::Cell::new(0);
563 let (data, report) = adopt_renamed_directory(base.path(), "New", "Old", |_| stops.set(stops.get() + 1));
564 assert_eq!(data, base.path().join("New"));
565 assert_eq!(report.unwrap().outcome, "moved");
566 assert!(!legacy.exists());
567 assert_eq!(fs::read(data.join("vault").join("v.json")).unwrap(), b"secret-free fixture");
568 assert_eq!(stops.get(), 1, "the helper is stopped before the move");
569 // A later old-name start created a folder again: the new one wins and the old one stays untouched.
570 fs::create_dir_all(legacy.join("x")).unwrap();
571 let (again, report) = adopt_renamed_directory(base.path(), "New", "Old", |_| panic!("no move expected"));
572 assert_eq!(again, base.path().join("New"));
573 assert_eq!(report.unwrap().outcome, "both-exist");
574 assert!(legacy.join("x").exists() && !again.join("x").exists());
575 }
577 #[cfg(unix)]
578 #[test]
579 fn symlinked_old_directory_is_moved_as_a_link_and_keeps_its_target() {
580 let base = tempfile::tempdir().unwrap();
581 let elsewhere = tempfile::tempdir().unwrap();
582 fs::write(elsewhere.path().join("config.json"), b"{}").unwrap();
583 std::os::unix::fs::symlink(elsewhere.path(), base.path().join("Old")).unwrap();
584 let (data, report) = adopt_renamed_directory(base.path(), "New", "Old", |_| {});
585 assert_eq!(report.unwrap().outcome, "moved");
586 assert!(fs::symlink_metadata(&data).unwrap().file_type().is_symlink());
587 assert_eq!(fs::read(data.join("config.json")).unwrap(), b"{}");
588 assert!(elsewhere.path().join("config.json").is_file());
589 }
591 #[test]
592 fn empty_new_directory_does_not_block_the_move() {
593 let base = tempfile::tempdir().unwrap();
594 fs::create_dir_all(base.path().join("Old").join("profiles")).unwrap();
595 fs::create_dir_all(base.path().join("New")).unwrap();
596 let (data, report) = adopt_renamed_directory(base.path(), "New", "Old", |_| {});
597 assert_eq!(report.unwrap().outcome, "moved");
598 assert!(data.join("profiles").is_dir() && !base.path().join("Old").exists());
599 }
601 #[cfg(unix)]
602 #[test]
603 fn empty_new_symlink_keeps_the_old_data_in_use() {
604 let base = tempfile::tempdir().unwrap();
605 let empty = tempfile::tempdir().unwrap();
606 fs::create_dir_all(base.path().join("Old").join("vault")).unwrap();
607 std::os::unix::fs::symlink(empty.path(), base.path().join("New")).unwrap();
608 let (data, report) = adopt_renamed_directory(base.path(), "New", "Old", |_| panic!("no move expected"));
609 assert_eq!(data, base.path().join("Old"));
610 assert_eq!(report.unwrap().outcome, "kept-legacy-move-failed");
611 }
613 #[test]
614 fn renamed_directory_without_old_data_is_the_new_one() {
615 let base = tempfile::tempdir().unwrap();
616 let (data, report) = adopt_renamed_directory(base.path(), "New", "Old", |_| panic!("no move expected"));
617 assert_eq!(data, base.path().join("New"));
618 assert!(report.is_none());
619 assert!(!data.exists());
620 }
622 #[test]
623 fn old_directory_in_use_by_another_core_stays_in_place() {
624 let base = tempfile::tempdir().unwrap();
625 let legacy = base.path().join("Old");
626 let _running = acquire_runtime_lock(&legacy).unwrap();
627 let (data, report) = adopt_renamed_directory(base.path(), "New", "Old", |_| panic!("no move expected"));
628 assert_eq!(data, legacy);
629 assert_eq!(report.unwrap().outcome, "kept-legacy-in-use");
630 assert!(!base.path().join("New").exists());
631 assert_eq!(acquire_runtime_lock(&data).err().unwrap().code, "CORE_ALREADY_RUNNING");
632 }
634 #[test]
635 fn runtime_lock_is_exclusive() {
636 let dir = tempfile::tempdir().unwrap();
637 let _lock = acquire_runtime_lock(dir.path()).unwrap();
638 assert_eq!(acquire_runtime_lock(dir.path()).err().unwrap().code, "CORE_ALREADY_RUNNING");
639 }
641 #[cfg(unix)]
642 #[test]
643 fn stale_singleton_lock_is_not_running() {
644 let dir = tempfile::tempdir().unwrap();
645 std::os::unix::fs::symlink(format!("{}-999999", hostname()), dir.path().join("SingletonLock")).unwrap();
646 assert!(!legacy_electron_running(dir.path()));
647 }
SHA-256e5472c175fd721d4a9017f4cba01624e6fbe18beacb851bde22e70cb1ce87905

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 archivuac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Soubory