Veröffentlichte Quellen
Quellen als ZIP herunterladen
CH-J Tools
Durchsuchen Sie Verzeichnisse und Dateien einer bestimmten Anwendungsausgabe.
CH-J / Server Manager
872 Dateien
core-20261030T220507Z-61e4558d889f
Hinzugefügt: · Erstellt:
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 Server Manager 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
// Backup144
// ---------------------------------------------------------------------------------------------146
fn is_excluded(relative: &Path) -> bool {147
let text = relative.to_string_lossy().replace('\\', "/");148
let first = text.split('/').next().unwrap_or("");149
if relative.components().count() == 1 && EXCLUDED_TOP_LEVEL.contains(&first) {150
return true;151
}152
if EXCLUDED_NESTED.iter().any(|prefix| text == *prefix || text.starts_with(&format!("{prefix}/"))) {153
return true;154
}155
let name = relative.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();156
name.contains(".tmp-") || name.ends_with(".replace-backup") || name.starts_with(".staging-")157
}159
fn collect(data: &Path, relative: &Path, files: &mut Vec<PathBuf>, links: &mut usize) -> CoreResult<()> {160
if !relative.as_os_str().is_empty() && is_excluded(relative) {161
return Ok(());162
}163
let full = data.join(relative);164
let meta = match fs::symlink_metadata(&full) {165
Ok(meta) => meta,166
Err(_) => return Ok(()),167
};168
if meta.file_type().is_symlink() {169
*links += 1; // never follow links out of the data directory170
} else if meta.is_file() {171
files.push(relative.to_path_buf());172
} else if meta.is_dir() {173
let mut names: Vec<_> = fs::read_dir(&full)?.flatten().map(|e| e.file_name()).collect();174
names.sort();175
for name in names {176
collect(data, &relative.join(name), files, links)?;177
}178
}179
Ok(())180
}182
pub fn check_integrity(data: &Path) -> (String, String) {183
let config = match fs::read_to_string(data.join("config/config.json")) {184
Err(_) => "missing".to_string(),185
Ok(text) => match serde_json::from_str::<Value>(&text) {186
Ok(value) if value.get("schemaVersion").and_then(Value::as_u64) == Some(1) => "ok".to_string(),187
Ok(_) => "unsupported-schema".to_string(),188
Err(_) => "invalid-json".to_string(),189
},190
};191
let meta = data.join("vault/core-v1.meta.json");192
let blob = data.join("vault/core-v1.data.json");193
let read = |path: &Path| fs::read_to_string(path).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok());194
let vault = match (meta.exists(), blob.exists()) {195
(false, false) => "not-created".to_string(),196
(true, true) => {197
let meta_ok = read(&meta)198
.map(|m| {199
m.get("formatVersion").and_then(Value::as_u64) == Some(1)200
&& m.get("cipher").and_then(Value::as_str) == Some("aes-256-gcm")201
&& m.pointer("/kdf/name").and_then(Value::as_str) == Some("scrypt")202
&& m.get("salt").and_then(Value::as_str).map(|s| !s.is_empty()).unwrap_or(false)203
})204
.unwrap_or(false);205
let blob_ok = read(&blob)206
.map(|b| {207
b.get("formatVersion").and_then(Value::as_u64) == Some(1)208
&& ["iv", "authTag", "ciphertext"].iter().all(|k| b.get(*k).and_then(Value::as_str).map(|s| !s.is_empty()).unwrap_or(false))209
})210
.unwrap_or(false);211
if meta_ok && blob_ok { "ok".to_string() } else { "damaged".to_string() }212
}213
_ => "incomplete".to_string(),214
};215
(config, vault)216
}218
fn prune_backups(root: &Path) {219
let Ok(entries) = fs::read_dir(root) else { return };220
let mut dirs: Vec<PathBuf> = entries221
.flatten()222
.map(|e| e.path())223
.filter(|p| p.is_dir() && p.file_name().map(|n| n.to_string_lossy().starts_with("pre-")).unwrap_or(false))224
.collect();225
dirs.sort_by_key(|p| fs::metadata(p).and_then(|m| m.modified()).ok());226
while dirs.len() > KEEP_BACKUPS {227
let oldest = dirs.remove(0);228
let _ = fs::remove_dir_all(oldest);229
}230
}232
/// Complete verified backup. Returns (backup dir, files, bytes, skipped symlinks).233
pub fn create_backup(data: &Path, label: &str, metadata: Value) -> CoreResult<(PathBuf, usize, u64, usize)> {234
let mut files = Vec::new();235
let mut links = 0usize;236
collect(data, Path::new(""), &mut files, &mut links)?;237
let total: u64 = files.iter().filter_map(|f| fs::metadata(data.join(f)).ok()).map(|m| m.len()).sum();238
if total > MAX_BACKUP_BYTES {239
return Err(CoreError::new("MIGRATION_BACKUP_TOO_LARGE", "The data directory is too large for an automatic backup."));240
}241
let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ").to_string();242
let backup_root = data.join(BACKUP_DIR);243
ensure_private_dir(&backup_root)?;244
let target = backup_root.join(format!("{label}-{stamp}"));245
let staging = backup_root.join(format!(".staging-{label}-{stamp}"));246
let result = (|| -> CoreResult<Vec<Value>> {247
let mut manifest = Vec::with_capacity(files.len());248
for relative in &files {249
let source = data.join(relative);250
let destination = staging.join(relative);251
if let Some(parent) = destination.parent() {252
ensure_private_dir(parent)?;253
}254
fs::copy(&source, &destination)?;255
let (original, copy) = (sha256_file(&source)?, sha256_file(&destination)?);256
if original != copy {257
return Err(CoreError::new("MIGRATION_BACKUP_VERIFY_FAILED", "A backup copy does not match its original."));258
}259
manifest.push(json!({ "path": relative.to_string_lossy().replace('\\', "/"), "size": fs::metadata(&destination)?.len(), "sha256": copy }));260
}261
Ok(manifest)262
})();263
let manifest = match result {264
Ok(manifest) => manifest,265
Err(error) => {266
let _ = fs::remove_dir_all(&staging);267
return Err(error);268
}269
};270
let mut document = json!({ "schemaVersion": 1, "createdAt": chrono::Utc::now().to_rfc3339(), "dataFormat": DATA_FORMAT, "files": manifest, "skippedSymlinks": links });271
if let (Some(doc), Value::Object(extra)) = (document.as_object_mut(), metadata) {272
doc.extend(extra);273
}274
ensure_private_dir(&staging)?;275
atomic_write_json(&staging.join("backup-manifest.json"), &document)?;276
fs::rename(&staging, &target)?;277
Ok((target, files.len(), total, links))278
}280
/// Verifies every file of a backup against its manifest.281
pub fn verify_backup(backup: &Path) -> CoreResult<Vec<(String, String)>> {282
let manifest: Value = serde_json::from_str(&fs::read_to_string(backup.join("backup-manifest.json"))?)?;283
let mut entries = Vec::new();284
for entry in manifest["files"].as_array().cloned().unwrap_or_default() {285
let path = entry["path"].as_str().unwrap_or("").to_string();286
let expected = entry["sha256"].as_str().unwrap_or("").to_string();287
let relative = Path::new(&path);288
if path.is_empty() || relative.is_absolute() || relative.components().any(|c| matches!(c, std::path::Component::ParentDir)) {289
return Err(CoreError::new("MIGRATION_BACKUP_INVALID", "The backup manifest contains an unsafe path."));290
}291
if sha256_file(&backup.join(relative)).map_err(|_| CoreError::new("MIGRATION_BACKUP_INVALID", format!("Backup file is missing: {path}")))? != expected {292
return Err(CoreError::new("MIGRATION_BACKUP_INVALID", format!("Backup file is damaged: {path}")));293
}294
entries.push((path, expected));295
}296
Ok(entries)297
}299
/// Rollback: verify the backup, move the current top-level entries it covers to300
/// `backups/pre-restore-<timestamp>/` (kept, not deleted) and copy the backup into place.301
pub fn restore_backup(data: &Path, backup: &Path) -> CoreResult<PathBuf> {302
let entries = verify_backup(backup)?;303
let mut tops: Vec<String> = entries.iter().map(|(p, _)| p.split('/').next().unwrap_or("").to_string()).collect();304
tops.sort();305
tops.dedup();306
let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ").to_string();307
let aside = data.join(BACKUP_DIR).join(format!("pre-restore-{stamp}"));308
ensure_private_dir(&aside)?;309
for top in &tops {310
if top.is_empty() || EXCLUDED_TOP_LEVEL.contains(&top.as_str()) {311
continue;312
}313
let current = data.join(top);314
if fs::symlink_metadata(¤t).is_ok() {315
fs::rename(¤t, aside.join(top))?;316
}317
}318
for (path, expected) in &entries {319
let destination = data.join(path);320
if let Some(parent) = destination.parent() {321
ensure_private_dir(parent)?;322
}323
fs::copy(backup.join(path), &destination)?;324
if &sha256_file(&destination)? != expected {325
return Err(CoreError::new("MIGRATION_RESTORE_FAILED", format!("Restored file does not match the backup: {path}")));326
}327
}328
Ok(aside)329
}331
/// Location for a future, backwards-incompatible data format (never the 0.1.0 files).332
pub fn separate_store_for(data: &Path, format: u32) -> PathBuf {333
data.join(STORES_DIR).join(format!("format-{format}"))334
}336
/// Once-per-version upgrade step. Never modifies existing data files.337
pub fn prepare_data_directory(data: &Path, version: &str, build_id: &str) -> CoreResult<MigrationReport> {338
ensure_private_dir(data)?;339
let marker_path = data.join(MARKER_FILE);340
let marker: Option<Value> = fs::read_to_string(&marker_path).ok().and_then(|t| serde_json::from_str(&t).ok());341
let previous_version = marker.as_ref().and_then(|m| m.get("lastVersion")).and_then(Value::as_str).map(str::to_string);342
let previous_format = marker.as_ref().and_then(|m| m.get("dataFormat")).and_then(Value::as_u64).unwrap_or(1) as u32;343
if previous_format > DATA_FORMAT {344
return Err(CoreError::new(345
"DATA_FORMAT_NEWER",346
"The data directory was written by a newer CH-J Server Manager. Install that version or restore a backup.",347
));348
}349
let (config, vault) = check_integrity(data);350
let mut probe = Vec::new();351
let mut probe_links = 0;352
collect(data, Path::new(""), &mut probe, &mut probe_links)?;353
let has_existing_data = probe.iter().any(|p| p != Path::new(MARKER_FILE));354
let previous_runtime = marker355
.as_ref()356
.and_then(|m| m.get("runtime"))357
.and_then(Value::as_str)358
.map(str::to_string)359
.or_else(|| has_existing_data.then(|| "electron".to_string()));360
let mut report = MigrationReport {361
previous_runtime: previous_runtime.clone(),362
previous_version: previous_version.clone(),363
data_format: DATA_FORMAT,364
backup: None,365
files: 0,366
bytes: 0,367
skipped_links: 0,368
vault,369
config,370
performed: false,371
};372
if previous_version.as_deref() == Some(version) {373
return Ok(report);374
}375
if has_existing_data {376
let (backup, files, bytes, links) = create_backup(377
data,378
&format!("pre-{version}"),379
json!({ "fromVersion": previous_version, "fromRuntime": previous_runtime, "toVersion": version, "toBuild": build_id, "vault": report.vault, "config": report.config }),380
)?;381
verify_backup(&backup)?;382
prune_backups(&data.join(BACKUP_DIR));383
report.backup = Some(backup.to_string_lossy().to_string());384
report.files = files;385
report.bytes = bytes;386
report.skipped_links = links;387
}388
let mut history = marker.as_ref().and_then(|m| m.get("history")).and_then(Value::as_array).cloned().unwrap_or_default();389
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 }));390
let keep = history.len().saturating_sub(20);391
history.drain(..keep);392
atomic_write_json(393
&marker_path,394
&json!({ "schemaVersion": 1, "runtime": "tauri", "lastVersion": version, "lastBuildId": build_id, "dataFormat": DATA_FORMAT, "history": history }),395
)?;396
report.performed = true;397
Ok(report)398
}400
#[cfg(test)]401
mod tests {402
use super::*;404
fn electron_like_directory(data: &Path) {405
for (path, content) in [406
("config/config.json", r#"{"schemaVersion":1}"#),407
("vault/core-v1.meta.json", r#"{"formatVersion":1,"cipher":"aes-256-gcm","kdf":{"name":"scrypt"},"salt":"AAAA"}"#),408
("vault/core-v1.data.json", r#"{"formatVersion":1,"iv":"a","authTag":"b","ciphertext":"c"}"#),409
("logs/core.log", "{}\n"),410
("plugins/chj.x/1.0.0/manifest.json", "{}"),411
("plugins/.downloads/x.zip", "x"),412
("updates/downloads/a.dmg", "x"),413
("GPUCache/data", "cache"),414
("Local Storage/leveldb/000003.log", "ls"),415
("transfers.json", "{}"),416
] {417
let full = data.join(path);418
fs::create_dir_all(full.parent().unwrap()).unwrap();419
fs::write(full, content).unwrap();420
}421
}423
#[test]424
fn complete_backup_once_per_version_keeps_originals() {425
let dir = tempfile::tempdir().unwrap();426
let data = dir.path();427
electron_like_directory(data);428
let first = prepare_data_directory(data, "0.1.1", "b1").unwrap();429
assert!(first.performed);430
assert_eq!(first.previous_runtime.as_deref(), Some("electron"));431
assert_eq!(first.vault, "ok");432
let backup = PathBuf::from(first.backup.clone().unwrap());433
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"] {434
assert!(backup.join(kept).exists(), "{kept} missing in backup");435
}436
for skipped in ["plugins/.downloads", "updates/downloads", "GPUCache"] {437
assert!(!backup.join(skipped).exists(), "{skipped} must not be backed up");438
}439
assert_eq!(verify_backup(&backup).unwrap().len(), first.files);440
assert!(data.join("config/config.json").exists());441
let second = prepare_data_directory(data, "0.1.1", "b1").unwrap();442
assert!(!second.performed && second.backup.is_none());443
}445
#[test]446
fn restore_moves_current_state_aside() {447
let dir = tempfile::tempdir().unwrap();448
let data = dir.path();449
electron_like_directory(data);450
let report = prepare_data_directory(data, "0.1.1", "b1").unwrap();451
fs::write(data.join("config/config.json"), r#"{"schemaVersion":1,"changed":true}"#).unwrap();452
fs::write(data.join("config/extra.json"), "{}").unwrap();453
let aside = restore_backup(data, Path::new(report.backup.as_ref().unwrap())).unwrap();454
assert_eq!(fs::read_to_string(data.join("config/config.json")).unwrap(), r#"{"schemaVersion":1}"#);455
assert!(!data.join("config/extra.json").exists());456
assert!(aside.join("config/extra.json").exists());457
}459
#[test]460
fn damaged_backup_is_rejected() {461
let dir = tempfile::tempdir().unwrap();462
let data = dir.path();463
electron_like_directory(data);464
let backup = PathBuf::from(prepare_data_directory(data, "0.1.1", "b").unwrap().backup.unwrap());465
fs::write(backup.join("transfers.json"), "tampered").unwrap();466
assert_eq!(restore_backup(data, &backup).unwrap_err().code, "MIGRATION_BACKUP_INVALID");467
assert_eq!(fs::read_to_string(data.join("transfers.json")).unwrap(), "{}");468
}470
#[test]471
fn newer_data_format_is_refused() {472
let dir = tempfile::tempdir().unwrap();473
fs::write(dir.path().join(MARKER_FILE), r#"{"lastVersion":"9.0.0","dataFormat":99}"#).unwrap();474
assert_eq!(prepare_data_directory(dir.path(), "0.1.1", "b").unwrap_err().code, "DATA_FORMAT_NEWER");475
assert_eq!(separate_store_for(dir.path(), 2), dir.path().join("stores/format-2"));476
}478
#[test]479
fn fresh_install_has_no_backup() {480
let dir = tempfile::tempdir().unwrap();481
let report = prepare_data_directory(dir.path(), "0.1.1", "b1").unwrap();482
assert!(report.performed && report.backup.is_none());483
assert_eq!(report.vault, "not-created");484
}486
#[test]487
fn runtime_lock_is_exclusive() {488
let dir = tempfile::tempdir().unwrap();489
let _lock = acquire_runtime_lock(dir.path()).unwrap();490
assert_eq!(acquire_runtime_lock(dir.path()).err().unwrap().code, "CORE_ALREADY_RUNNING");491
}493
#[cfg(unix)]494
#[test]495
fn stale_singleton_lock_is_not_running() {496
let dir = tempfile::tempdir().unwrap();497
std::os::unix::fs::symlink(format!("{}-999999", hostname()), dir.path().join("SingletonLock")).unwrap();498
assert!(!legacy_electron_running(dir.path()));499
}500
}SHA-256
c731189a1fef9d85234a9e582d48483f99fd1d80485cc2dc28aa236d347bf402CH-J Proprietary Software License 1.14 · Die Quellen werden unter der CH-J Proprietary Software License 1.14 bereitgestellt. Ihre Verfügbarkeit ändert die Lizenzbedingungen nicht und gewährt keine zusätzlichen Rechte.
SHA-256 des Archivs
77380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df