공개된 소스
소스 ZIP 다운로드
CH-J Tools
특정 애플리케이션 릴리스의 디렉터리와 파일을 살펴보세요.
CH-J / Server Manager
1704 개 파일
0.1.2-beta
추가됨: · 빌드됨:
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 backwards5
//! 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 while7
//! 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 the10
//! directory (everything except regenerable Chromium caches and download caches) is written11
//! to `backups/pre-<version>-<timestamp>/` and atomically published. Originals are untouched.12
//! * A future, backwards-incompatible data format must be migrated into a separate store13
//! (`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 puts15
//! the verified backup back in place; reinstalling 0.1.0 then works on the restored data.17
use crate::core::error::{CoreError, CoreResult};18
use crate::core::storage::{atomic_write_json, ensure_private_dir};19
use serde_json::{json, Value};20
use sha2::{Digest, Sha256};21
use std::fs::{self, File};22
use std::io::Read;23
use std::path::{Path, PathBuf};25
pub const MARKER_FILE: &str = ".chj-core-runtime.json";26
pub const RUNTIME_LOCK_FILE: &str = ".chj-core-runtime.lock";27
pub const BACKUP_DIR: &str = "backups";28
pub const STORES_DIR: &str = "stores";29
/// On-disk data format written by this Core. 1 = format of Electron 0.1.0.30
pub const DATA_FORMAT: u32 = 1;31
const KEEP_BACKUPS: usize = 3;32
const MAX_BACKUP_BYTES: u64 = 2 * 1024 * 1024 * 1024;34
/// Top-level entries that are regenerable caches, locks or our own management folders.35
const 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.41
const EXCLUDED_NESTED: &[&str] = &["plugins/.downloads", "plugins/.staging", "updates/downloads"];43
#[derive(Debug, Clone, serde::Serialize)]44
#[serde(rename_all = "camelCase")]45
pub 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,56
}58
pub 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()))70
}72
// ---------------------------------------------------------------------------------------------73
// Concurrency guard74
// ---------------------------------------------------------------------------------------------76
#[cfg(unix)]77
fn 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()84
}86
/// Detects a running Electron 0.1.0 Core (Chromium profile lock in the same directory).87
pub 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
}111
}113
/// Locks held for the whole process lifetime.114
pub struct RuntimeLock {115
_runtime: File,116
#[cfg(windows)]117
_chromium: Option<File>,118
}120
pub 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 start132
// 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
})140
}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")]149
pub 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>,156
}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 stays162
/// 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 the164
/// usual "already running" checks stop this start on the old folder. On Unix the old folder's runtime lock is held165
/// across the rename, so an old Core cannot take the folder in between; Windows cannot rename a folder with an open166
/// 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 the168
/// 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 move170
/// is tried at the next start.171
/// * Neither exists: the new folder (created later by the Core).172
pub 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 cannot178
// 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
}210
}212
// ---------------------------------------------------------------------------------------------213
// Backup214
// ---------------------------------------------------------------------------------------------216
fn 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-")227
}229
fn 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 directory240
} 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(())250
}252
pub 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)286
}288
fn prune_backups(root: &Path) {289
let Ok(entries) = fs::read_dir(root) else { return };290
let mut dirs: Vec<PathBuf> = entries291
.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
}300
}302
/// Complete verified backup. Returns (backup dir, files, bytes, skipped symlinks).303
pub 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))348
}350
/// Verifies every file of a backup against its manifest.351
pub 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)367
}369
/// Rollback: verify the backup, move the current top-level entries it covers to370
/// `backups/pre-restore-<timestamp>/` (kept, not deleted) and copy the backup into place.371
pub 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(¤t).is_ok() {385
fs::rename(¤t, 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)399
}401
/// Location for a future, backwards-incompatible data format (never the 0.1.0 files).402
pub fn separate_store_for(data: &Path, format: u32) -> PathBuf {403
data.join(STORES_DIR).join(format!("format-{format}"))404
}406
/// Once-per-version upgrade step. Never modifies existing data files.407
pub 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 = marker425
.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)468
}470
#[cfg(test)]471
mod 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
}648
}SHA-256
e5472c175fd721d4a9017f4cba01624e6fbe18beacb851bde22e70cb1ce87905CH-J Proprietary Software License 1.14 · 소스는 CH-J Proprietary Software License 1.14에 따라 제공됩니다. 소스를 공개한다고 해서 라이선스 조항이 바뀌거나 추가 권리가 부여되지는 않습니다.
압축 파일 SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f