diff --git a/Cargo.lock b/Cargo.lock index 97bff9f..1f939a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 2f57649..73bccfa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/HOTFIX_READINESS.md b/HOTFIX_READINESS.md new file mode 100644 index 0000000..ab03652 --- /dev/null +++ b/HOTFIX_READINESS.md @@ -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. diff --git a/changelog.d/20261005-update-history-hotfix.md b/changelog.d/20261005-update-history-hotfix.md new file mode 100644 index 0000000..1edaa12 --- /dev/null +++ b/changelog.d/20261005-update-history-hotfix.md @@ -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. diff --git a/changelog.d/20261006-prevent-server-downgrades.md b/changelog.d/20261006-prevent-server-downgrades.md new file mode 100644 index 0000000..852ab7d --- /dev/null +++ b/changelog.d/20261006-prevent-server-downgrades.md @@ -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. diff --git a/changelog.d/20261006-recognize-managed-launcher.md b/changelog.d/20261006-recognize-managed-launcher.md new file mode 100644 index 0000000..eb10e36 --- /dev/null +++ b/changelog.d/20261006-recognize-managed-launcher.md @@ -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. diff --git a/changelog.d/20261006-update-preflight-and-recovery-guards.md b/changelog.d/20261006-update-preflight-and-recovery-guards.md new file mode 100644 index 0000000..8216096 --- /dev/null +++ b/changelog.d/20261006-update-preflight-and-recovery-guards.md @@ -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. diff --git a/changelog.d/20261006-update-sql-and-status-guards.md b/changelog.d/20261006-update-sql-and-status-guards.md new file mode 100644 index 0000000..38e797b --- /dev/null +++ b/changelog.d/20261006-update-sql-and-status-guards.md @@ -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. diff --git a/crates/coa-core/Cargo.toml b/crates/coa-core/Cargo.toml index 1bea4ee..d4471dd 100644 --- a/crates/coa-core/Cargo.toml +++ b/crates/coa-core/Cargo.toml @@ -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 = [] diff --git a/crates/coa-core/examples/hotfix_history_e2e.rs b/crates/coa-core/examples/hotfix_history_e2e.rs new file mode 100644 index 0000000..8d12d55 --- /dev/null +++ b/crates/coa-core/examples/hotfix_history_e2e.rs @@ -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> { 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(()) +} diff --git a/crates/coa-core/examples/hotfix_preview_e2e.rs b/crates/coa-core/examples/hotfix_preview_e2e.rs new file mode 100644 index 0000000..63d6e07 --- /dev/null +++ b/crates/coa-core/examples/hotfix_preview_e2e.rs @@ -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::>()); + println!("PASS: real fixture repeated preview: {} files unchanged, zero pending SQL, no update offered",preview.items.len()); + Ok(()) +} diff --git a/crates/coa-core/examples/hotfix_transition_e2e.rs b/crates/coa-core/examples/hotfix_transition_e2e.rs new file mode 100644 index 0000000..2157732 --- /dev/null +++ b/crates/coa-core/examples/hotfix_transition_e2e.rs @@ -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 { 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 { 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 { + 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::>>())?; + assert!(driver::run(root,driver::Verb::StopAll)?.ok); + let dirs=std::fs::read_dir(meta.join("updates"))?.collect::>>()?; + 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(()) +} diff --git a/crates/coa-core/examples/migrate_e2e.rs b/crates/coa-core/examples/migrate_e2e.rs index 290bd3e..91b1adc 100644 --- a/crates/coa-core/examples/migrate_e2e.rs +++ b/crates/coa-core/examples/migrate_e2e.rs @@ -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 { diff --git a/crates/coa-core/examples/realm_e2e.rs b/crates/coa-core/examples/realm_e2e.rs index af08337..b5fab7c 100644 --- a/crates/coa-core/examples/realm_e2e.rs +++ b/crates/coa-core/examples/realm_e2e.rs @@ -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()); diff --git a/crates/coa-core/examples/repair_e2e.rs b/crates/coa-core/examples/repair_e2e.rs index 300a771..06499e2 100644 --- a/crates/coa-core/examples/repair_e2e.rs +++ b/crates/coa-core/examples/repair_e2e.rs @@ -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()); diff --git a/crates/coa-core/examples/update_e2e.rs b/crates/coa-core/examples/update_e2e.rs index 547284e..a398d99 100644 --- a/crates/coa-core/examples/update_e2e.rs +++ b/crates/coa-core/examples/update_e2e.rs @@ -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(); diff --git a/crates/coa-core/examples/update_recovery_e2e.rs b/crates/coa-core/examples/update_recovery_e2e.rs index 9cbf310..7e24705 100644 --- a/crates/coa-core/examples/update_recovery_e2e.rs +++ b/crates/coa-core/examples/update_recovery_e2e.rs @@ -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 { diff --git a/crates/coa-core/examples/wildcard_repair_e2e.rs b/crates/coa-core/examples/wildcard_repair_e2e.rs index d9ebec1..7373c40 100644 --- a/crates/coa-core/examples/wildcard_repair_e2e.rs +++ b/crates/coa-core/examples/wildcard_repair_e2e.rs @@ -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()))?; diff --git a/crates/coa-core/src/backup.rs b/crates/coa-core/src/backup.rs index 5fcad0d..8e21adb 100644 --- a/crates/coa-core/src/backup.rs +++ b/crates/coa-core/src/backup.rs @@ -51,6 +51,8 @@ pub struct Component { pub sha256: Option, pub tables: Option, pub files: Option>, + #[serde(default)] + pub file_sha256: std::collections::BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -91,7 +93,9 @@ pub fn list(meta: &Path) -> Vec { } pub fn get(meta: &Path, id: &str) -> Result { - 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 { 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 { 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 { }, (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 { /// 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(); diff --git a/crates/coa-core/src/cleanbase.rs b/crates/coa-core/src/cleanbase.rs index 9519ff7..ced4b04 100644 --- a/crates/coa-core/src/cleanbase.rs +++ b/crates/coa-core/src/cleanbase.rs @@ -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 { diff --git a/crates/coa-core/src/fsx.rs b/crates/coa-core/src/fsx.rs index 983fa0c..47925a1 100644 --- a/crates/coa-core/src/fsx.rs +++ b/crates/coa-core/src/fsx.rs @@ -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> { + 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(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"); diff --git a/crates/coa-core/src/manifest.rs b/crates/coa-core/src/manifest.rs index 9f5fac4..b87dbe9 100644 --- a/crates/coa-core/src/manifest.rs +++ b/crates/coa-core/src/manifest.rs @@ -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, 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) { diff --git a/crates/coa-core/src/migrations.rs b/crates/coa-core/src/migrations.rs index d7d8d9b..7341bc4 100644 --- a/crates/coa-core/src/migrations.rs +++ b/crates/coa-core/src/migrations.rs @@ -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>; + fn load_readonly(&self) -> Result> { 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> { + 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 { + 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) -> Result { 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> { panic!("preflight must use readonly access") } + fn load_readonly(&self) -> Result> { 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;")]); diff --git a/crates/coa-core/src/release.rs b/crates/coa-core/src/release.rs index 427b03c..0ef1ece 100644 --- a/crates/coa-core/src/release.rs +++ b/crates/coa-core/src/release.rs @@ -98,7 +98,7 @@ pub fn collect_bots_sql(bots: &Path) -> Result> { } fn to_migrations(files: &[SqlFile]) -> Vec { - 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> { diff --git a/crates/coa-core/src/release_schema.rs b/crates/coa-core/src/release_schema.rs index 3b9c429..d4c161e 100644 --- a/crates/coa-core/src/release_schema.rs +++ b/crates/coa-core/src/release_schema.rs @@ -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)?)?; diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index dfbb8bc..a048010 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -77,6 +77,8 @@ pub struct Txn { pub databases_started: bool, pub ops: Vec, pub message: Option, + #[serde(default)] + pub recovery_hashes: BTreeMap, } 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; 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 { - 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 { + 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 { + txn_dir(meta, id)?; + Ok(meta.join("update-cleanup").join(format!("{id}.json"))) +} + +fn journals(meta: &Path) -> Result> { + 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::(&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> { + 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 { - let rd = fs::read_dir(updates_dir(meta)).ok()?; - let mut all: Vec = rd.flatten().filter_map(|e| fsx::read_json::(&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::(&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, pub conflicts: Vec, 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) -> Result { + 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 { + 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 { 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 { 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 { 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 { 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::(&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 { + 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, calls: RefCell>, migrate_fail: Cell, + preflight_fail: Cell, restore_fail: Cell, } 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); diff --git a/package-lock.json b/package-lock.json index a98828c..7ef1c3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 650bb96..d5ed230 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 874169e..e930549 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -697,7 +697,7 @@ async fn check_update(state: State<'_, AppState>, id: String, source: Option, id: String) -> std::result::Result, UiError> { let root = path_of(&state, &id)?; - Ok(update::unfinished(&meta_dir(&root)?)) + Ok(update::pending_checked(&meta_dir(&root)?)?) } #[tauri::command] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6340112..0f3103d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/dev/mock.ts b/src/dev/mock.ts index 4996c1c..e3ac63f 100644 --- a/src/dev/mock.ts +++ b/src/dev/mock.ts @@ -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; diff --git a/src/lib/api.ts b/src/lib/api.ts index 554608e..c3f239d 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -270,6 +270,7 @@ export interface UpdatePreview { items: UpdatePlanItem[]; conflicts: string[]; migrations: number; + pending_migrations?: number; download_bytes: number; } diff --git a/src/lib/serverUpdate.ts b/src/lib/serverUpdate.ts index 49727ff..cc74040 100644 --- a/src/lib/serverUpdate.ts +++ b/src/lib/serverUpdate.ts @@ -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(); const listeners = new Set<() => void>(); export const CHECK_EVERY_MS = 5 * 60 * 1000; @@ -37,10 +40,10 @@ export async function checkServerUpdate(id: string): Promise { 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 { + 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; source?: string }): Promise { 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 item.action === "skip") + && (preview.pending_migrations ?? (preview.migrations === 0 ? 0 : undefined)) === 0; +} diff --git a/src/screens/SettingsHome.tsx b/src/screens/SettingsHome.tsx index 9d09611..1f5574f 100644 --- a/src/screens/SettingsHome.tsx +++ b/src/screens/SettingsHome.tsx @@ -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(null); const [pending, setPending] = useState(null); + const [pendingChecked, setPendingChecked] = useState(false); + const [pendingStateError, setPendingStateError] = useState(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(null); - const error = localError ?? upd.uiError; + const error = pendingStateError ?? localError ?? upd.uiError; const [localDone, setDone] = useState(null); const done = upd.result && "committed" in upd.result ? t("upd.updatedTo", { v: upd.result.committed }) : localDone; const [choices, setChoices] = useState>({}); @@ -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 }) { )} - {done &&

{done}

} + {done && pendingChecked && !pendingStateError &&

{done}

} {error && (

{error.human.code === "unknown" ? error.technical : human(error.human).message}

)} - {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) && (

{t("upd.current", { v: preview.to_version })}

)} - {preview && !(preview.from_version === preview.to_version && preview.items.every((i) => i.action === "skip")) && ( + {preview && !isUpdateCurrent(preview) && (

{preview.from_version ? t("upd.availableFrom", { to: preview.to_version, from: preview.from_version }) : t("upd.available", { to: preview.to_version })}

- {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 })}

    @@ -211,7 +240,7 @@ export function SettingsHome({ serverId }: { serverId: string }) { ))}
- diff --git a/tools/server-update-refresh.test.mjs b/tools/server-update-refresh.test.mjs new file mode 100644 index 0000000..dde2c9d --- /dev/null +++ b/tools/server-update-refresh.test.mjs @@ -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; } +}); diff --git a/tools/server-update-status.test.mjs b/tools/server-update-status.test.mjs new file mode 100644 index 0000000..8db2ce2 --- /dev/null +++ b/tools/server-update-status.test.mjs @@ -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); +});