Harden server update history, recovery guards and repeated checks

This commit is contained in:
Дмитрий committed 2026-10-06 07:01:13 +05:00
1 parent 3940506987
commit 0d6d8a0db8
37 files changed
+1112 -99

No files matched your search

Generated
+3 -3
View File
@@ -491,7 +491,7 @@ dependencies = [
[[package]]
name = "coa-core"
version = "0.6.4"
version = "0.6.5"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -519,7 +519,7 @@ dependencies = [
[[package]]
name = "coa-release"
version = "0.6.4"
version = "0.6.5"
dependencies = [
"chrono",
"coa-core",
@@ -528,7 +528,7 @@ dependencies = [
[[package]]
name = "coa-server-manager"
version = "0.6.4"
version = "0.6.5"
dependencies = [
"coa-core",
"serde",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/coa-core", "crates/coa-release", "src-tauri"]
[workspace.package]
version = "0.6.4"
version = "0.6.5"
edition = "2021"
license = "AGPL-3.0-only"
+104
View File
@@ -0,0 +1,104 @@
# Urgent update hotfix
Deadline: 2026-10-06. This checkout starts at the exact v0.6.4 tag, commit
6c771fa, and excludes the ongoing isolated-trial/recovery redesign.
The client accepts checksum compatibility only from a verified signed manifest and only
for an Applied migration. It never updates the historical checksum, reruns historical
SQL, or excuses a Running/Failed ledger row. A server package must explicitly list the
verified published variants and append corrections under new migration IDs.
Current local evidence: the locked workspace tests passed, including 252 core tests
run serially. Six frontend logic tests and the frontend production build passed.
The hotfix runtime was tested against the exact signed candidate manifest on the marked
CoA/Wildcard fixture. Both published repair checksums were accepted independently in
both worlds, with zero migration executions, unchanged historical checksums, and unchanged
account, character and inventory counts. The original ledger rows were restored after
the test, and all fixture processes were stopped. A real Running or Failed repair row in
either world was rejected before staging or creating a transaction, with unchanged world
executable and installation metadata. Tests use independent hotfix metadata so experimental
transaction formats are not confused with the 0.6.4 upgrade path. Windows journal writes also use flushed
atomic replacement with long-path support. Changelog validation passed.
Additional guards implemented:
- Read migration history without creating its ledger before downloading or replacing files.
- Reject missing journals, invalid identities, paths, hashes and inconsistent operations.
Durable completed-cleanup markers distinguish interrupted pruning from a lost journal.
- Preserve all backups while an update is unfinished or its journal is damaged.
- Verify saved-file hashes and the complete database/configuration recovery point before
stopping services or changing files/databases. New snapshots include configuration hashes;
update journals bind their backup metadata to a hash.
- Surface state-reading errors in Settings and disable applying until the disk state is
successfully read. Checking again refreshes that state.
- Verify every extracted SQL artifact before taking the update snapshot or replacing
server files; missing files, invalid IDs, duplicate migrations and checksum disagreements
reject the update early. SQL application repeats the integrity check immediately before use.
- Count unapplied migrations in the active and secondary realms, counting shared auth once.
Matching versions and files do not hide remaining database work. Preview reads history
under the installation lock; a stopped database is temporarily started for inspection
and stopped again when no game services are running.
- Reread the disk state after every update/recovery attempt, including failed attempts and
background updates. Unfinished or unreadable state blocks another application in the UI.
- Acquire the installation lock before checking and deleting a backup.
Latest real-runtime test used the independently copied, doubly marked fixture
`C:\games\coa-schema-fixture-chaos-20261005-v1` and signed candidate `0.261005.24`, manifest SHA-256
`1284b1273815209c9621b6d173a2211bae316f33462ccbe8b578e53b8d26fb5b`.
All 1748 SQL artifacts passed verification after authenticated archive extraction.
Removing one repair record separately in CoA and Wildcard increased the pending count
by exactly one; restoring its original record restored the original count. No migrations
were executed, player counts were preserved and fixture services were stopped afterward.
The source checkout remains uncommitted; these results are not an installer acceptance report.
Additional acceptance on 2026-10-06: a new independent installation/database copy at
`C:\games\coa-schema-fixture-chaos-064-transition-20261006` received the authenticated
published `0.261005.22` program files, original CRLF repair checksum, and a deliberately
missing Wildcard table in both realms. The production hotfix update pipeline committed
signed candidate `0.261005.24`, repaired both realms, passed schema checks and actual
bundled-Python server startup, and preserved the selected account, character, item and
inventory fields byte-for-byte. The fixture was stopped afterward. The harness explicitly
instruments the launcher to prevent relay submissions and bind to localhost; this is not
proof of packaged UI behavior or operating-system network containment.
Local installer `C:\games\source\manager-065-local-installer-20261006\CoA Server Manager_0.6.5_x64-setup.exe`
has SHA-256 `520ee0391d923b92da0a20591e30006c048cdb60156275d7a829918bf1b77491`.
Its updater signature and the published 0.6.4 installer's signature passed independent
verification with the configured updater public key. Both executables extracted from
their exact NSIS packages started, created responsive windows, and were stopped using
an independent application/WebView profile. No installer was executed over a user's
installation, and no UI update operation was exercised.
Legacy limitation: old 0.6.4 transaction copies and configuration snapshots without recorded
hashes can be checked for presence and declared database hashes, but their original file
contents cannot be authenticated retroactively. Missing copies block restoration. This
hotfix does not claim that old unchecksummed copies received full integrity verification.
Remaining release requirements: reproduce the existing-user failures through the published
0.6.4 interface, verify legacy unfinished transaction handling on real-runtime fixtures,
and execute the exact installer over 0.6.4 in an isolated Windows environment with UI
state/settings/server-list verification. Windows native UI automation is unavailable in
this session. Packaged startup and signature checks do not satisfy that installer gate.
No public release has been made.
The user subsequently exercised the packaged UI: candidate 0.261005.24 committed and
both CoA and Wildcard were shown running. Repeat-check/restart acceptance is still pending.
The screenshots exposed an older published package being offered after the local candidate
was installed. Follow-up changes reject older packages before preview/database inspection
and application and clear a cached offer after a failed recheck. All 253 core tests and
seven frontend tests passed. The earlier installer hash above does not include this follow-up;
a separately recorded revision is required. Native UI control was subsequently initialized
through the deferred node_repl tool, then the user stopped automation with Escape and
performed the UI check manually.
Another user repeat-check exposed the already installed launcher being offered again.
The preview now verifies canonical launcher bytes against the current signed package,
caches those bytes by SHA-256, and compares only recognized Manager transformations.
The actual disposable fixture's repeat preview passed with all 104 files skipped and
zero pending SQL. User edits, recorded custom content, and a package with different
launcher bytes are not mistaken for that integration. All 254 core tests passed;
the strengthened regression test and seven frontend tests also passed. The first
preview of a managed launcher may download/extract its authenticated package to build
the optional canonical cache. Earlier installer revisions do not include this fix.
Do not recommend deleting an installation or replacing its player databases.
@@ -0,0 +1,7 @@
---
area: manager
type: fixed
audience: admins
title: Server updates accept verified published migration variants without rerunning them
---
Signed updates can recognise known published checksums without changing your migration history. Interrupted or failed SQL still requires recovery before retrying.
@@ -0,0 +1,7 @@
---
area: manager
type: fixed
audience: admins
title: Older server packages cannot replace a newer installation
---
Update checks stop offering stale packages after an update. To undo an update, use its recovery point instead of applying an older server package.
@@ -0,0 +1,7 @@
---
area: manager
type: fixed
audience: admins
title: Checking again no longer offers an already installed launcher update
---
The Manager recognizes its own launcher integration while still detecting user edits and changes in newer signed packages.
@@ -0,0 +1,7 @@
---
area: manager
type: fixed
audience: admins
title: Update history conflicts are detected before server files change
---
Damaged update records block changes and remain visible. Recovery checks saved copies before restoring, and unfinished updates protect backups from deletion and cleanup.
@@ -0,0 +1,7 @@
---
area: manager
type: fixed
audience: admins
title: Pending database updates remain visible when the server version matches
---
SQL files are checked before replacing server files. Update and recovery attempts refresh their saved status, including errors. Backup deletion cannot race with an update.
+1 -1
View File
@@ -31,7 +31,7 @@ rand_core = { version = "0.6", features = ["getrandom"] }
ed25519-dalek = { version = "2", features = ["rand_core"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.60", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_SystemInformation", "Win32_System_Diagnostics_ToolHelp", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock"] }
windows-sys = { version = "0.60", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Threading", "Win32_System_SystemInformation", "Win32_System_Diagnostics_ToolHelp", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock"] }
[features]
testkit = []
@@ -0,0 +1,93 @@
//! Verify published checksum compatibility on a disposable real database, without running SQL migrations.
use std::{path::{Path, PathBuf}, sync::atomic::{AtomicUsize, Ordering}};
use coa_core::{backup, db::Db, migrations::{self, LedgerRow, Status, Store}, pkgsource::{self, Source}, Result};
struct Observed<'a> { db: &'a Db, executions: AtomicUsize }
impl Store for Observed<'_> {
fn load(&self) -> Result<Vec<LedgerRow>> { self.db.load() }
fn put(&self, row: &LedgerRow) -> Result<()> { self.db.put(row) }
fn run_file(&self, _: &str, _: &Path) -> Result<()> {
self.executions.fetch_add(1, Ordering::SeqCst);
panic!("Historical SQL must never be replayed");
}
}
fn main() -> Result<()> {
let args: Vec<_> = std::env::args().skip(1).collect();
let root = PathBuf::from(&args[0]);
assert!(root.file_name().unwrap().to_string_lossy().starts_with("coa-schema-fixture-chaos-"));
assert_eq!(std::fs::read(root.join(".coa-chaos-fixture"))?, b"disposable update chaos fixture");
assert_eq!(std::fs::read(root.join(".release-schema-fixture"))?, b"disposable release-schema fixture");
let source = Source::Dir(PathBuf::from(&args[1]));
let package = pkgsource::fetch_manifest(&source, coa_core::signing::EMBEDDED_PUBLIC_KEY)?.0;
let staging = tempfile::tempdir()?;
let parts = pkgsource::fetch_parts(&source, &package, &staging.path().join("download"), &Default::default(), &|_, _| {})?;
coa_core::package::extract(&parts, &package, &staging.path().join("tree"), &|_, _| {})?;
migrations::verify_files(&package.migrations, &staging.path().join("tree/_migrations"))?;
println!("PASS: all {} signed-package SQL artifacts verified before any database mutation", package.migrations.len());
let migration = package.migrations.iter().find(|m| m.id == "manager_repair__20261005_missing_wildcard_tables").unwrap();
let hashes = ["c1d8dbf2271234283a48a39e4b4aea1106b83b70581f1e3c4aa9fff438e8e4d2", "9e4a36245c3f83255415c122c01f08ceb3e3b83898d3d427ac4e2fbcf61e9638"];
for hash in hashes { assert!(migration.sha256 == hash || migration.compatible_sha256.iter().any(|h| h == hash)); }
let fixture_meta = coa_core::registry::metadata_dir_for(&root)?;
let test_meta = tempfile::tempdir()?;
coa_core::fsx::atomic_write(&test_meta.path().join("install.json"), &std::fs::read(fixture_meta.join("install.json"))?)?;
coa_core::registry::MetaDir::open(test_meta.path())?;
std::fs::create_dir_all(test_meta.path().join("updates"))?;
let result = backup::with_database(&root, |db| {
let mut modes = vec![coa_core::realms::Mode::Coa];
if coa_core::realms::state(&root)?.wildcard_created { modes.push(coa_core::realms::Mode::Wildcard); }
for mode in modes {
let db = db.clone().for_realm(mode);
let saved = db.load()?.into_iter().find(|row| row.id == migration.id && row.db == migration.db).expect("Published repair must already be applied");
assert_eq!(saved.status, Status::Applied);
let counts = db.query("SELECT CONCAT((SELECT COUNT(*) FROM acore_characters.characters),':',(SELECT COUNT(*) FROM acore_characters.item_instance),':',(SELECT COUNT(*) FROM acore_auth.account));")?;
let check = || -> Result<()> {
for hash in hashes {
db.put(&LedgerRow { sha256: hash.into(), ..saved.clone() })?;
let observed = Observed { db: &db, executions: AtomicUsize::new(0) };
migrations::preflight(&db, std::slice::from_ref(migration))?;
let report = migrations::apply_pending(&observed, std::slice::from_ref(migration), &root.join(".unused-hotfix-sql"), &|| panic!("Already applied SQL needs no snapshot"))?;
assert!(report.applied.is_empty());
assert_eq!(observed.executions.load(Ordering::SeqCst), 0);
assert_eq!(db.load()?.into_iter().find(|r| r.id == migration.id && r.db == migration.db).unwrap().sha256, hash);
}
let meta = test_meta.path();
let file_hash = coa_core::fsx::sha256_file(&root.join("Core/worldserver.exe"))?;
let install_hash = coa_core::fsx::sha256_file(&meta.join("install.json"))?;
let transaction_count = std::fs::read_dir(meta.join("updates"))?.count();
for status in [Status::Running, Status::Failed] {
db.put(&LedgerRow { status, ..saved.clone() })?;
let env = coa_core::update::RepackEnv { root: &root, meta_dir: meta };
let rejected = coa_core::update::apply(&coa_core::update::Params {
root: &root, meta_dir: meta, source: Source::Dir(PathBuf::from(&args[1])),
trusted_key: coa_core::signing::EMBEDDED_PUBLIC_KEY, cancel: Default::default(),
resolutions: Default::default(), env: &env, fail_after_ops: None,
}, &|_, _| {});
let message = rejected.unwrap_err().to_string();
assert!(message.contains("partially applied"), "Unexpected rejection: {message}");
assert_eq!(std::fs::read_dir(meta.join("updates"))?.count(), transaction_count);
assert_eq!(coa_core::fsx::sha256_file(&root.join("Core/worldserver.exe"))?, file_hash);
assert_eq!(coa_core::fsx::sha256_file(&meta.join("install.json"))?, install_hash);
}
db.put(&saved)?;
let env = coa_core::update::RepackEnv { root: &root, meta_dir: meta };
let pending_before = env.pending_migrations(&package)?;
db.query("DELETE FROM acore_world.coa_manager_migrations WHERE db='characters' AND id='manager_repair__20261005_missing_wildcard_tables';")?;
assert_eq!(env.pending_migrations(&package)?, pending_before + 1,
"Missing history in either realm must remain visible even with identical server files");
db.put(&saved)?;
assert_eq!(env.pending_migrations(&package)?, pending_before);
Ok(())
};
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(check));
db.put(&saved)?;
result.map_err(|_| coa_core::Error::Invalid("History regression assertion failed; the injected ledger row was restored.".into()))??;
assert_eq!(db.query("SELECT CONCAT((SELECT COUNT(*) FROM acore_characters.characters),':',(SELECT COUNT(*) FROM acore_characters.item_instance),':',(SELECT COUNT(*) FROM acore_auth.account));")?, counts);
println!("PASS: {} published checksums; Running/Failed rejected before staging; missing migration counted; zero SQL replays; history and player counts preserved", mode.name());
}
Ok(())
});
assert!(coa_core::driver::run(&root, coa_core::driver::Verb::StopAll)?.ok);
result?;
Ok(())
}
@@ -0,0 +1,16 @@
//! Read-only update preview against the marked disposable acceptance fixture.
use std::{collections::BTreeMap,path::PathBuf};
fn main()->coa_core::Result<()> {
let args:Vec<_>=std::env::args().skip(1).collect();
let root=PathBuf::from(&args[0]);
assert!(root.file_name().unwrap().to_string_lossy().starts_with("coa-schema-fixture-chaos-064-transition-"));
assert_eq!(std::fs::read(root.join(".transition-fixture"))?,b"disposable published-064 transition fixture");
let meta_dir=coa_core::registry::metadata_dir_for(&root)?;
let (_,meta)=coa_core::registry::MetaDir::open(&meta_dir)?;
let preview=coa_core::update::preview(&root,&meta,&coa_core::pkgsource::Source::Dir(args[1].clone().into()),coa_core::signing::EMBEDDED_PUBLIC_KEY,&BTreeMap::new())?;
assert_eq!(preview.from_version.as_deref(),Some(preview.to_version.as_str()));
assert_eq!(preview.pending_migrations,0);
assert!(preview.items.iter().all(|item|item.action==coa_core::update::Action::Skip),"{:?}",preview.items.iter().filter(|item|item.action!=coa_core::update::Action::Skip).collect::<Vec<_>>());
println!("PASS: real fixture repeated preview: {} files unchanged, zero pending SQL, no update offered",preview.items.len());
Ok(())
}
@@ -0,0 +1,165 @@
//! Real signed-package transition on an independent, explicitly marked disposable copy.
//! The Env wrapper instruments only launcher isolation; migration/backup/update code is production code.
use std::{collections::BTreeMap, path::{Path, PathBuf}};
use coa_core::{backup, driver, fsx, migrations, pkgsource::{self, Source}, registry::{InstallMeta, MetaDir}, update::{self, Env, RepackEnv}, Result};
struct Offline<'a>(RepackEnv<'a>);
impl Env for Offline<'_> {
fn ensure_stopped(&self) -> Result<()> { self.0.ensure_stopped() }
fn preflight(&self, m: &coa_core::manifest::Manifest) -> Result<()> { self.0.preflight(m) }
fn verify_snapshot(&self, id: &str) -> Result<()> { self.0.verify_snapshot(id) }
fn snapshot(&self) -> Result<String> { self.0.snapshot() }
fn restore_snapshot(&self, id: &str) -> Result<()> { self.0.restore_snapshot(id) }
fn migrate(&self, m: &coa_core::manifest::Manifest, p: &Path) -> Result<migrations::ApplyReport> { self.0.migrate(m,p) }
fn validate(&self) -> Result<()> {
let path=self.0.root.join("Scripts/manage.py");
let source=std::fs::read_to_string(&path)?.replace("\r\n", "\n");
// Explicit test instrumentation prevents relay transmissions and binds services locally.
let guarded=source.replace("def spawn(name, command, cwd, log):\n", "def spawn(name, command, cwd, log):\n for filename in ('worldserver.conf', 'authserver.conf'):\n path = ROOT / 'Core/configs' / filename\n if path.exists():\n text = path.read_text(encoding='utf-8-sig')\n for key, value in (('BindIP', '\"127.0.0.1\"'), ('Ra.IP', '\"127.0.0.1\"'), ('SOAP.Enabled', '0')):\n text = re.sub(r'(?m)^\\s*' + re.escape(key) + r'\\s*=.*$', '', text)\n text += '\\n' + key + ' = ' + value + '\\n'\n path.write_text(text, encoding='utf-8')\n").replace("def relay_worker():\n", "def relay_worker():\n save_process('relay-ready', os.getpid())\n while not (STATE / 'stop-relay').exists():\n time.sleep(0.5)\n return\n");
assert_ne!(source,guarded,"Fixture launcher isolation failed");
fsx::atomic_write(&path,guarded.as_bytes())?;
self.0.validate()
}
}
fn players(db: &coa_core::db::Db) -> Result<String> {
db.query("SELECT CONCAT(id,':',username,':',HEX(salt),':',HEX(verifier)) FROM acore_auth.account WHERE id=900001; SELECT CONCAT(guid,':',account,':',name,':',level,':',money) FROM acore_characters.characters WHERE guid=900001; SELECT CONCAT(guid,':',itemEntry,':',owner_guid,':',enchantments) FROM acore_characters.item_instance WHERE guid=900001; SELECT CONCAT(guid,':',bag,':',slot,':',item) FROM acore_characters.character_inventory WHERE guid=900001;")
}
fn main() -> Result<()> {
let args:Vec<_>=std::env::args().skip(1).collect();
let root=PathBuf::from(&args[0]);
assert!(root.file_name().unwrap().to_string_lossy().starts_with("coa-schema-fixture-chaos-064-transition-"));
assert_eq!(std::fs::read(root.join(".transition-fixture"))?,b"disposable published-064 transition fixture");
if args.get(3).is_some_and(|mode| mode=="legacy-recovery") {
return run_fixture(&root,||legacy_recovery(&root));
}
assert!(!root.join(".transition-started").exists(),"Use a new independent fixture");
fsx::atomic_write(&root.join(".transition-started"),b"started")?;
let meta=coa_core::registry::metadata_dir_for(&root)?;
assert!(!meta.exists(),"Do not reuse experimental or user metadata");
run_fixture(&root,||exercise(&root,&meta,&args[1],&args[2]))
}
fn run_fixture(root:&Path,operation:impl FnOnce()->Result<()>)->Result<()> {
let result=std::panic::catch_unwind(std::panic::AssertUnwindSafe(operation));
assert!(driver::run(root,driver::Verb::StopAll)?.ok,"Fixture shutdown failed");
result.map_err(|_|coa_core::Error::Invalid("Disposable acceptance assertion failed; services were stopped and recovery copies retained.".into()))?
}
fn legacy_recovery(root:&Path)->Result<()> {
let meta=coa_core::registry::metadata_dir_for(root)?;
assert!(driver::run(root,driver::Verb::StopAll)?.ok);
let expected=backup::with_database(root,|db| [coa_core::realms::Mode::Coa,coa_core::realms::Mode::Wildcard].into_iter().map(|mode|players(&db.clone().for_realm(mode))).collect::<Result<Vec<_>>>())?;
assert!(driver::run(root,driver::Verb::StopAll)?.ok);
let dirs=std::fs::read_dir(meta.join("updates"))?.collect::<std::io::Result<Vec<_>>>()?;
assert_eq!(dirs.len(),1,"Requires exactly the preceding successful disposable transition");
let txn_dir=dirs[0].path();
let journal=txn_dir.join("txn.json");
let mut legacy:serde_json::Value=fsx::read_json(&journal)?;
assert_eq!(legacy["state"],"committed");
let point=legacy["recovery_point"].as_str().unwrap().to_string();
let point_path=meta.join("backups").join(&point).join("backup.json");
let mut point_json:serde_json::Value=fsx::read_json(&point_path)?;
point_json["manager_version"]="0.6.4".into();
for component in point_json["components"].as_array_mut().unwrap() { component.as_object_mut().unwrap().remove("file_sha256"); }
fsx::atomic_write_json(&point_path,&point_json)?;
legacy.as_object_mut().unwrap().remove("recovery_hashes");
legacy.as_object_mut().unwrap().remove("databases_started");
legacy["state"]="applied".into();
fsx::atomic_write_json(&journal,&legacy)?;
assert!(update::ensure_recovered(&meta).is_err(),"Legacy unfinished update must block startup");
let id=legacy["id"].as_str().unwrap();
let env=RepackEnv {root,meta_dir:&meta};
println!("Recovering legacy-format transaction and recovery point on disposable installation");
assert_eq!(update::rollback(root,&meta,id,&env)?.state,update::State::RolledBack);
for op in legacy["ops"].as_array().unwrap() {
let relative=op["path"].as_str().unwrap();
if op["had_previous"]==true {
assert_eq!(fsx::sha256_file(&fsx::safe_join(root,relative)?)?,fsx::sha256_file(&fsx::safe_join(&txn_dir.join("before"),relative)?)?,"Legacy file restoration mismatch: {relative}");
} else { assert!(!fsx::safe_join(root,relative)?.exists()); }
}
assert_eq!(std::fs::read(meta.join("install.json"))?,std::fs::read(txn_dir.join("before/manager-install.json"))?);
backup::with_database(root,|db| {
for (index,mode) in [coa_core::realms::Mode::Coa,coa_core::realms::Mode::Wildcard].into_iter().enumerate() {
let db=db.clone().for_realm(mode);
assert_eq!(players(&db)?,expected[index]);
assert_eq!(db.query("SELECT COUNT(*) FROM acore_world.coa_manager_migrations WHERE id IN ('manager_repair__20261005_reconcile_wildcard_tables','manager_repair__20261005_reconcile_missing_class_starts');")?,"0");
assert_eq!(db.query("SELECT sha256 FROM acore_world.coa_manager_migrations WHERE id='manager_repair__20261005_missing_wildcard_tables';")?,"9e4a36245c3f83255415c122c01f08ceb3e3b83898d3d427ac4e2fbcf61e9638");
}
Ok(())
})?;
assert!(driver::run(root,driver::Verb::StopAll)?.ok);
assert!(update::pending_checked(&meta)?.is_none());
assert!(update::rollback(root,&meta,id,&env).is_err(),"Completed recovery must not run again");
println!("PASS: legacy journal blocked startup; old files, metadata, migration histories and player fields restored; completed recovery rejects replay. Original baseline had a missing table, so healthy startup is not claimed after rollback.");
Ok(())
}
fn exercise(root:&Path,meta:&Path,baseline:&str,candidate:&str)->Result<()> {
let mut install:InstallMeta=fsx::read_json(&PathBuf::from("C:/games/coa-schema-fixture-chaos-20261005-v1.manager/install.json"))?;
install.server_path=root.to_string_lossy().into_owned();
install.id=uuid::Uuid::new_v4().to_string();
install.manager_version="0.6.4".into();
MetaDir::create(root,&install)?;
// Private ports, and no inherited PID records or paths to the source database.
let mut cfg:serde_json::Value=fsx::read_json(&root.join("Settings/repack.json"))?;
let mut listeners=Vec::new();
for key in ["mysqlPort","authPort","worldPort","raPort"] {
let listener=std::net::TcpListener::bind("127.0.0.1:0")?;
cfg[key]=listener.local_addr()?.port().into(); listeners.push(listener);
}
fsx::atomic_write_json(&root.join("Settings/repack.json"),&cfg)?;
let mut profile:serde_json::Value=fsx::read_json(&root.join("Settings/realm-profile.json"))?;
for key in ["secondary_world_port","secondary_ra_port"] {
let listener=std::net::TcpListener::bind("127.0.0.1:0")?;
profile[key]=listener.local_addr()?.port().into(); listeners.push(listener);
}
fsx::atomic_write_json(&root.join("Settings/realm-profile.json"),&profile)?;
drop(listeners);
let state=root.join(".state");
if state.exists() { std::fs::remove_dir_all(&state)?; }
let secondary_state=root.join(".realms/secondary/.state");
if secondary_state.exists() { std::fs::remove_dir_all(&secondary_state)?; }
let source=Source::Dir(baseline.into());
let (manifest,_)=pkgsource::fetch_manifest(&source,coa_core::signing::EMBEDDED_PUBLIC_KEY)?;
let temp=tempfile::tempdir()?;
let parts=pkgsource::fetch_parts(&source,&manifest,&temp.path().join("download"),&Default::default(),&|_,_|{})?;
let tree=temp.path().join("tree");
coa_core::package::extract(&parts,&manifest,&tree,&|_,_|{})?;
for file in &manifest.files {
if file.path.starts_with("_migrations/") {continue;}
fsx::atomic_write(&fsx::safe_join(root,&file.path)?,&std::fs::read(fsx::safe_join(&tree,&file.path)?)?)?;
install.original_hashes.insert(file.path.clone(),file.sha256.clone());
}
install.core.version=Some(manifest.version);
fsx::atomic_write_json(&meta.join("install.json"),&install)?;
// Both worlds must remain local, including generated secondary configuration.
for relative in ["Settings/worldserver.conf.template","Settings/authserver.conf.template"] {
let p=root.join(relative);
let mut conf=coa_core::config::parser::ConfFile::parse_bytes(&std::fs::read(&p)?)?;
conf.set("BindIP","\"127.0.0.1\"",&[]); conf.set("Ra.IP","\"127.0.0.1\"",&[]); conf.set("SOAP.Enabled","0",&[]);
fsx::atomic_write(&p,conf.to_text().as_bytes())?;
}
let expected=backup::with_database(root,|db| {
let mut saved=Vec::new();
for mode in [coa_core::realms::Mode::Coa,coa_core::realms::Mode::Wildcard] {
let db=db.clone().for_realm(mode);
saved.push(players(&db)?);
db.query("DROP TABLE IF EXISTS acore_characters.coa_wildcard_skill_card; DELETE FROM acore_world.coa_manager_migrations WHERE id IN ('manager_repair__20261005_reconcile_wildcard_tables','manager_repair__20261005_reconcile_missing_class_starts'); UPDATE acore_world.coa_manager_migrations SET sha256='9e4a36245c3f83255415c122c01f08ceb3e3b83898d3d427ac4e2fbcf61e9638' WHERE id='manager_repair__20261005_missing_wildcard_tables' AND status='applied';")?;
}
Ok(saved)
})?;
let source=Source::Dir(candidate.into());
let candidate_manifest=pkgsource::fetch_manifest(&source,coa_core::signing::EMBEDDED_PUBLIC_KEY)?.0;
let choices:BTreeMap<_,_>=update::plan(root,&install,&candidate_manifest,&BTreeMap::new(),None)?.into_iter().filter(|p|p.action==update::Action::Conflict).map(|p|(p.path,update::Resolution::Replace)).collect();
let env=Offline(RepackEnv{root,meta_dir:meta});
let outcome=update::apply(&update::Params {root,meta_dir:meta,source,trusted_key:coa_core::signing::EMBEDDED_PUBLIC_KEY,cancel:Default::default(),resolutions:choices,env:&env,fail_after_ops:None},&|stage,pct|println!("{pct}% {stage}"))?;
assert_eq!(outcome.txn.state,update::State::Committed,"{:?}",outcome.txn.message);
backup::with_database(root,|db| {
for (index,mode) in [coa_core::realms::Mode::Coa,coa_core::realms::Mode::Wildcard].into_iter().enumerate() {
let db=db.clone().for_realm(mode);
assert_eq!(players(&db)?,expected[index],"Player data changed in {}",mode.name());
assert!(coa_core::schema_check::check(&db,root)?.is_empty());
assert_eq!(migrations::pending_count(&db,&candidate_manifest.migrations)?,0);
}
Ok(())
})?;
println!("PASS: signed historical package to candidate; missing Wildcard table repaired in both realms; player fields and inventory preserved; schema and startup verified. Launcher isolation was test instrumentation, not packaged UI acceptance.");
Ok(())
}
+1 -1
View File
@@ -29,7 +29,7 @@ fn main() {
let dst = staging.join(db).join(format!("{id}.sql"));
std::fs::create_dir_all(dst.parent().unwrap()).unwrap();
std::fs::write(&dst, &bytes).unwrap();
list.push(Migration { id, db: db.into(), sha256: fsx::sha256_bytes(&bytes), destructive: false });
list.push(Migration { compatible_sha256: vec![], id, db: db.into(), sha256: fsx::sha256_bytes(&bytes), destructive: false });
};
for (kind, dirs) in [("auth", ["db_auth", "pending_db_auth"]), ("characters", ["db_characters", "pending_db_characters"]), ("world", ["db_world", "pending_db_world"])] {
for d in dirs {
+1 -1
View File
@@ -58,7 +58,7 @@ fn main() -> Result<()> {
let path = staged.join(format!("{kind}/manager_realm_fixture.sql"));
std::fs::create_dir_all(path.parent().unwrap())?;
std::fs::write(&path, sql)?;
manifest.migrations.push(Migration { id: "manager_realm_fixture".into(), db: kind.into(), sha256: coa_core::fsx::sha256_file(&path)?, destructive: false });
manifest.migrations.push(Migration { compatible_sha256: vec![], id: "manager_realm_fixture".into(), db: kind.into(), sha256: coa_core::fsx::sha256_file(&path)?, destructive: false });
}
let result = env.migrate(&manifest, &staged)?;
assert!(result.failed.is_none());
+1 -1
View File
@@ -20,7 +20,7 @@ fn main() -> Result<()> {
fsx::atomic_write(&update_tree.join(format!("_migrations/world/{id}.sql")), sql.as_bytes())?;
let options = |kind, version: &str, migrations| BuildOptions { kind, version: version.into(), core_commit: None, built_at: "fixture".into(), part_size: 1 << 20, bots_commit: None, migrations };
let base = package::build(&base_tree, &base_pkg, &options(Kind::Base, "1.0.0", vec![]), &|_| {})?;
let update = package::build(&update_tree, &update_pkg, &options(Kind::Update, "1.0.1", vec![Migration { id: id.into(), db: "world".into(), sha256: fsx::sha256_bytes(sql.as_bytes()), destructive: false }]), &|_| {})?;
let update = package::build(&update_tree, &update_pkg, &options(Kind::Update, "1.0.1", vec![Migration { compatible_sha256: vec![], id: id.into(), db: "world".into(), sha256: fsx::sha256_bytes(sql.as_bytes()), destructive: false }]), &|_| {})?;
// A test-only trust key, unrelated to production signing material.
let key = SigningKey::from_bytes(&[23; 32]);
let public = STANDARD.encode(key.verifying_key().as_bytes());
+1 -1
View File
@@ -33,7 +33,7 @@ fn main() {
let sql = "CREATE TABLE IF NOT EXISTS coa_manager_update_test (id INT PRIMARY KEY); INSERT IGNORE INTO coa_manager_update_test VALUES (1);";
put(&src, "_migrations/world/2026_09_30_00_update_e2e.sql", sql);
let mut m = build(&src, &pkg, &BuildOptions { kind: Kind::Update, version: "0.2.0".into(), core_commit: None, built_at: chrono::Utc::now().to_rfc3339(), part_size: 1 << 20, bots_commit: None, migrations: vec![] }, &|_| {}).unwrap();
m.migrations.push(Migration { id: "2026_09_30_00_update_e2e".into(), db: "world".into(), sha256: fsx::sha256_bytes(sql.as_bytes()), destructive: false });
m.migrations.push(Migration { compatible_sha256: vec![], id: "2026_09_30_00_update_e2e".into(), db: "world".into(), sha256: fsx::sha256_bytes(sql.as_bytes()), destructive: false });
std::fs::write(pkg.join("manifest.json"), serde_json::to_vec_pretty(&m).unwrap()).unwrap();
let key = format!("{}/.coa-manager/signing/manifest-signing.key", std::env::var("USERPROFILE").unwrap());
let seed: [u8; 32] = STANDARD.decode(std::fs::read_to_string(key).unwrap().trim()).unwrap().try_into().unwrap();
@@ -37,7 +37,7 @@ fn exercise(root: &Path) -> Result<()> {
("world", "probe_world", "DROP TABLE manager_recovery_probe; THIS IS INTENTIONALLY INVALID SQL;"),
] {
fsx::atomic_write(&source.join(format!("_migrations/{db}/{id}.sql")), sql.as_bytes())?;
migrations.push(Migration { db: db.into(), id: id.into(), sha256: fsx::sha256_bytes(sql.as_bytes()), destructive: false });
migrations.push(Migration { compatible_sha256: vec![], db: db.into(), id: id.into(), sha256: fsx::sha256_bytes(sql.as_bytes()), destructive: false });
}
let pkg = root.join("fixture-package");
let manifest = package::build(&source, &pkg, &BuildOptions {
@@ -22,7 +22,7 @@ fn main() -> Result<()> {
let path = old_sql.join(format!("{name}.sql"));
let hash = fsx::sha256_file(&path)?;
db.put(&LedgerRow { db: "characters".into(), id: name.into(), sha256: hash.clone(), status: Status::Applied, error: None, baseline: false })?;
migrations.push(Migration { db: "characters".into(), id: name.into(), sha256: hash, destructive: false });
migrations.push(Migration { compatible_sha256: vec![], db: "characters".into(), id: name.into(), sha256: hash, destructive: false });
}
for (_, tables) in schema_check::WILDCARD_TABLES.iter().filter(|(kind,_)| *kind == "characters") {
for table in *tables { db.query(&format!("DROP TABLE IF EXISTS acore_characters.`{table}`;"))?; }
@@ -33,7 +33,7 @@ fn main() -> Result<()> {
let files = release_schema::collect(empty_core.path(), None, Some(&repairs))?;
assert_eq!(files.len(), 1);
let repair = &files[0];
migrations.push(Migration { db: repair.db.clone(), id: repair.id.clone(), sha256: repair.sha256.clone(), destructive: false });
migrations.push(Migration { compatible_sha256: vec![], db: repair.db.clone(), id: repair.id.clone(), sha256: repair.sha256.clone(), destructive: false });
let staged = tree.path().join("sql/characters");
fsx::atomic_write(&staged.join(format!("{}.sql", repair.id)), &std::fs::read(&repair.abs)?)?;
let report = migrations::apply_pending(&db, &migrations, &tree.path().join("sql"), &|| Ok("disposable fixture".into()))?;
+64 -7
View File
@@ -51,6 +51,8 @@ pub struct Component {
pub sha256: Option<String>,
pub tables: Option<usize>,
pub files: Option<Vec<String>>,
#[serde(default)]
pub file_sha256: std::collections::BTreeMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -91,7 +93,9 @@ pub fn list(meta: &Path) -> Vec<RecoveryPoint> {
}
pub fn get(meta: &Path, id: &str) -> Result<RecoveryPoint> {
fsx::read_json(&point_dir(meta, id)?.join("backup.json")).map_err(|_| Error::Invalid(format!("backup {id} was not found")))
let point: RecoveryPoint = fsx::read_json(&fsx::ensure_within(meta, &point_dir(meta, id)?.join("backup.json"))?).map_err(|_| Error::Invalid(format!("backup {id} was not found or its metadata is damaged")))?;
if point.id != id || point.schema != 1 { return Err(Error::Invalid(format!("Backup {id} has inconsistent identity or an unsupported schema."))); }
Ok(point)
}
/// Files that make up "configuration": everything a user or the Manager may have changed, minus secrets.
@@ -139,13 +143,16 @@ fn copy_configs(root: &Path, dir: &Path) -> Result<Component> {
let files = config_files(root);
let target = dir.join("files");
let mut bytes = 0;
let mut file_sha256 = std::collections::BTreeMap::new();
for rel in &files {
let src = fsx::safe_join(root, rel)?;
let dst = fsx::safe_join(&target, rel)?;
fs::create_dir_all(dst.parent().unwrap())?;
bytes += fs::copy(&src, &dst)?;
let content = fs::read(&src)?;
fsx::atomic_write(&dst, &content)?;
bytes += content.len() as u64;
file_sha256.insert(rel.clone(), fsx::sha256_bytes(&content));
}
Ok(Component { name: "configs".into(), path: "files".into(), bytes, sha256: None, tables: None, files: Some(files) })
Ok(Component { name: "configs".into(), path: "files".into(), bytes, sha256: None, tables: None, files: Some(files), file_sha256 })
}
/// Make sure the database is up for `f`. If we had to start it (and nothing else is running), stop it again.
@@ -215,7 +222,7 @@ pub fn create(root: &Path, meta: &Path, kind: Kind, trigger: Trigger, label: Opt
let tables = db.tables(schema)?.len();
let file = format!("{name}.sql.zst");
let (bytes, sha) = db.dump_to(schema, &partial.join(&file))?;
Ok(Component { name, path: file, bytes, sha256: Some(sha), tables: Some(tables), files: None })
Ok(Component { name, path: file, bytes, sha256: Some(sha), tables: Some(tables), files: None, file_sha256: Default::default() })
})?;
components.push(component);
}
@@ -255,6 +262,7 @@ pub fn create(root: &Path, meta: &Path, kind: Kind, trigger: Trigger, label: Opt
/// Keep the newest `keep` automatic recovery points; manual and safety ones are never pruned.
pub fn prune_automatic(meta: &Path, keep: usize) {
if crate::update::ensure_recovered(meta).is_err() { return; }
let autos: Vec<_> = list(meta).into_iter().filter(|p| p.trigger == Trigger::Automatic).collect();
for p in autos.into_iter().skip(keep) {
if let Ok(dir) = point_dir(meta, &p.id) {
@@ -273,8 +281,21 @@ pub fn verify(meta: &Path, id: &str) -> Result<VerifyReport> {
let point = get(meta, id)?;
let dir = point_dir(meta, id)?;
let mut problems = Vec::new();
let mut names = std::collections::BTreeSet::new();
let mut schemas = std::collections::BTreeSet::new();
for c in &point.components {
let path = dir.join(&c.path);
if !names.insert(&c.name) { problems.push(format!("{} appears more than once", c.name)); }
let path = fsx::ensure_within(&dir, &fsx::safe_join(&dir, &c.path)?)?;
if c.name != "configs" && (c.sha256.is_none() || c.tables.is_none()) { problems.push(format!("{} lacks database integrity metadata", c.name)); }
if c.name == "configs" {
if c.sha256.is_some() || c.files.is_none() { problems.push("configuration integrity metadata is inconsistent".into()); }
} else {
let schema = if c.name.contains('-') || c.name == "playerbots" { db::schema_of(&c.name) } else { point.realm.schema(&c.name) };
match schema {
Ok(schema) if schemas.insert(schema) => {}
_ => problems.push(format!("{} has an unknown or duplicate database target", c.name)),
}
}
match (&c.sha256, &c.files) {
(Some(sha), _) => match fsx::sha256_file(&path) {
Ok(actual) if actual.eq_ignore_ascii_case(sha) => {}
@@ -283,12 +304,16 @@ pub fn verify(meta: &Path, id: &str) -> Result<VerifyReport> {
},
(None, Some(files)) => {
for f in files {
if !c.file_sha256.is_empty() && !c.file_sha256.contains_key(f) { problems.push(format!("configuration file {f} lacks its checksum")); }
if !fsx::safe_join(&path, f).map(|p| p.is_file()).unwrap_or(false) {
problems.push(format!("configuration file {f} is missing"));
} else if let Some(expected) = c.file_sha256.get(f) {
let file = fsx::ensure_within(&dir, &fsx::safe_join(&path, f)?)?;
if fsx::sha256_file(&file)?.as_str() != expected { problems.push(format!("configuration file {f} is damaged")); }
}
}
}
_ => {}
_ => problems.push(format!("{} lacks an integrity inventory", c.name)),
}
}
Ok(VerifyReport { ok: problems.is_empty(), problems })
@@ -296,6 +321,8 @@ pub fn verify(meta: &Path, id: &str) -> Result<VerifyReport> {
/// Delete one recovery point (its own folder only).
pub fn delete(meta: &Path, id: &str) -> Result<()> {
let _lock = crate::update::operation_lock(meta)?;
crate::update::ensure_recovered(meta)?;
if crate::update::unfinished(meta).is_some_and(|t| t.recovery_point.as_deref() == Some(id)) {
return Err(Error::Invalid("This recovery point is required by an unfinished update and cannot be deleted.".into()));
}
@@ -405,6 +432,36 @@ mod tests {
(dir, root, meta)
}
#[test]
fn backup_deletion_obeys_the_installation_lock() {
let (_d, root, meta) = setup();
let point = create(&root, &meta, Kind::Config, Trigger::Manual, None, &|_| {}).unwrap();
let lock = crate::update::operation_lock(&meta).unwrap();
assert!(delete(&meta, &point.id).is_err());
assert!(get(&meta, &point.id).is_ok());
drop(lock);
delete(&meta, &point.id).unwrap();
assert!(get(&meta, &point.id).is_err());
}
#[test]
fn damaged_configuration_is_detected_before_restore_and_copies_are_retained() {
let (_d, root, meta) = setup();
let point = create(&root, &meta, Kind::Config, Trigger::Automatic, None, &|_| {}).unwrap();
let file = point_dir(&meta, &point.id).unwrap().join("files/Core/configs/worldserver.conf");
fs::write(&file, b"corrupted").unwrap();
assert!(!verify(&meta, &point.id).unwrap().ok);
let original = fs::read(root.join("Core/configs/worldserver.conf")).unwrap();
assert!(restore_configs(&root, &meta, &point.id).is_err());
assert_eq!(fs::read(root.join("Core/configs/worldserver.conf")).unwrap(), original);
assert_eq!(list(&meta).len(), 1);
fs::create_dir_all(meta.join("updates/broken")).unwrap();
fs::write(meta.join("updates/broken/txn.json"), b"{damaged").unwrap();
assert!(delete(&meta, &point.id).is_err());
prune_automatic(&meta, 0);
assert!(file.exists());
}
#[test]
fn config_backup_excludes_secrets_and_clutter_and_restores_byte_exact() {
let (_d, root, meta) = setup();
+1 -1
View File
@@ -181,7 +181,7 @@ pub fn build(p: &Params, say: &dyn Fn(&str)) -> Result<()> {
if let Some(b) = p.bots {
sql.extend(collect_bots_sql(b)?);
}
let migrations: Vec<_> = sql.iter().map(|f| crate::manifest::Migration { id: f.id.clone(), db: f.db.clone(), sha256: f.sha256.clone(), destructive: false }).collect();
let migrations: Vec<_> = sql.iter().map(|f| crate::manifest::Migration { compatible_sha256: vec![], id: f.id.clone(), db: f.db.clone(), sha256: f.sha256.clone(), destructive: false }).collect();
let staging = std::env::temp_dir().join("coa-cleanbase-sql");
let _ = fs::remove_dir_all(&staging);
for f in &sql {
+40 -2
View File
@@ -123,7 +123,7 @@ pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
let tmp = parent.join(format!(
".{}.{}.tmp",
path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
std::process::id()
uuid::Uuid::new_v4().simple()
));
let write = || -> std::io::Result<()> {
let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp)?;
@@ -135,13 +135,41 @@ pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
let _ = fs::remove_file(&tmp);
return Err(e.into());
}
if let Err(e) = fs::rename(&tmp, path) {
if let Err(e) = durable_replace(&tmp, path) {
let _ = fs::remove_file(&tmp);
return Err(e.into());
}
Ok(())
}
/// The temporary file must already be flushed, on the same volume as the destination.
pub(crate) fn durable_replace(from: &Path, to: &Path) -> std::io::Result<()> {
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH};
fn wide(path: &Path) -> std::io::Result<Vec<u16>> {
let absolute = std::path::absolute(path)?;
let text = absolute.as_os_str().to_string_lossy().replace('/', "\\");
let extended = if text.starts_with("\\\\?\\") { text }
else if let Some(unc) = text.strip_prefix("\\\\") { format!("\\\\?\\UNC\\{unc}") }
else { format!("\\\\?\\{text}") };
Ok(std::ffi::OsStr::new(&extended).encode_wide().chain(Some(0)).collect())
}
let src = wide(from)?;
let dst = wide(to)?;
if unsafe { MoveFileExW(src.as_ptr(), dst.as_ptr(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) } == 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
#[cfg(not(windows))]
{
fs::rename(from, to)?;
File::open(to.parent().unwrap())?.sync_all()
}
}
pub fn atomic_write_json<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
let mut bytes = serde_json::to_vec_pretty(value)?;
bytes.push(b'\n');
@@ -199,6 +227,16 @@ pub fn require_space(path: &Path, needed: u64) -> Result<()> {
mod tests {
use super::*;
#[test]
fn durable_replacement_handles_long_transaction_backup_paths() {
let dir = tempfile::tempdir().unwrap();
let parent = dir.path().join("a".repeat(90)).join("b".repeat(90));
let path = parent.join("configuration-with-a-long-name.template");
atomic_write(&path, b"old").unwrap();
atomic_write(&path, b"new").unwrap();
assert_eq!(fs::read(&path).unwrap(), b"new");
}
#[test]
fn safe_join_accepts_normal_and_rejects_traversal() {
let root = Path::new("C:/srv");
+7
View File
@@ -48,6 +48,8 @@ pub struct FileEntry {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Migration {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub compatible_sha256: Vec<String>,
pub id: String,
pub db: String,
pub sha256: String,
@@ -134,6 +136,11 @@ impl Manifest {
if parse_version(&self.min_manager_version).is_none() {
return bad(format!("bad minManagerVersion {:?}", self.min_manager_version));
}
for migration in &self.migrations {
if !is_sha256(&migration.sha256) || migration.compatible_sha256.iter().any(|h| !is_sha256(h) || h == &"0".repeat(64)) {
return bad(format!("{}: invalid migration checksum compatibility", migration.id));
}
}
for (label, rev) in [("core", Some(&self.core)), ("bots", self.bots.as_ref())] {
if let Some(Revision { commit: Some(c) }) = rev {
if !is_commit(c) {
+110 -21
View File
@@ -35,6 +35,7 @@ pub struct LedgerRow {
/// Storage for the ledger and the ability to run a SQL file; implemented by the real database and by tests.
pub trait Store {
fn load(&self) -> Result<Vec<LedgerRow>>;
fn load_readonly(&self) -> Result<Vec<LedgerRow>> { self.load() }
fn put(&self, row: &LedgerRow) -> Result<()>;
fn run_file(&self, schema: &str, file: &Path) -> Result<()>;
}
@@ -52,7 +53,13 @@ impl Store for Db {
self.query(&format!(
"CREATE TABLE IF NOT EXISTS `{LEDGER_SCHEMA}`.`{LEDGER_TABLE}` (`db` VARCHAR(32) NOT NULL, `id` VARCHAR(190) NOT NULL, `sha256` CHAR(64) NOT NULL, `status` VARCHAR(16) NOT NULL, `applied_at` DATETIME NOT NULL, `error` TEXT NULL, `baseline` TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (`db`,`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"
))?;
let out = self.query(&format!("SELECT `db`,`id`,`sha256`,`status`,`baseline`,COALESCE(HEX(`error`),'') FROM `{LEDGER_SCHEMA}`.`{LEDGER_TABLE}` ORDER BY `db`,`id`;"))?;
self.load_readonly()
}
fn load_readonly(&self) -> Result<Vec<LedgerRow>> {
let out = if self.tables(LEDGER_SCHEMA)?.iter().any(|name| name == LEDGER_TABLE) {
self.query(&format!("SELECT `db`,`id`,`sha256`,`status`,`baseline`,COALESCE(HEX(`error`),'') FROM `{LEDGER_SCHEMA}`.`{LEDGER_TABLE}` ORDER BY `db`,`id`;"))?
} else { String::new() };
let mut rows = Vec::new();
// Files the core's own updater (or whoever prepared this database) already recorded count as applied.
for (kind, schema) in db::SCHEMAS {
@@ -149,6 +156,50 @@ fn recorded<'a>(rows: &'a [LedgerRow], m: &Migration) -> Option<&'a LedgerRow> {
fn hash_changed(row: &LedgerRow, m: &Migration) -> bool {
row.status == Status::Applied && row.sha256 != "0".repeat(64) && !row.sha256.eq_ignore_ascii_case(&m.sha256)
&& !m.compatible_sha256.iter().any(|hash| hash.eq_ignore_ascii_case(&row.sha256))
}
/// Inspect recorded history without creating a ledger or executing migrations.
pub fn preflight(store: &dyn Store, list: &[Migration]) -> Result<()> {
check_history(&store.load_readonly()?, list)
}
pub fn pending_count(store: &dyn Store, list: &[Migration]) -> Result<usize> {
let rows = store.load_readonly()?;
check_history(&rows, list)?;
Ok(list.iter().filter(|m| recorded(&rows, m).is_none_or(|r| r.status != Status::Applied)).count())
}
fn verified_file(m: &Migration, dir: &Path) -> Result<(&'static str, std::path::PathBuf, bool)> {
if !ident_ok(&m.id) { return Err(Error::InvalidManifest(format!("migration id {:?} is not allowed", m.id))); }
let schema = db::schema_of(&m.db)?;
let path = fsx::safe_join(dir, &format!("{}/{}.sql", m.db, m.id))?;
let bytes = std::fs::read(&path).map_err(|_| Error::Invalid(format!("migration file {} is missing", m.id)))?;
let actual = fsx::sha256_bytes(&bytes);
if !actual.eq_ignore_ascii_case(&m.sha256) { return Err(Error::HashMismatch { path: path.display().to_string(), expected: m.sha256.clone(), actual }); }
Ok((schema, path, m.destructive || looks_destructive(&String::from_utf8_lossy(&bytes))))
}
/// Check every migration artifact, including applied history, before replacing server files.
pub fn verify_files(list: &[Migration], dir: &Path) -> Result<()> {
let mut seen = std::collections::BTreeSet::new();
for m in list {
if !seen.insert((&m.db, &m.id)) { return Err(Error::InvalidManifest(format!("duplicate migration {} ({})", m.id, m.db))); }
verified_file(m, dir)?;
}
Ok(())
}
fn check_history(rows: &[LedgerRow], list: &[Migration]) -> Result<()> {
for m in list {
if recorded(rows, m).is_some_and(|r| matches!(r.status, Status::Running | Status::Failed)) {
return Err(Error::Invalid(format!("Database update {} ({}) failed or was interrupted and may be partially applied. Restore its recovery point before retrying.", m.id, m.db)));
}
if recorded(rows, m).is_some_and(|r| hash_changed(r, m)) {
return Err(Error::Invalid(format!("Applied database update {} ({}) has a different checksum. A new migration is required; it was not replayed.", m.id, m.db)));
}
}
Ok(())
}
/// Record migrations as already present without running them (adopting an existing server whose database
@@ -178,14 +229,7 @@ pub struct ApplyReport {
pub fn apply_pending(store: &dyn Store, list: &[Migration], dir: &Path, snapshot: &dyn Fn() -> Result<String>) -> Result<ApplyReport> {
let rows = store.load()?;
// The list order is the release's order of application (released updates, then pending, then modules).
for m in list {
if recorded(&rows, m).is_some_and(|r| matches!(r.status, Status::Running | Status::Failed)) {
return Err(Error::Invalid(format!("Database update {} ({}) failed or was interrupted and may be partially applied. Restore its recovery point before retrying.", m.id, m.db)));
}
if recorded(&rows, m).is_some_and(|r| hash_changed(r, m)) {
return Err(Error::Invalid(format!("Applied database update {} ({}) has a different checksum. A new migration is required; it was not replayed.", m.id, m.db)));
}
}
check_history(&rows, list)?;
let todo: Vec<&Migration> = list.iter().filter(|m| recorded(&rows, m).is_none_or(|r| r.status != Status::Applied)).collect();
tracing::info!(total = list.len(), pending = todo.len(), skipped = list.len() - todo.len(), "database migration plan");
@@ -193,17 +237,8 @@ pub fn apply_pending(store: &dyn Store, list: &[Migration], dir: &Path, snapshot
let mut files = Vec::new();
let mut any_destructive = false;
for m in &todo {
if !ident_ok(&m.id) {
return Err(Error::InvalidManifest(format!("migration id {:?} is not allowed", m.id)));
}
let schema = db::schema_of(&m.db)?;
let path = fsx::safe_join(dir, &format!("{}/{}.sql", m.db, m.id))?;
let bytes = std::fs::read(&path).map_err(|_| Error::Invalid(format!("migration file {} is missing", m.id)))?;
let actual = fsx::sha256_bytes(&bytes);
if !actual.eq_ignore_ascii_case(&m.sha256) {
return Err(Error::HashMismatch { path: path.display().to_string(), expected: m.sha256.clone(), actual });
}
any_destructive |= m.destructive || looks_destructive(&String::from_utf8_lossy(&bytes));
let (schema, path, destructive) = verified_file(m, dir)?;
any_destructive |= destructive;
files.push((*m, schema, path));
}
@@ -273,7 +308,7 @@ mod tests {
for (db, id, sql) in files {
std::fs::create_dir_all(d.path().join(db)).unwrap();
std::fs::write(d.path().join(db).join(format!("{id}.sql")), sql).unwrap();
list.push(Migration { id: id.to_string(), db: db.to_string(), sha256: fsx::sha256_bytes(sql.as_bytes()), destructive: false });
list.push(Migration { compatible_sha256: vec![], id: id.to_string(), db: db.to_string(), sha256: fsx::sha256_bytes(sql.as_bytes()), destructive: false });
}
(d, list)
}
@@ -304,6 +339,60 @@ mod tests {
assert!(store.ran.borrow().is_empty());
}
#[test]
fn staged_files_reject_missing_corrupt_duplicate_and_invalid_migrations() {
let (dir, list) = setup(&[("characters", "repair", "SELECT 1;\n")]);
verify_files(&list, dir.path()).unwrap();
let mut bad = list.clone();
bad[0].id = "../outside".into();
assert!(verify_files(&bad, dir.path()).is_err());
bad = list.clone();
bad.push(list[0].clone());
assert!(verify_files(&bad, dir.path()).is_err());
std::fs::write(dir.path().join("characters/repair.sql"), b"SELECT 2;\n").unwrap();
assert!(verify_files(&list, dir.path()).is_err());
std::fs::remove_file(dir.path().join("characters/repair.sql")).unwrap();
assert!(verify_files(&list, dir.path()).is_err());
}
#[test]
fn preflight_uses_readonly_history_and_rejects_conflicts_without_writes() {
struct ReadOnly<'a>(&'a Mem);
impl Store for ReadOnly<'_> {
fn load(&self) -> Result<Vec<LedgerRow>> { panic!("preflight must use readonly access") }
fn load_readonly(&self) -> Result<Vec<LedgerRow>> { self.0.load() }
fn put(&self, _: &LedgerRow) -> Result<()> { panic!("preflight must not rewrite history") }
fn run_file(&self, _: &str, _: &Path) -> Result<()> { panic!("preflight must not run SQL") }
}
let (_d, list) = setup(&[("characters", "repair", "SELECT 1;\n")]);
for state in [Status::Applied, Status::Running, Status::Failed] {
let store = Mem::default();
store.rows.borrow_mut().push(LedgerRow { db: "characters".into(), id: "repair".into(), sha256: "a".repeat(64), status: state, error: None, baseline: false });
assert!(preflight(&ReadOnly(&store), &list).is_err());
assert_eq!(store.rows.borrow()[0].sha256, "a".repeat(64));
assert_eq!(store.rows.borrow()[0].status, state);
}
}
#[test]
fn published_compatibility_preserves_history_and_never_replays_sql() {
let (d, mut list) = setup(&[("characters", "repair", "SELECT 1;\n")]);
let store = Mem::default();
let old = fsx::sha256_bytes(b"SELECT 1;\r\n");
store.rows.borrow_mut().push(LedgerRow { db: "characters".into(), id: "repair".into(), sha256: old.clone(), status: Status::Applied, error: None, baseline: false });
assert!(apply_pending(&store, &list, d.path(), &no_snapshot).is_err());
list[0].compatible_sha256.push(old.clone());
assert_eq!(status(&store, &list).unwrap()[0].status, Status::Applied);
assert!(apply_pending(&store, &list, d.path(), &no_snapshot).unwrap().applied.is_empty());
assert!(store.ran.borrow().is_empty());
assert_eq!(store.rows.borrow()[0].sha256, old);
store.rows.borrow_mut()[0].status = Status::Running;
assert!(apply_pending(&store, &list, d.path(), &no_snapshot).is_err());
store.rows.borrow_mut()[0].status = Status::Failed;
assert!(apply_pending(&store, &list, d.path(), &no_snapshot).is_err());
assert!(store.ran.borrow().is_empty());
}
#[test]
fn applies_in_order_once_and_records_the_ledger() {
let (d, list) = setup(&[("world", "2026_09_01_00_a", "CREATE TABLE t (id INT);"), ("world", "2026_09_02_00_b", "ALTER TABLE t ADD c INT;")]);
+1 -1
View File
@@ -98,7 +98,7 @@ pub fn collect_bots_sql(bots: &Path) -> Result<Vec<SqlFile>> {
}
fn to_migrations(files: &[SqlFile]) -> Vec<Migration> {
files.iter().map(|f| Migration { id: f.id.clone(), db: f.db.clone(), sha256: f.sha256.clone(), destructive: false }).collect()
files.iter().map(|f| Migration { compatible_sha256: vec![], id: f.id.clone(), db: f.db.clone(), sha256: f.sha256.clone(), destructive: false }).collect()
}
pub struct UpdateParams<'a> {
+1 -1
View File
@@ -47,7 +47,7 @@ pub fn capture_release(fixture: &Path, tree: &Path, sql: &[SqlFile]) -> Result<(
return Err(Error::Invalid("Refusing to apply release SQL to a non-fixture server.".into()));
}
let staging = tempfile::tempdir()?;
let migrations: Vec<_> = sql.iter().map(|f| Migration { db: f.db.clone(), id: f.id.clone(), sha256: f.sha256.clone(), destructive: false }).collect();
let migrations: Vec<_> = sql.iter().map(|f| Migration { compatible_sha256: vec![], db: f.db.clone(), id: f.id.clone(), sha256: f.sha256.clone(), destructive: false }).collect();
for file in sql {
let path = fsx::safe_join(staging.path(), &format!("{}/{}.sql", file.db, file.id))?;
fsx::atomic_write(&path, &std::fs::read(&file.abs)?)?;
+298 -27
View File
@@ -77,6 +77,8 @@ pub struct Txn {
pub databases_started: bool,
pub ops: Vec<Op>,
pub message: Option<String>,
#[serde(default)]
pub recovery_hashes: BTreeMap<String, String>,
}
fn legacy_databases_started() -> bool { true }
@@ -84,6 +86,8 @@ fn legacy_databases_started() -> bool { true }
/// Everything the transaction needs from the outside world; tests provide a fake.
pub trait Env {
fn ensure_stopped(&self) -> Result<()>;
fn preflight(&self, _manifest: &Manifest) -> Result<()> { Ok(()) }
fn verify_snapshot(&self, _id: &str) -> Result<()> { Ok(()) }
/// Create a recovery point for the databases and configuration; returns its id.
fn snapshot(&self) -> Result<String>;
fn restore_snapshot(&self, id: &str) -> Result<()>;
@@ -108,32 +112,72 @@ fn save(meta: &Path, t: &Txn) -> Result<()> {
}
pub fn load(meta: &Path, id: &str) -> Result<Txn> {
fsx::read_json(&txn_dir(meta, id)?.join("txn.json"))
read_journal(&txn_dir(meta, id)?.join("txn.json"))
}
fn read_journal(path: &Path) -> Result<Txn> {
let txn: Txn = fsx::read_json(path).map_err(|e| Error::Invalid(format!("Update journal {} cannot be read: {e}. Available recovery copies were preserved.", path.display())))?;
if path.parent().and_then(Path::file_name).and_then(|id| id.to_str()) != Some(txn.id.as_str()) { return Err(Error::Invalid("Update journal identity is inconsistent.".into())); }
txn_dir(path.parent().unwrap(), &txn.id)?;
if let Some(id) = &txn.recovery_point {
if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) || id.contains("..") { return Err(Error::Invalid("Update journal contains an invalid recovery point identity.".into())); }
}
let valid_hash = |hash: &str| hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit());
let mut seen = std::collections::BTreeSet::new();
for op in &txn.ops {
fsx::safe_join(path.parent().unwrap(), &op.path)?;
if !seen.insert(op.path.replace('\\', "/").to_ascii_lowercase()) || !valid_hash(&op.new_sha256)
|| (op.done && !op.started && !matches!(op.action, Action::Skip | Action::Conflict))
|| (txn.state == State::Prepared && op.started)
|| (txn.state == State::Committed && !op.done) {
return Err(Error::Invalid("Update journal contains inconsistent file operations.".into()));
}
}
if txn.recovery_hashes.values().any(|hash| !valid_hash(hash)) { return Err(Error::Invalid("Update journal contains invalid recovery hashes.".into())); }
for key in txn.recovery_hashes.keys().filter(|key| !matches!(key.as_str(), "@install" | "@point")) { fsx::safe_join(path.parent().unwrap(), key)?; }
Ok(txn)
}
#[derive(Serialize, Deserialize)]
struct CleanupMarker { id: String, state: State }
fn cleanup_marker(meta: &Path, id: &str) -> Result<PathBuf> {
txn_dir(meta, id)?;
Ok(meta.join("update-cleanup").join(format!("{id}.json")))
}
fn journals(meta: &Path) -> Result<Vec<Txn>> {
let entries = match fs::read_dir(updates_dir(meta)) { Ok(entries) => entries, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), Err(e) => return Err(e.into()) };
let mut all = Vec::new();
for entry in entries {
let entry = entry?;
if entry.file_type()?.is_symlink() { return Err(Error::Invalid("Linked update storage cannot be recovered safely.".into())); }
if !entry.file_type()?.is_dir() { continue; }
let dir = fsx::ensure_within(meta, &entry.path())?;
let path = dir.join("txn.json");
if !path.is_file() {
let id = entry.file_name().to_string_lossy().into_owned();
let marker = cleanup_marker(meta, &id)?;
if fsx::read_json::<CleanupMarker>(&marker).is_ok_and(|m| m.id == id && matches!(m.state, State::Committed | State::RolledBack)) { continue; }
return Err(Error::Invalid(format!("Update {id} has no journal. Preserve its recovery copies and inspect it before starting or changing the server.")));
}
all.push(read_journal(&path)?);
}
all.sort_by(|a, b| b.id.cmp(&a.id));
Ok(all)
}
pub fn pending_checked(meta: &Path) -> Result<Option<Txn>> {
Ok(journals(meta)?.into_iter().find(|t| !matches!(t.state, State::Committed | State::RolledBack | State::Prepared) && (t.state != State::Failed || t.databases_started || t.ops.iter().any(|o| o.started))))
}
/// A transaction that started but never reached a final state (crash, power loss). The UI offers rollback.
pub fn unfinished(meta: &Path) -> Option<Txn> {
let rd = fs::read_dir(updates_dir(meta)).ok()?;
let mut all: Vec<Txn> = rd.flatten().filter_map(|e| fsx::read_json::<Txn>(&e.path().join("txn.json")).ok()).collect();
all.sort_by(|a, b| b.id.cmp(&a.id));
all.into_iter().find(|t| !matches!(t.state, State::Committed | State::RolledBack | State::Prepared) && (t.state != State::Failed || t.databases_started || t.ops.iter().any(|o| o.started)))
pending_checked(meta).ok().flatten()
}
pub fn ensure_recovered(meta: &Path) -> Result<()> {
match fs::read_dir(updates_dir(meta)) {
Ok(entries) => {
for entry in entries {
let entry = entry?;
let journal = entry.path().join("txn.json");
if journal.exists() {
fsx::read_json::<Txn>(&journal).map_err(|e| Error::Invalid(format!("Update journal {} cannot be read: {e}. Recover it before changing or starting the server.", journal.display())))?;
}
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
if let Some(t) = unfinished(meta) {
if let Some(t) = pending_checked(meta)? {
return Err(Error::Invalid(format!("Resolve unfinished update {} before changing or starting the server.", t.id)));
}
Ok(())
@@ -172,6 +216,12 @@ pub fn plan(root: &Path, meta: &InstallMeta, manifest: &Manifest, resolutions: &
if would_add { (Action::MergeConfig, Some("new settings are added; your values are kept".into())) } else { (Action::Skip, Some("no new settings".into())) }
}
(_, Some(c)) if c.eq_ignore_ascii_case(&f.sha256) => (Action::Skip, Some("already up to date".into())),
(_, Some(_)) if f.path == "Scripts/manage.py" && staged.is_some_and(|tree| {
match (fs::read_to_string(tree.join(&f.path)), fs::read(&target)) {
(Ok(signed), Ok(actual)) => fsx::sha256_bytes(signed.as_bytes()) == f.sha256 && crate::driver::launcher_matches(&signed, &actual),
_ => false,
}
}) => (Action::Skip, Some("already up to date with Manager integration".into())),
(ReplacePolicy::Replace | ReplacePolicy::ReplaceIfPristine, Some(c)) => {
let manager_launcher = f.path == "Scripts/manage.py" && recorded.is_some_and(|hash| {
fs::read(&target).is_ok_and(|bytes| crate::driver::launcher_matches_recorded(hash, &bytes))
@@ -207,24 +257,72 @@ pub struct Preview {
pub items: Vec<PlanItem>,
pub conflicts: Vec<String>,
pub migrations: usize,
pub pending_migrations: usize,
pub download_bytes: u64,
}
/// Check the signed manifest and describe the update without downloading the payload.
/// Describe the update; a managed launcher may need a one-time authenticated payload check.
pub fn preview(root: &Path, meta: &InstallMeta, source: &Source, trusted_key: &str, resolutions: &BTreeMap<String, Resolution>) -> Result<Preview> {
let meta_dir = crate::registry::metadata_dir_for(root)?;
let _lock = operation_lock(&meta_dir)?;
ensure_recovered(&meta_dir)?;
let (m, _) = fetch_manifest(source, trusted_key)?;
check_manifest(&m)?;
let items = plan(root, meta, &m, resolutions, None)?;
reject_downgrade(meta, &m)?;
let mut items = plan(root, meta, &m, resolutions, None)?;
if items.iter().any(|item| item.path == "Scripts/manage.py" && item.action != Action::Skip)
&& launcher_already_integrated(root, &meta_dir, meta, &m, source)? {
if let Some(item) = items.iter_mut().find(|item| item.path == "Scripts/manage.py") {
item.action = Action::Skip;
item.reason = Some("already up to date with Manager integration".into());
}
}
let pending_migrations = (RepackEnv { root, meta_dir: &meta_dir }).pending_migrations(&m)?;
Ok(Preview {
from_version: meta.core.version.clone(),
to_version: m.version.clone(),
conflicts: items.iter().filter(|i| i.action == Action::Conflict).map(|i| i.path.clone()).collect(),
items,
migrations: m.migrations.len(),
pending_migrations,
download_bytes: m.archive.as_ref().map(|a| a.parts.iter().map(|p| p.size).sum()).unwrap_or(0),
})
}
fn launcher_already_integrated(root: &Path, meta_dir: &Path, meta: &InstallMeta, manifest: &Manifest, source: &Source) -> Result<bool> {
let Some(file) = manifest.files.iter().find(|file| file.path == "Scripts/manage.py") else { return Ok(false); };
let actual = sha_if_exists(&root.join(&file.path));
if actual.as_ref() != meta.original_hashes.get(&file.path) || actual.is_none() { return Ok(false); }
let cache = meta_dir.join("manifests").join(format!("launcher-{}.py", file.sha256));
let signed = match fs::read(&cache) {
Ok(bytes) if fsx::sha256_bytes(&bytes) == file.sha256 => bytes,
_ => {
let temp = tempfile::tempdir()?;
let parts = fetch_parts(source, manifest, &temp.path().join("download"), &Cancel::default(), &|_, _| {})?;
package::extract(&parts, manifest, &temp.path().join("tree"), &|_, _| {})?;
let bytes = fs::read(temp.path().join("tree").join(&file.path))?;
if fsx::sha256_bytes(&bytes) != file.sha256 { return Err(Error::Invalid("The signed launcher failed verification.".into())); }
// This derived cache is optional; failure to save it does not change the server.
let _ = fsx::atomic_write(&cache, &bytes);
bytes
}
};
Ok(std::str::from_utf8(&signed).ok().is_some_and(|signed| {
fs::read(root.join(&file.path)).ok().is_some_and(|actual| crate::driver::launcher_matches(signed, &actual))
}))
}
fn reject_downgrade(meta: &InstallMeta, manifest: &Manifest) -> Result<()> {
if let Some(installed) = meta.core.version.as_deref() {
if let (Some(have), Some(candidate)) = (crate::manifest::parse_version(installed), crate::manifest::parse_version(&manifest.version)) {
if candidate < have {
return Err(Error::Invalid(format!("Installed server {installed} is newer than package {}. Use its recovery point to undo an update; older packages cannot be applied.", manifest.version)));
}
}
}
Ok(())
}
fn check_manifest(m: &Manifest) -> Result<()> {
if m.kind != Kind::Update {
return Err(Error::Invalid("This is not an update package.".into()));
@@ -271,6 +369,8 @@ pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result<Outcome> {
let (m, manifest_bytes) = fetch_manifest(&p.source, p.trusted_key)?;
tracing::info!(root = %root.display(), from_version = ?meta.core.version, to_version = %m.version, migrations = m.migrations.len(), "server update selected");
check_manifest(&m)?;
reject_downgrade(&meta, &m)?;
p.env.preflight(&m)?;
let archive = m.archive.clone().ok_or_else(|| Error::InvalidManifest("no archive".into()))?;
let id = format!("{}-{}-{}", chrono::Utc::now().format("%Y%m%d-%H%M%S"), m.version.replace('.', "_"), uuid::Uuid::new_v4().simple());
let tdir = txn_dir(meta_dir, &id)?;
@@ -282,7 +382,8 @@ pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result<Outcome> {
fsx::atomic_write(&tdir.join("manifest.json.sig"), &signature)?;
fsx::atomic_write(&before.join("manager-install.json"), &fs::read(meta_dir.join("install.json"))?)?;
let mut txn = Txn { id: id.clone(), state: State::Prepared, from_version: meta.core.version.clone(), to_version: m.version.clone(), recovery_point: None, databases_started: false, ops: Vec::new(), message: None };
let mut txn = Txn { id: id.clone(), state: State::Prepared, from_version: meta.core.version.clone(), to_version: m.version.clone(), recovery_point: None, databases_started: false, ops: Vec::new(), message: None, recovery_hashes: BTreeMap::new() };
txn.recovery_hashes.insert("@install".into(), fsx::sha256_file(&before.join("manager-install.json"))?);
save(meta_dir, &txn)?;
// Nothing has touched the installation yet; failures up to here just discard the staging area.
@@ -291,6 +392,7 @@ pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result<Outcome> {
let parts = fetch_parts(&p.source, &m, &meta_dir.join("staging").join("download"), &p.cancel, &|f, _| step(report, "Downloading the update", 5 + (f * 35.0) as u8))?;
step(report, "Verifying the update", 42);
package::extract(&parts, &m, &tree, &|_, _| {})?;
crate::migrations::verify_files(&m.migrations, &tree.join("_migrations"))?;
Ok(tree.clone())
})();
let tree = match staged {
@@ -320,6 +422,8 @@ pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result<Outcome> {
p.env.ensure_stopped()?;
step(report, "Saving a recovery point", 52);
txn.recovery_point = Some(p.env.snapshot().map_err(|e| Error::Invalid(format!("The update was not applied because the safety backup failed: {e}")))?);
let point_meta = meta_dir.join("backups").join(txn.recovery_point.as_deref().unwrap()).join("backup.json");
if point_meta.is_file() { txn.recovery_hashes.insert("@point".into(), fsx::sha256_file(&point_meta)?); }
txn.ops = items
.iter()
@@ -458,6 +562,19 @@ fn fail_after_apply(p: &Params, txn: &mut Txn, before: &Path, tree: &Path, why:
}
fn restore_transaction(root: &Path, meta: &Path, before: &Path, txn: &mut Txn, env: &dyn Env) -> Result<()> {
for (rel, expected) in &txn.recovery_hashes {
let saved = match rel.as_str() {
"@install" => before.join("manager-install.json"),
"@point" => meta.join("backups").join(txn.recovery_point.as_deref().ok_or_else(|| Error::Invalid("The recovery point identity is missing.".into()))?).join("backup.json"),
_ => fsx::safe_join(before, rel)?,
};
if fsx::sha256_file(&saved)? != *expected { return Err(Error::Invalid(format!("Recovery copy {rel} is damaged; no restoration was started."))); }
}
if let Some(id) = &txn.recovery_point { env.verify_snapshot(id)?; }
for op in txn.ops.iter().filter(|op| op.started && op.had_previous) {
if !fsx::safe_join(before, &op.path)?.is_file() { return Err(Error::Invalid(format!("The recovery copy of {} is missing; no restoration was started.", op.path))); }
}
if !before.join("manager-install.json").is_file() { return Err(Error::Invalid("The saved installation metadata is missing; no restoration was started.".into())); }
env.ensure_stopped()?;
if txn.databases_started {
let id = txn.recovery_point.as_deref().ok_or_else(|| Error::Invalid("The database recovery point is missing.".into()))?;
@@ -485,6 +602,7 @@ fn apply_ops(root: &Path, tree: &Path, before: &Path, files: &[FileEntry], txn:
let keep = fsx::safe_join(before, &path)?;
fs::create_dir_all(keep.parent().unwrap())?;
fsx::atomic_write(&keep, &fs::read(&target)?)?;
txn.recovery_hashes.insert(path.clone(), fsx::sha256_file(&keep)?);
}
// Journal only after the old bytes are safely saved; a crash during backup must not
// turn an untouched file into a rollback operation with a missing saved copy.
@@ -575,7 +693,10 @@ pub(crate) fn begin_repair(root: &Path, meta_dir: &Path, version: &str, backup:
if had_previous { fsx::atomic_write(&fsx::safe_join(&before, &file.path)?, &fs::read(&target)?)?; }
ops.push(Op { path: file.path.clone(), action: if had_previous { Action::Replace } else { Action::Create }, reason: None, new_sha256: file.sha256.clone(), had_previous, started: true, done: false });
}
let txn = Txn { id, state: State::Applying, from_version: Some(version.into()), to_version: version.into(), recovery_point: Some(backup.into()), databases_started: false, ops, message: Some("Repair is in progress.".into()) };
let mut recovery_hashes = BTreeMap::new();
recovery_hashes.insert("@install".into(), fsx::sha256_file(&before.join("manager-install.json"))?);
for op in ops.iter().filter(|op| op.had_previous) { recovery_hashes.insert(op.path.clone(), fsx::sha256_file(&fsx::safe_join(&before, &op.path)?)?); }
let txn = Txn { id, state: State::Applying, from_version: Some(version.into()), to_version: version.into(), recovery_point: Some(backup.into()), databases_started: false, ops, message: Some("Repair is in progress.".into()), recovery_hashes };
save(meta_dir, &txn)?;
Ok(txn)
}
@@ -594,10 +715,15 @@ pub(crate) fn finish_repair(root: &Path, meta_dir: &Path, txn: &mut Txn, success
fn prune(meta_dir: &Path, keep: usize) {
let Ok(rd) = fs::read_dir(updates_dir(meta_dir)) else { return };
let mut dirs: Vec<_> = rd.flatten().filter(|e| fsx::read_json::<Txn>(&e.path().join("txn.json")).is_ok_and(|t| matches!(t.state, State::Committed | State::RolledBack))).map(|e| e.path()).collect();
let mut dirs: Vec<_> = rd.flatten().filter(|e| read_journal(&e.path().join("txn.json")).is_ok_and(|t| matches!(t.state, State::Committed | State::RolledBack))).map(|e| e.path()).collect();
dirs.sort();
while dirs.len() > keep {
let _ = fs::remove_dir_all(dirs.remove(0));
let dir = dirs.remove(0);
if let Ok(txn) = read_journal(&dir.join("txn.json")) {
if let Ok(marker) = cleanup_marker(meta_dir, &txn.id) {
if fsx::atomic_write_json(&marker, &CleanupMarker { id: txn.id, state: txn.state }).is_ok() && fs::remove_dir_all(&dir).is_ok() { let _ = fs::remove_file(marker); }
}
}
}
}
@@ -608,6 +734,20 @@ pub struct RepackEnv<'a> {
}
impl RepackEnv<'_> {
pub fn pending_migrations(&self, manifest: &Manifest) -> Result<usize> {
if manifest.migrations.is_empty() { return Ok(0); }
crate::backup::with_database(self.root, |db| {
let realms = crate::realms::state(self.root)?;
let mut modes = vec![realms.active];
if realms.wildcard_created { modes.push(if realms.active == crate::realms::Mode::Coa { crate::realms::Mode::Wildcard } else { crate::realms::Mode::Coa }); }
let mut count = 0;
for (index, mode) in modes.into_iter().enumerate() {
let list: Vec<_> = manifest.migrations.iter().filter(|m| index == 0 || m.db != "auth").cloned().collect();
count += crate::migrations::pending_count(&db.clone().for_realm(mode), &list)?;
}
Ok(count)
})
}
pub(crate) fn verify_recovery_point(&self, point: &crate::backup::RecoveryPoint) -> Result<()> {
if point.kind != crate::backup::Kind::Full || !["characters", "auth", "world", "configs"].iter().all(|name| point.components.iter().any(|c| c.name == *name)) {
return Err(Error::Invalid("The update recovery point does not contain every required database and configuration.".into()));
@@ -637,6 +777,18 @@ impl RepackEnv<'_> {
}
impl Env for RepackEnv<'_> {
fn preflight(&self, manifest: &Manifest) -> Result<()> {
self.pending_migrations(manifest).map(|_| ())
}
fn verify_snapshot(&self, id: &str) -> Result<()> {
let point = crate::backup::get(self.meta_dir, id)?;
if point.kind != crate::backup::Kind::Full || !["characters", "auth", "world", "configs"].iter().all(|name| point.components.iter().any(|c| c.name == *name)) {
return Err(Error::Invalid("The recovery point is incomplete; no restoration was started.".into()));
}
if !crate::backup::verify(self.meta_dir, id)?.ok { return Err(Error::Invalid("The recovery point is damaged; available copies were preserved.".into())); }
Ok(())
}
fn ensure_stopped(&self) -> Result<()> {
use crate::process::{observe, ServiceState};
let o = observe(self.root, &crate::layout::read_ports(self.root));
@@ -774,16 +926,20 @@ mod tests {
snapshot_ok: Cell<bool>,
calls: RefCell<Vec<&'static str>>,
migrate_fail: Cell<bool>,
preflight_fail: Cell<bool>,
restore_fail: Cell<bool>,
}
impl Fake {
fn ok() -> Fake {
Fake { stopped: Cell::new(true), healthy: Cell::new(true), snapshot_ok: Cell::new(true), calls: Default::default(), migrate_fail: Cell::new(false), restore_fail: Cell::new(false) }
Fake { stopped: Cell::new(true), healthy: Cell::new(true), snapshot_ok: Cell::new(true), calls: Default::default(), migrate_fail: Cell::new(false), preflight_fail: Cell::new(false), restore_fail: Cell::new(false) }
}
}
impl Env for Fake {
fn preflight(&self, _: &Manifest) -> Result<()> {
if self.preflight_fail.get() { Err(Error::Invalid("Conflicting migration history".into())) } else { Ok(()) }
}
fn ensure_stopped(&self) -> Result<()> {
self.calls.borrow_mut().push("stop");
if self.stopped.get() { Ok(()) } else { Err(Error::Invalid("The server is running.".into())) }
@@ -854,7 +1010,7 @@ mod tests {
let mut m = build(&src, &pkg, &BuildOptions { kind: Kind::Update, version: "2.0.0".into(), core_commit: None, built_at: "x".into(), part_size: 1 << 20, bots_commit: None, migrations: vec![] }, &|_| {}).unwrap();
// Settings/* are merge-config by the packager; make the migration entry visible to the runner
if migrations {
m.migrations.push(crate::manifest::Migration { id: "m1".into(), db: "world".into(), sha256: fsx::sha256_bytes(b"SELECT 1;"), destructive: false });
m.migrations.push(crate::manifest::Migration { compatible_sha256: vec![], id: "m1".into(), db: "world".into(), sha256: fsx::sha256_bytes(b"SELECT 1;"), destructive: false });
fs::write(pkg.join("manifest.json"), serde_json::to_vec_pretty(&m).unwrap()).unwrap();
}
let sk = SigningKey::generate(&mut rand_core::OsRng);
@@ -956,6 +1112,47 @@ mod tests {
assert!(!w.root.join("Core/worldserver.exe.coa-new").exists());
}
#[test]
fn an_integrated_launcher_is_current_but_user_edits_and_new_package_bytes_are_not() {
let source = b"import sys\nfrom pathlib import Path\nfrom squid_playerbots import configure\nROOT = Path(__file__).resolve().parents[1]\n";
let w = world(&[("Scripts/manage.py", source)], false);
run(&w, &Fake::ok(), BTreeMap::new(), None).unwrap();
let integrated = crate::driver::patch_launcher_imports(std::str::from_utf8(source).unwrap()).unwrap();
write(&w.root, "Scripts/manage.py", integrated.as_bytes());
let (_, mut meta) = MetaDir::open(&w.meta).unwrap();
meta.original_hashes.insert("Scripts/manage.py".into(), fsx::sha256_bytes(integrated.as_bytes()));
fsx::atomic_write_json(&w.meta.join("install.json"), &meta).unwrap();
let p = preview(&w.root, &meta, &Source::Dir(w.pkg.clone()), &w.key, &BTreeMap::new()).unwrap();
assert_eq!(p.items.iter().find(|item| item.path == "Scripts/manage.py").unwrap().action, Action::Skip);
let manifest = fetch_manifest(&Source::Dir(w.pkg.clone()), &w.key).unwrap().0;
let applied_plan = plan(&w.root, &meta, &manifest, &BTreeMap::new(), Some(&w._d.path().join("upd"))).unwrap();
assert_eq!(applied_plan.iter().find(|item| item.path == "Scripts/manage.py").unwrap().action, Action::Skip);
let mut changed = fetch_manifest(&Source::Dir(w.pkg.clone()), &w.key).unwrap().0;
changed.files.iter_mut().find(|file| file.path == "Scripts/manage.py").unwrap().sha256 = fsx::sha256_bytes(b"new launcher");
assert!(launcher_already_integrated(&w.root, &w.meta, &meta, &changed, &Source::Dir(w.pkg.clone())).is_err());
write(&w.root, "Scripts/manage.py", format!("{integrated}\n# user edit\n").as_bytes());
let p = preview(&w.root, &meta, &Source::Dir(w.pkg.clone()), &w.key, &BTreeMap::new()).unwrap();
assert_eq!(p.items.iter().find(|item| item.path == "Scripts/manage.py").unwrap().action, Action::Conflict);
meta.original_hashes.insert("Scripts/manage.py".into(), fsx::sha256_file(&w.root.join("Scripts/manage.py")).unwrap());
let p = preview(&w.root, &meta, &Source::Dir(w.pkg.clone()), &w.key, &BTreeMap::new()).unwrap();
assert_eq!(p.items.iter().find(|item| item.path == "Scripts/manage.py").unwrap().action, Action::Replace,
"Recorded user content must not be mistaken for a recognized Manager integration");
}
#[test]
fn older_packages_are_rejected_before_preview_or_application() {
let w = world(&[], false);
let (_, mut installed) = MetaDir::open(&w.meta).unwrap();
installed.core.version = Some("3.0.0".into());
fsx::atomic_write_json(&w.meta.join("install.json"), &installed).unwrap();
let env = Fake::ok();
assert!(run(&w, &env, BTreeMap::new(), None).unwrap_err().to_string().contains("newer than package"));
assert!(preview(&w.root, &installed, &Source::Dir(w.pkg.clone()), &w.key, &BTreeMap::new()).unwrap_err().to_string().contains("newer than package"));
assert!(env.calls.borrow().is_empty());
assert_eq!(read(&w, "Core/worldserver.exe"), b"world-v1");
assert!(pending_checked(&w.meta).unwrap().is_none());
}
#[test]
fn a_file_modified_by_the_user_is_a_conflict_until_resolved() {
let w = world(&[], false);
@@ -1078,6 +1275,7 @@ mod tests {
t.state = State::Applying;
for op in t.ops.iter_mut() {
op.started = op.path == "Core/worldserver.exe";
op.done &= op.started;
}
save(&w.meta, &t).unwrap();
assert_eq!(unfinished(&w.meta).unwrap().id, id);
@@ -1177,6 +1375,79 @@ mod tests {
assert!(unfinished(&w.meta).is_none());
}
#[test]
fn sql_manifest_mismatch_is_rejected_before_snapshot_or_replacement() {
let w = world(&[], true);
let manifest_path = w.pkg.join("manifest.json");
let mut manifest: Manifest = fsx::read_json(&manifest_path).unwrap();
manifest.migrations[0].sha256 = "a".repeat(64);
let bytes = serde_json::to_vec_pretty(&manifest).unwrap();
// A publisher mistake: valid signed archive bytes disagree with the SQL metadata.
let key = SigningKey::generate(&mut rand_core::OsRng);
let trusted = base64::engine::general_purpose::STANDARD.encode(key.verifying_key().to_bytes());
fs::write(&manifest_path, &bytes).unwrap();
fs::write(w.pkg.join("manifest.json.sig"), base64::engine::general_purpose::STANDARD.encode(key.sign(&bytes).to_bytes())).unwrap();
let env = Fake::ok();
let error = apply(&Params { root: &w.root, meta_dir: &w.meta, source: Source::Dir(w.pkg.clone()), trusted_key: &trusted,
cancel: Default::default(), resolutions: Default::default(), env: &env, fail_after_ops: None }, &|_, _| {}).unwrap_err();
assert!(matches!(error, Error::HashMismatch { .. }), "Expected SQL metadata mismatch, received {error}");
assert!(env.calls.borrow().is_empty());
assert_eq!(read(&w, "Core/worldserver.exe"), b"world-v1");
assert!(pending_checked(&w.meta).unwrap().is_none());
}
#[test]
fn preflight_rejection_precedes_snapshot_and_file_changes() {
let w = world(&[], true);
let env = Fake::ok();
env.preflight_fail.set(true);
let original = fsx::sha256_file(&w.meta.join("install.json")).unwrap();
assert!(run(&w, &env, BTreeMap::new(), None).unwrap_err().to_string().contains("Conflicting migration history"));
assert_eq!(read(&w, "Core/worldserver.exe"), b"world-v1");
assert_eq!(fsx::sha256_file(&w.meta.join("install.json")).unwrap(), original);
assert!(env.calls.borrow().is_empty());
assert!(fs::read_dir(w.meta.join("updates")).unwrap().next().is_none());
}
#[test]
fn missing_and_semantically_corrupt_journals_block_operations() {
let w = world(&[], false);
let txn = run(&w, &Fake::ok(), BTreeMap::new(), None).unwrap().txn;
let path = txn_dir(&w.meta, &txn.id).unwrap().join("txn.json");
let mut bad = txn.clone();
bad.id = "another-update".into();
fsx::atomic_write_json(&path, &bad).unwrap();
assert!(pending_checked(&w.meta).is_err());
bad = txn.clone();
bad.ops[0].path = "../outside".into();
fsx::atomic_write_json(&path, &bad).unwrap();
assert!(ensure_recovered(&w.meta).is_err());
bad = txn.clone();
bad.ops[0].started = false;
fsx::atomic_write_json(&path, &bad).unwrap();
assert!(ensure_recovered(&w.meta).is_err());
fs::remove_file(&path).unwrap();
assert!(pending_checked(&w.meta).is_err());
let marker = cleanup_marker(&w.meta, &txn.id).unwrap();
fsx::atomic_write_json(&marker, &CleanupMarker { id: txn.id, state: State::Committed }).unwrap();
assert!(pending_checked(&w.meta).unwrap().is_none());
}
#[test]
fn damaged_saved_file_blocks_restore_before_any_environment_action() {
let w = world(&[], false);
let env = Fake::ok();
env.healthy.set(false);
let txn = run(&w, &env, BTreeMap::new(), None).unwrap().txn;
let before = txn_dir(&w.meta, &txn.id).unwrap().join("before");
write(&before, "Core/worldserver.exe", b"damaged original");
env.calls.borrow_mut().clear();
assert!(rollback(&w.root, &w.meta, &txn.id, &env).unwrap_err().to_string().contains("damaged"));
assert!(env.calls.borrow().is_empty());
assert_eq!(read(&w, "Core/worldserver.exe"), b"world-v2");
assert_eq!(pending_checked(&w.meta).unwrap().unwrap().state, State::NeedsDecision);
}
#[test]
fn unreadable_update_journal_blocks_changes_and_startup() {
let w = world(&[], false);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "coa-server-manager",
"version": "0.6.4",
"version": "0.6.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "coa-server-manager",
"version": "0.6.4",
"version": "0.6.5",
"dependencies": {
"@tauri-apps/api": "^2.12.0",
"@tauri-apps/plugin-dialog": "^2.8.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "coa-server-manager",
"private": true,
"version": "0.6.4",
"version": "0.6.5",
"license": "AGPL-3.0-only",
"type": "module",
"scripts": {
+1 -1
View File
@@ -697,7 +697,7 @@ async fn check_update(state: State<'_, AppState>, id: String, source: Option<Str
#[tauri::command]
fn pending_update(state: State<'_, AppState>, id: String) -> std::result::Result<Option<update::Txn>, UiError> {
let root = path_of(&state, &id)?;
Ok(update::unfinished(&meta_dir(&root)?))
Ok(update::pending_checked(&meta_dir(&root)?)?)
}
#[tauri::command]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CoA Server Manager",
"version": "0.6.4",
"version": "0.6.5",
"identifier": "com.coa.servermanager",
"build": {
"beforeDevCommand": "npm run dev",
+1 -1
View File
@@ -46,7 +46,7 @@ export {};
if(cmd==="preview_preset"){const a=args;const sc=a.scope==="server"?serverSchema:botsSchema;const pr=(a.scope==="server"?serverPresets:botsPresets).presets as any[];const p=pr.find(x=>x.id===a.preset)??{id:a.preset??"defaults",title:"Recommended defaults",description:"Every setting returns to its default value."};const st=(sc.settings as any[]).filter(x=>x.type==="int"||x.type==="float"||x.type==="enum").slice(0,3);return {id:p.id,title:p.title,description:p.description,changes:st.map(x=>({key:x.key,title:x.title,from:x.default,to:x.type==="enum"?(x.options?.[1]?.value??x.default):(Number(x.default)||1)*2,dangerous:false}))};}
if(cmd==="companion_sizes") return {hardware:{cores:16,ram_gb:31,free_ram_gb:12},sizes:[{id:"small",title:"Small",bots:50,warning:null},{id:"medium",title:"Medium",bots:250,warning:null},{id:"large",title:"Large",bots:500,warning:"500 companions may need more CPU and memory than this computer has (8 cores, 16 GB)."}]};
if(cmd==="add_companions"){const base=(window as any).__bots??85;(window as any).__target=base+args.count;return {spawned:"queued",created:null,baseline:base};}
if(cmd==="check_update") return {from_version:(window as any).__updated?"0.261001.8":"0.260930.5",to_version:"0.261001.8",items:[],conflicts:[],migrations:5,download_bytes:6000000};
if(cmd==="check_update") return {from_version:(window as any).__updated?"0.261001.8":"0.260930.5",to_version:"0.261001.8",items:[],conflicts:[],migrations:5,pending_migrations:(window as any).__updated?0:5,download_bytes:6000000};
if(cmd==="apply_update"){const w=window as any;for(let i=1;i<=8;i++){await new Promise((r: any)=>setTimeout(r,700));(w.__handlers?.["update-progress"]??[]).forEach((h: any)=>h({event:"update-progress",id:1,payload:{step:i<4?"Downloading the update":i<7?"Applying the update":"Finishing",percent:i*12}}));}(window as any).__updated=true;return {txn:{state:"committed",to_version:"0.261001.8"},migrations:null};}
if(cmd==="get_population"){if(!running) return null;let b=(window as any).__bots??85;const t=(window as any).__target??0;if(t>b){b=Math.min(t,b+25);(window as any).__bots=b;}return {online_total:b,bots_online:Math.max(0,b-7),players_online:0,bots_total:b};}
if(cmd==="get_performance") return running?{mean_ms:14,median_ms:15,p95_ms:24,p99_ms:30,max_ms:61,ticks_per_sec:71.4}:null;
+1
View File
@@ -270,6 +270,7 @@ export interface UpdatePreview {
items: UpdatePlanItem[];
conflicts: string[];
migrations: number;
pending_migrations?: number;
download_bytes: number;
}
+26 -5
View File
@@ -1,6 +1,7 @@
import { useSyncExternalStore } from "react";
import { listen } from "@tauri-apps/api/event";
import { api, asUiError, type UiError, type UpdatePreview, type UpdateTxn } from "@/lib/api";
import { isUpdateCurrent } from "./serverUpdateStatus";
/**
* Server update status for the main screen and the navigation: checked when the app starts and every five minutes,
@@ -16,11 +17,13 @@ export interface ServerUpdateState {
error: string | null;
/** The full error of the last update attempt, for the Settings card. */
uiError: UiError | null;
stateError: UiError | null;
stateRevision: number;
/** How the last update ended; kept here so the Settings card can show it after the user left and came back. */
result: { committed: string } | { pending: UpdateTxn } | null;
}
const EMPTY: ServerUpdateState = { preview: null, available: false, checking: false, applying: false, step: null, percent: 0, error: null, uiError: null, result: null };
const EMPTY: ServerUpdateState = { preview: null, available: false, checking: false, applying: false, step: null, percent: 0, error: null, uiError: null, stateError: null, stateRevision: 0, result: null };
const states = new Map<string, ServerUpdateState>();
const listeners = new Set<() => void>();
export const CHECK_EVERY_MS = 5 * 60 * 1000;
@@ -37,10 +40,10 @@ export async function checkServerUpdate(id: string): Promise<void> {
set(id, { checking: true });
try {
const p = await api.checkUpdate(id);
set(id, { preview: p, available: p.to_version !== (p.from_version ?? ""), error: null });
set(id, { preview: p, available: !isUpdateCurrent(p), error: null });
} catch (e) {
// offline or no package published: keep what we knew and stay quiet
set(id, { error: asUiError(e).human.code });
// A cached offer is no longer safe after an update or failed recheck.
set(id, { preview: null, available: false, error: asUiError(e).human.code });
} finally {
set(id, { checking: false });
}
@@ -57,6 +60,20 @@ export function clearServerUpdateResult(id: string) {
set(id, { result: null, uiError: null });
}
export async function refreshServerUpdateState(id: string): Promise<UpdateTxn | null> {
try {
const pending = await api.pendingUpdate(id);
const current = get(id).result;
set(id, { stateError: null, stateRevision: get(id).stateRevision + 1,
...(pending ? { available: false, preview: null } : {}),
result: pending ? { pending } : current && "committed" in current ? current : null });
return pending;
} catch (e) {
set(id, { stateError: asUiError(e), stateRevision: get(id).stateRevision + 1, available: false, preview: null });
throw e;
}
}
/**
* Apply an update. Without options this is the main-screen button: the pending update, only when no file needs a
* decision. With options (the Settings card) the owner has already chosen what to do with each changed file and may
@@ -65,7 +82,8 @@ export function clearServerUpdateResult(id: string) {
*/
export async function applyServerUpdate(id: string, opts?: { choices: Record<string, "keep" | "replace">; source?: string }): Promise<boolean> {
const p = get(id).preview;
if (get(id).applying) return false;
const current = get(id);
if (current.applying || current.stateError || (current.result && "pending" in current.result)) return false;
if (!opts && (!p || p.conflicts.length > 0)) return false;
set(id, { applying: true, step: null, percent: 0, error: null, uiError: null, result: null });
let un: (() => void) | undefined;
@@ -84,6 +102,9 @@ export async function applyServerUpdate(id: string, opts?: { choices: Record<str
return false;
} finally {
un?.();
try {
await refreshServerUpdateState(id);
} catch { /* The state error remains visible and prevents another apply. */ }
set(id, { applying: false });
void checkServerUpdate(id);
}
+7
View File
@@ -0,0 +1,7 @@
import type { UpdatePreview } from "./api";
export function isUpdateCurrent(preview: UpdatePreview): boolean {
return preview.from_version === preview.to_version
&& preview.items.every((item) => item.action === "skip")
&& (preview.pending_migrations ?? (preview.migrations === 0 ? 0 : undefined)) === 0;
}
+45 -16
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { ChevronDown, ChevronRight, Loader2 } from "lucide-react";
import { applyServerUpdate, clearServerUpdateResult, useServerUpdate } from "@/lib/serverUpdate";
import { applyServerUpdate, clearServerUpdateResult, refreshServerUpdateState, useServerUpdate } from "@/lib/serverUpdate";
import { isUpdateCurrent } from "@/lib/serverUpdateStatus";
import { api, asUiError, type UiError, type UpdatePreview, type UpdateTxn } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
@@ -23,12 +24,14 @@ export function SettingsHome({ serverId }: { serverId: string }) {
const human = useHuman();
const [preview, setPreview] = useState<UpdatePreview | null>(null);
const [pending, setPending] = useState<UpdateTxn | null>(null);
const [pendingChecked, setPendingChecked] = useState(false);
const [pendingStateError, setPendingStateError] = useState<UiError | null>(null);
const [localBusy, setBusy] = useState<"check" | "rollback" | "retry" | null>(null);
// an update keeps running in the background when this tab is left, so its state is the shared one
const busy = upd.applying ? "update" : localBusy;
const progress = upd.applying ? { step: upd.step ?? "Starting", percent: upd.percent } : null;
const [localError, setError] = useState<UiError | null>(null);
const error = localError ?? upd.uiError;
const error = pendingStateError ?? localError ?? upd.uiError;
const [localDone, setDone] = useState<string | null>(null);
const done = upd.result && "committed" in upd.result ? t("upd.updatedTo", { v: upd.result.committed }) : localDone;
const [choices, setChoices] = useState<Record<string, "keep" | "replace">>({});
@@ -36,16 +39,36 @@ export function SettingsHome({ serverId }: { serverId: string }) {
const [source, setSource] = useState("");
useEffect(() => {
void api.pendingUpdate(serverId).then(setPending).catch(() => {});
let active = true;
setPendingChecked(false);
setPending(null);
setPreview(null);
setPendingStateError(null);
void api.pendingUpdate(serverId).then((txn) => {
if (active) { setPending(txn); setPendingChecked(true); }
}).catch((e) => { if (active) setPendingStateError(asUiError(e)); });
return () => { active = false; };
}, [serverId]);
// the update finished (possibly while another tab was open): drop the stale preview, show what is left to decide
// Every finished attempt refreshes the disk state, including failures in the background.
useEffect(() => {
if (!upd.result) return;
if (!upd.stateRevision) return;
setPreview(null);
if ("pending" in upd.result) setPending(upd.result.pending);
else void api.pendingUpdate(serverId).then(setPending).catch(() => {});
}, [upd.result, serverId]);
setPendingChecked(!upd.stateError);
setPendingStateError(upd.stateError);
if (!upd.stateError) setPending(upd.result && "pending" in upd.result ? upd.result.pending : null);
}, [upd.stateRevision, serverId]);
async function refreshPending() {
setPendingChecked(false);
try {
const txn = await refreshServerUpdateState(serverId);
setPending(txn);
setPendingStateError(null);
setPendingChecked(true);
return txn;
} catch (e) { setPendingStateError(asUiError(e)); throw e; }
}
async function check() {
setBusy("check");
@@ -54,6 +77,8 @@ export function SettingsHome({ serverId }: { serverId: string }) {
clearServerUpdateResult(serverId);
setPreview(null);
try {
const txn = await refreshPending();
if (txn) return;
const p = await api.checkUpdate(serverId, source.trim() || undefined);
setPreview(p);
setChoices(Object.fromEntries(p.conflicts.map((c) => [c, "keep" as const])));
@@ -65,6 +90,7 @@ export function SettingsHome({ serverId }: { serverId: string }) {
}
async function update() {
if (!pendingChecked || pendingStateError || pending) return;
setError(null);
setDone(null);
await applyServerUpdate(serverId, { choices, source: source.trim() || undefined });
@@ -81,6 +107,7 @@ export function SettingsHome({ serverId }: { serverId: string }) {
} catch (e) {
setError(asUiError(e));
} finally {
try { await refreshPending(); } catch { /* Keep the disk-state error visible. */ }
setBusy(null);
}
}
@@ -97,7 +124,10 @@ export function SettingsHome({ serverId }: { serverId: string }) {
clearServerUpdateResult(serverId);
} else setPending(result);
} catch (e) { setError(asUiError(e)); }
finally { setBusy(null); }
finally {
try { await refreshPending(); } catch { /* Keep the disk-state error visible. */ }
setBusy(null);
}
}
async function browse() {
@@ -165,28 +195,27 @@ export function SettingsHome({ serverId }: { serverId: string }) {
</div>
)}
{done && <p className="mt-4 text-sm text-ok" role="status">{done}</p>}
{done && pendingChecked && !pendingStateError && <p className="mt-4 text-sm text-ok" role="status">{done}</p>}
{error && (
<p className="mt-4 text-sm text-bad" role="alert">
{error.human.code === "unknown" ? error.technical : human(error.human).message}
</p>
)}
{preview && preview.from_version === preview.to_version && preview.items.every((i) => i.action === "skip") && (
// The same version with no file to change: the database scripts of this package are all in the ledger already.
{preview && isUpdateCurrent(preview) && (
<div className="mt-5 border-t border-line pt-4">
<p className="font-medium text-ok" role="status">{t("upd.current", { v: preview.to_version })}</p>
</div>
)}
{preview && !(preview.from_version === preview.to_version && preview.items.every((i) => i.action === "skip")) && (
{preview && !isUpdateCurrent(preview) && (
<div className="mt-5 border-t border-line pt-4">
<p className="font-medium">
{preview.from_version ? t("upd.availableFrom", { to: preview.to_version, from: preview.from_version }) : t("upd.available", { to: preview.to_version })}
</p>
<p className="mt-1 text-sm text-muted">
{preview.migrations > 0
? t("upd.summaryDb", { size: mb(preview.download_bytes), files: preview.items.filter((i) => i.action !== "skip").length, db: preview.migrations })
{(preview.pending_migrations ?? preview.migrations) > 0
? t("upd.summaryDb", { size: mb(preview.download_bytes), files: preview.items.filter((i) => i.action !== "skip").length, db: preview.pending_migrations ?? preview.migrations })
: t("upd.summary", { size: mb(preview.download_bytes), files: preview.items.filter((i) => i.action !== "skip").length })}
</p>
<ul className="mt-3 max-h-56 divide-y divide-line overflow-auto text-sm">
@@ -211,7 +240,7 @@ export function SettingsHome({ serverId }: { serverId: string }) {
</li>
))}
</ul>
<Button className="mt-4" variant="primary" disabled={!!busy} onClick={() => void update()}>
<Button className="mt-4" variant="primary" disabled={!!busy || !pendingChecked || !!pendingStateError || !!pending} onClick={() => void update()}>
{busy === "update" && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
{t("upd.now")}
</Button>
+62
View File
@@ -0,0 +1,62 @@
import assert from "node:assert/strict";
import test from "node:test";
import { registerHooks } from "node:module";
let applyFailure = false;
let stateFailure = false;
let pending = null;
const calls = [];
globalThis.__hotfixApi = {
applyUpdate: async () => { calls.push("apply"); if (applyFailure) throw new Error("SQL failure"); return { txn: { state: "committed", to_version: "2" } }; },
pendingUpdate: async () => { calls.push("disk"); if (stateFailure) throw new Error("damaged journal"); return pending; },
checkUpdate: async () => ({ from_version: "2", to_version: "2", items: [], conflicts: [], migrations: 1, pending_migrations: 0 }),
};
registerHooks({ resolve(specifier, context, next) {
if (specifier === "react") return { shortCircuit: true, url: "data:text/javascript,export const useSyncExternalStore=(subscribe,getSnapshot)=>getSnapshot();" };
if (specifier === "@/lib/api") return { shortCircuit: true, url: "data:text/javascript,export const api=globalThis.__hotfixApi;export const asUiError=e=>({human:{code:'unknown'},technical:e.message});" };
if (specifier === "@tauri-apps/api/event") return { shortCircuit: true, url: "data:text/javascript,export const listen=async()=>()=>{};" };
if (specifier === "./serverUpdateStatus" && context.parentURL?.endsWith("/serverUpdate.ts")) return { shortCircuit: true, url: new URL("../src/lib/serverUpdateStatus.ts", import.meta.url).href };
return next(specifier, context);
} });
const { applyServerUpdate, refreshServerUpdateState, checkServerUpdate, useServerUpdate } = await import("../src/lib/serverUpdate.ts");
test("a failed update rereads its unfinished transaction from disk", async () => {
calls.length = 0;
applyFailure = true;
pending = { id: "interrupted", state: "failed", message: "Recovery required" };
assert.equal(await applyServerUpdate("failed-case", { choices: {} }), false);
assert.deepEqual(calls, ["apply", "disk"]);
assert.equal(await refreshServerUpdateState("failed-case"), pending);
});
test("unreadable state blocks another apply until a successful reread", async () => {
calls.length = 0;
applyFailure = false;
stateFailure = true;
await applyServerUpdate("corrupt-case", { choices: {} });
assert.equal(await applyServerUpdate("corrupt-case", { choices: {} }), false);
assert.deepEqual(calls, ["apply", "disk"]);
stateFailure = false;
pending = null;
assert.equal(await refreshServerUpdateState("corrupt-case"), null);
assert.equal(await applyServerUpdate("corrupt-case", { choices: {} }), true);
assert.deepEqual(calls, ["apply", "disk", "disk", "apply", "disk"]);
});
test("refresh after recovery returns the latest disk state, then its absence", async () => {
pending = { id: "recovery", state: "failed", message: "Restore failed at database verification" };
assert.equal(await refreshServerUpdateState("recovery-case"), pending);
pending = null;
assert.equal(await refreshServerUpdateState("recovery-case"), null);
});
test("a rejected recheck removes an earlier cached update offer", async () => {
const original = globalThis.__hotfixApi.checkUpdate;
try {
globalThis.__hotfixApi.checkUpdate = async () => ({ from_version: "0.261005.22", to_version: "0.261005.24", items: [], migrations: 1, pending_migrations: 1 });
await checkServerUpdate("stale-offer");
assert.equal(useServerUpdate("stale-offer").available, true);
globalThis.__hotfixApi.checkUpdate = async () => { throw new Error("Installed version is newer than package"); };
await checkServerUpdate("stale-offer");
assert.equal(useServerUpdate("stale-offer").available, false);
assert.equal(useServerUpdate("stale-offer").preview, null);
} finally { globalThis.__hotfixApi.checkUpdate = original; }
});
+18
View File
@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isUpdateCurrent } from "../src/lib/serverUpdateStatus.ts";
const same = { from_version: "1", to_version: "1", items: [{ action: "skip" }], migrations: 10 };
test("same files and version cannot hide pending database repairs", () => {
assert.equal(isUpdateCurrent({ ...same, pending_migrations: 1 }), false);
assert.equal(isUpdateCurrent({ ...same, pending_migrations: 0 }), true);
});
test("unknown history is not presented as up to date", () => {
assert.equal(isUpdateCurrent(same), false);
assert.equal(isUpdateCurrent({ ...same, migrations: 0 }), true);
});
test("file changes and new releases remain available with no pending SQL", () => {
assert.equal(isUpdateCurrent({ ...same, pending_migrations: 0, items: [{ action: "replace" }] }), false);
assert.equal(isUpdateCurrent({ ...same, pending_migrations: 0, to_version: "2" }), false);
});