From 94fd5fb5b9d234bbb31df7d42a1f8d0543c5f850 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 10 Oct 2026 12:05:14 +0500 Subject: [PATCH 01/17] Resolve signed immutable server update channels --- .../20261010-immutable-update-channels.md | 7 ++ crates/coa-core/src/channels.rs | 86 +++++++++++++++++++ crates/coa-core/src/lib.rs | 1 + crates/coa-release/src/main.rs | 10 +++ src-tauri/src/lib.rs | 20 +++-- 5 files changed, 117 insertions(+), 7 deletions(-) create mode 100644 changelog.d/20261010-immutable-update-channels.md create mode 100644 crates/coa-core/src/channels.rs diff --git a/changelog.d/20261010-immutable-update-channels.md b/changelog.d/20261010-immutable-update-channels.md new file mode 100644 index 0000000..eb7e414 --- /dev/null +++ b/changelog.d/20261010-immutable-update-channels.md @@ -0,0 +1,7 @@ +--- +area: manager +type: added +audience: admins +title: Updates can follow signed channels that select a complete server release +--- +The Manager checks the channel signature and downloads every package part from the same release. Existing installations continue using the previous download source until the new channel is published. diff --git a/crates/coa-core/src/channels.rs b/crates/coa-core/src/channels.rs new file mode 100644 index 0000000..53d787a --- /dev/null +++ b/crates/coa-core/src/channels.rs @@ -0,0 +1,86 @@ +//! Signed channel pointers select one immutable package for an entire transaction. +use serde::{Deserialize, Serialize}; +use crate::{Error, Result}; +use crate::pkgsource::{fetch_small, Source}; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Pointer { + pub schema: u32, + pub channel: String, + pub version: String, + pub release_tag: String, + pub snapshot: String, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Envelope { + pub payload: String, + pub signature: String, +} + +pub fn verify(bytes: &[u8], channel: &str, key: &str) -> Result { + let envelope: Envelope = serde_json::from_slice(bytes).map_err(|e| Error::InvalidManifest(e.to_string()))?; + crate::signing::verify(envelope.payload.as_bytes(), &envelope.signature, key)?; + let pointer: Pointer = serde_json::from_str(&envelope.payload).map_err(|e| Error::InvalidManifest(e.to_string()))?; + if pointer.schema != 1 || pointer.channel != channel + || pointer.release_tag != format!("server-{}", pointer.version) + || pointer.version.is_empty() + || !pointer.version.bytes().all(|b| b.is_ascii_digit() || b == b'.') + || pointer.snapshot.len() != 64 + || !pointer.snapshot.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(Error::InvalidManifest("Invalid channel pointer".into())); + } + Ok(pointer) +} + +pub fn resolve(channel: &str) -> Result { + if channel != "stable" && channel != "edge" { + return Err(Error::InvalidManifest("Unknown release channel".into())); + } + let source = Source::Url("https://raw.githubusercontent.com/Corfirean/coa-server-build/channels".into()); + let bytes = fetch_small(&source, &format!("{channel}.json"))?; + let pointer = verify(&bytes, channel, crate::signing::EMBEDDED_PUBLIC_KEY)?; + Ok(Source::Url(format!("https://github.com/Corfirean/coa-server-build/releases/download/{}", pointer.release_tag))) +} + +pub fn sign(pointer: &Pointer, seed: &str) -> Result> { + use base64::{engine::general_purpose::STANDARD, Engine}; + use ed25519_dalek::{Signer, SigningKey}; + let seed: [u8; 32] = STANDARD.decode(seed.trim()).map_err(|_| Error::Invalid("Invalid signing key".into()))? + .try_into().map_err(|_| Error::Invalid("Invalid signing key length".into()))?; + let payload = serde_json::to_string(pointer).map_err(|e| Error::Invalid(e.to_string()))?; + let signature = STANDARD.encode(SigningKey::from_bytes(&seed).sign(payload.as_bytes()).to_bytes()); + let bytes = serde_json::to_vec(&Envelope { payload, signature }).map_err(|e| Error::Invalid(e.to_string()))?; + verify(&bytes, &pointer.channel, crate::signing::EMBEDDED_PUBLIC_KEY)?; + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::{engine::general_purpose::STANDARD, Engine}; + use ed25519_dalek::{Signer, SigningKey}; + + fn signed(pointer: &Pointer) -> (Vec, String) { + let sk = SigningKey::from_bytes(&[17; 32]); + let payload = serde_json::to_string(pointer).unwrap(); + let signature = STANDARD.encode(sk.sign(payload.as_bytes()).to_bytes()); + (serde_json::to_vec(&Envelope { payload, signature }).unwrap(), STANDARD.encode(sk.verifying_key().to_bytes())) + } + + #[test] + fn verifies_identity_signature_and_immutable_tag() { + let mut p = Pointer { schema: 1, channel: "stable".into(), version: "0.261010.40".into(), release_tag: "server-0.261010.40".into(), snapshot: "a".repeat(64) }; + let (bytes, key) = signed(&p); + assert_eq!(verify(&bytes, "stable", &key).unwrap().version, p.version); + assert!(verify(&bytes, "edge", &key).is_err()); + let mut changed: Envelope = serde_json::from_slice(&bytes).unwrap(); + changed.payload = changed.payload.replace("40", "41"); + assert!(verify(&serde_json::to_vec(&changed).unwrap(), "stable", &key).is_err()); + p.release_tag = "stable".into(); + let (bytes, key) = signed(&p); + assert!(verify(&bytes, "stable", &key).is_err()); + } +} diff --git a/crates/coa-core/src/lib.rs b/crates/coa-core/src/lib.rs index e276e03..18d2b35 100644 --- a/crates/coa-core/src/lib.rs +++ b/crates/coa-core/src/lib.rs @@ -47,6 +47,7 @@ pub mod update; pub mod wine; pub mod upnp; pub mod signing; +pub mod channels; pub mod srp6; pub use error::{Error, ErrorCode, Result}; diff --git a/crates/coa-release/src/main.rs b/crates/coa-release/src/main.rs index 7551547..604359b 100644 --- a/crates/coa-release/src/main.rs +++ b/crates/coa-release/src/main.rs @@ -112,6 +112,16 @@ fn run() -> Result<(), String> { coa_core::release_schema::verify_package(&dir, coa_core::signing::EMBEDDED_PUBLIC_KEY).map_err(e)?; println!("signature, archive contents and database schema contract OK"); } + "sign-channel" => { + let pointer = coa_core::channels::Pointer { + schema: 1, channel: need(&a, "channel")?.clone(), + version: need(&a, "version")?.clone(), + release_tag: format!("server-{}", need(&a, "version")?), + snapshot: need(&a, "snapshot")?.clone(), + }; + let bytes = coa_core::channels::sign(&pointer, &signing_key()?).map_err(e)?; + coa_core::fsx::atomic_write(&PathBuf::from(need(&a, "out")?), &bytes).map_err(e)?; + } other => return Err(format!("unknown command {other}")), } Ok(()) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1d9ade2..1110b03 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -39,12 +39,16 @@ struct AppState { /// Where signed update packages are published; override with COA_UPDATE_SOURCE (URL or local package folder). const DEFAULT_UPDATE_URL: &str = "https://github.com/Corfirean/coa-server-build/releases/download/stable"; -fn update_source(custom: Option) -> Source { +fn update_source(custom: Option) -> Result { let pick = custom.filter(|s| !s.trim().is_empty()).or_else(|| std::env::var("COA_UPDATE_SOURCE").ok()); match pick { - Some(p) if p.to_ascii_lowercase().starts_with("http") => Source::Url(p), - Some(p) => Source::Dir(PathBuf::from(p)), - None => Source::Url(DEFAULT_UPDATE_URL.into()), + Some(p) if p.to_ascii_lowercase().starts_with("http") => Ok(Source::Url(p)), + Some(p) => Ok(Source::Dir(PathBuf::from(p))), + None => match coa_core::channels::resolve("stable") { + Ok(source) => Ok(source), + Err(Error::PackageNotPublished(_)) => Ok(Source::Url(DEFAULT_UPDATE_URL.into())), + Err(error) => Err(error), + }, } } @@ -645,7 +649,7 @@ async fn repair_server(app: AppHandle, state: State<'_, AppState>, id: String) - let _guard = BusyGuard::acquire(&state, &id)?; blocking(move || { let dir = meta_dir(&root)?; - coa_core::repair::run(&root, &dir, &package_source(None), &update_source(None), coa_core::signing::EMBEDDED_PUBLIC_KEY, &|step,percent| { + coa_core::repair::run(&root, &dir, &package_source(None), &update_source(None)?, coa_core::signing::EMBEDDED_PUBLIC_KEY, &|step,percent| { let _ = app.emit("repair-progress", serde_json::json!({ "id": id, "step": step, "percent": percent })); }) }).await @@ -797,11 +801,12 @@ fn install_meta(root: &std::path::Path) -> Result<(PathBuf, InstallMeta)> { #[tauri::command] async fn check_update(state: State<'_, AppState>, id: String, source: Option, background: Option) -> std::result::Result { let root = path_of(&state, &id)?; - let src = update_source(source); + // The check that repeats every few minutes must not start and stop the database of a stopped server. let access = if background.unwrap_or(false) { update::DatabaseAccess::OnlyIfRunning } else { update::DatabaseAccess::Start }; blocking(move || { let (_, meta) = install_meta(&root)?; + let src = update_source(source)?; update::preview_with(&root, &meta, &src, coa_core::signing::EMBEDDED_PUBLIC_KEY, &Default::default(), access) }) .await @@ -822,9 +827,10 @@ async fn apply_update( resolutions: BTreeMap, ) -> std::result::Result { let root = path_of(&state, &id)?; - let src = update_source(source); + let _guard = BusyGuard::acquire(&state, &id)?; blocking(move || { + let src = update_source(source)?; let (dir, _) = install_meta(&root)?; // Files cannot be replaced while the server runs: stop it first (gracefully), like the Stop button. let observed = coa_core::process::observe(&root, &layout::read_ports(&root)); From 115c57f54e8b2078d534362cc4401488ad71d7a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 10 Oct 2026 12:11:19 +0500 Subject: [PATCH 02/17] Separate module installation SQL from incremental update packages --- .../20261010-module-base-update-replay.md | 7 ++++ crates/coa-core/src/cleanbase.rs | 4 +- crates/coa-core/src/release.rs | 39 +++++++++++++++++-- 3 files changed, 45 insertions(+), 5 deletions(-) create mode 100644 changelog.d/20261010-module-base-update-replay.md diff --git a/changelog.d/20261010-module-base-update-replay.md b/changelog.d/20261010-module-base-update-replay.md new file mode 100644 index 0000000..0c16757 --- /dev/null +++ b/changelog.d/20261010-module-base-update-replay.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Server updates no longer replay module installation scripts on existing databases +--- +Update packages contain incremental SQL only. Module table initialization and seed scripts remain available when preparing a clean installation, preventing existing content from being reset during updates. diff --git a/crates/coa-core/src/cleanbase.rs b/crates/coa-core/src/cleanbase.rs index ced4b04..3f96fe4 100644 --- a/crates/coa-core/src/cleanbase.rs +++ b/crates/coa-core/src/cleanbase.rs @@ -10,7 +10,7 @@ use crate::backup; use crate::error::{Error, Result}; use crate::fsx; use crate::migrations::{apply_pending, status, Status}; -use crate::release::{collect_bots_sql, collect_core_sql}; +use crate::release::{collect_bots_sql, collect_core_install_sql}; pub struct Params<'a> { pub repack: &'a Path, @@ -177,7 +177,7 @@ pub fn build(p: &Params, say: &dyn Fn(&str)) -> Result<()> { } fsx::atomic_write_json(&repack_json, &cfg)?; - let mut sql = collect_core_sql(p.core)?; + let mut sql = collect_core_install_sql(p.core)?; if let Some(b) = p.bots { sql.extend(collect_bots_sql(b)?); } diff --git a/crates/coa-core/src/release.rs b/crates/coa-core/src/release.rs index ba8b5f1..c7daab8 100644 --- a/crates/coa-core/src/release.rs +++ b/crates/coa-core/src/release.rs @@ -44,8 +44,17 @@ fn introduction_rank(repo: &Path, pathspecs: &[&str]) -> std::collections::HashM rank } -/// Every SQL script of a core checkout (released + pending updates, module base and update scripts), in application order. +/// Incremental SQL of a core checkout. Installation-only module bases must never be replayed on a live database. pub fn collect_core_sql(core: &Path) -> Result> { + collect_core_sql_mode(core, false) +} + +/// A clean installation also needs the module tables and initial seed data. +pub fn collect_core_install_sql(core: &Path) -> Result> { + collect_core_sql_mode(core, true) +} + +fn collect_core_sql_mode(core: &Path, include_base: bool) -> Result> { let mut out = Vec::new(); let mut push = |db: &str, id: String, abs: PathBuf, origin: String| -> Result<()> { let sha256 = fsx::sha256_file(&abs)?; @@ -69,7 +78,7 @@ pub fn collect_core_sql(core: &Path) -> Result> { let mname = name.replace('-', "_"); for kind in ["auth", "characters", "world"] { let base = m.path().join(format!("data/sql/db-{kind}")); - for f in sql_in(&base.join("base")) { + for f in if include_base { sql_in(&base.join("base")) } else { vec![] } { let stem = f.file_stem().unwrap().to_string_lossy().into_owned(); let rel = format!("modules/{name}/data/sql/db-{kind}/base/{}", f.file_name().unwrap().to_string_lossy()); push(kind, format!("mod_{mname}__base__{stem}"), f, rel)?; @@ -144,6 +153,9 @@ fn walk(root: &Path, dir: &Path, out: &mut Vec) -> Result<()> { /// Build a cumulative update: every file of `tree` that differs from the base, plus every SQL script. Unsigned. pub fn pack_update(p: &UpdateParams, progress: &dyn Fn(&str)) -> Result { + if let Some(file) = p.sql.iter().find(|file| file.id.starts_with("mod_") && file.id.contains("__base__")) { + return Err(Error::Invalid(format!("Module installation SQL {} cannot be included in an update package.", file.id))); + } let base: BTreeMap = p.base_manifest.files.iter().map(|f| (f.path.to_lowercase(), f.sha256.as_str())).collect(); let work = p.out.with_file_name(format!("{}-staging", p.out.file_name().unwrap().to_string_lossy())); let _ = fs::remove_dir_all(&work); @@ -218,7 +230,7 @@ mod tests { write(c, "data/sql/updates/db_characters/2026_01_02_00.sql", "SELECT 3;"); write(c, "modules/mod-x/data/sql/db-world/base/01_a.sql", "SELECT 4;"); write(c, "modules/mod-x/data/sql/db-world/2026_02_02_00_b.sql", "SELECT 5;"); - let v = collect_core_sql(c).unwrap(); + let v = collect_core_install_sql(c).unwrap(); let ids: Vec<(&str, &str)> = v.iter().map(|f| (f.db.as_str(), f.id.as_str())).collect(); assert!(ids.contains(&("world", "2026_01_01_00")) && ids.contains(&("world", "rev_1")) && ids.contains(&("characters", "2026_01_02_00"))); assert!(ids.contains(&("world", "mod_mod_x__base__01_a")) && ids.contains(&("world", "mod_mod_x__2026_02_02_00_b"))); @@ -226,6 +238,27 @@ mod tests { let base_pos = ids.iter().position(|x| x.1 == "mod_mod_x__base__01_a").unwrap(); let upd_pos = ids.iter().position(|x| x.1 == "mod_mod_x__2026_02_02_00_b").unwrap(); assert!(base_pos < upd_pos, "a module's base script precedes its updates"); + let updates = collect_core_sql(c).unwrap(); + assert!(!updates.iter().any(|f| f.id.contains("__base__"))); + assert!(updates.iter().any(|f| f.id == "mod_mod_x__2026_02_02_00_b")); + } + + #[test] + fn update_packaging_rejects_module_seed_sql_before_creating_output() { + let d = tempfile::tempdir().unwrap(); + let base: Manifest = serde_json::from_value(serde_json::json!({ + "schema": 1, "kind": "base", "version": "1.0.0", "core": {"commit": null}, + "builtAt": "test", "minManagerVersion": "0.1.0", "files": [] + })).unwrap(); + let out = d.path().join("out"); + let sql = vec![SqlFile { db: "world".into(), id: "mod_mod_raid__base__03_boss_schedule".into(), + origin: "modules/mod-raid/data/sql/db-world/base/03_boss_schedule.sql".into(), + abs: d.path().join("seed.sql"), sha256: "a".repeat(64) }]; + let result = pack_update(&UpdateParams { tree: d.path(), base_manifest: &base, sql: &sql, + out: &out, version: "1.1.0".into(), core_commit: None, bots_commit: None, part_size: 1024 }, &|_| {}); + assert!(result.unwrap_err().to_string().contains("installation SQL")); + assert!(!out.exists()); + assert!(!d.path().join("out-staging").exists()); } #[test] From 77318895aeec9fad7ecbd3773d155ec2490cc3b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 10 Oct 2026 12:15:43 +0500 Subject: [PATCH 03/17] Reproduce raid base SQL replay on disposable MySQL --- tools/test_module_sql.py | 79 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tools/test_module_sql.py diff --git a/tools/test_module_sql.py b/tools/test_module_sql.py new file mode 100644 index 0000000..2ed16d5 --- /dev/null +++ b/tools/test_module_sql.py @@ -0,0 +1,79 @@ +"""Exercise raid module initialization against disposable MySQL data only.""" +import argparse +import socket +import subprocess +import tempfile +import time +from pathlib import Path + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--mysql-bin', required=True, type=Path) + parser.add_argument('--core', required=True, type=Path) + parser.add_argument('--before', required=True) + args = parser.parse_args() + suffix = '.exe' if (args.mysql_bin / 'mysqld.exe').exists() else '' + server = args.mysql_bin / ('mysqld' + suffix) + client = args.mysql_bin / ('mysql' + suffix) + admin = args.mysql_bin / ('mysqladmin' + suffix) + relative = 'modules/mod-coa-raid-difficulty/data/sql/db-world/base/03_boss_schedule.sql' + original = subprocess.check_output(['git', '-C', str(args.core), 'show', args.before + ':' + relative], text=True) + current = (args.core / relative).read_text() + split = (args.core / 'data/sql/updates/pending_db_world/rev_20261001_g1_molten_core_shazzrah_nova_area.sql').read_text() + with tempfile.TemporaryDirectory(prefix='coa-module-sql-fixture-') as temp: + data = Path(temp) / 'data' + flags = subprocess.CREATE_NO_WINDOW if hasattr(subprocess, 'CREATE_NO_WINDOW') else 0 + subprocess.run([str(server), '--no-defaults', '--initialize-insecure', '--datadir=' + str(data)], + check=True, capture_output=True, creationflags=flags) + with socket.socket() as sock: + sock.bind(('127.0.0.1', 0)) + port = sock.getsockname()[1] + process = subprocess.Popen([str(server), '--no-defaults', '--datadir=' + str(data), + '--bind-address=127.0.0.1', '--port=' + str(port), '--mysqlx=0', + '--skip-log-bin', '--log-error=' + str(Path(temp) / 'mysql.log')], + creationflags=flags, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + connection = ['--no-defaults', '--protocol=TCP', '--host=127.0.0.1', '--port=' + str(port), '--user=root'] + + def sql(statement, schema=None, check=True): + result = subprocess.run([str(client), *connection, '--batch', '--skip-column-names', *([schema] if schema else [])], + input=statement, text=True, capture_output=True, creationflags=flags) + if check and result.returncode: + raise RuntimeError(result.stderr) + return result + + try: + deadline = time.monotonic() + 60 + while sql('SELECT 1;', check=False).returncode: + if process.poll() is not None or time.monotonic() > deadline: + raise RuntimeError('Disposable MySQL did not start: ' + (Path(temp) / 'mysql.log').read_text()) + time.sleep(0.5) + for schema in ('fresh', 'old', 'upgraded'): + sql('CREATE DATABASE `' + schema + '`;') + sql('CREATE TABLE creature_template (entry INT PRIMARY KEY, ScriptName VARCHAR(128));', schema) + sql(original, 'old') + sql('CREATE TABLE coa_spell_damage_info (spell_id INT); CREATE TABLE spell_script_names (spell_id INT, ScriptName VARCHAR(128));', 'old') + sql(split, 'old') + broken = sql(original, 'old', check=False) + assert broken.returncode and '1136' in broken.stderr, broken.stderr + sql(current, 'fresh') + sql(current, 'fresh') + sql(original, 'upgraded') + sql(current, 'upgraded') + for schema in ('fresh', 'upgraded'): + count = sql("SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='coa_boss_schedule';", schema).stdout.strip() + assert count == '15', count + effects = sql('SELECT effect_d0,effect_d1,effect_d2,effect_d3 FROM coa_boss_schedule WHERE entry=12264 AND idx=6;', schema).stdout.strip() + assert effects == '2105613\t2105614\t2105615\t2105616', effects + print('PASS: original SQL reproduces ERROR 1136 after migration; fixed SQL supports fresh, repeated and old-layout initialization') + finally: + subprocess.run([str(admin), *connection, 'shutdown'], capture_output=True, creationflags=flags) + try: + process.wait(timeout=15) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +if __name__ == '__main__': + main() From 02843bb6d16bf488fabcebe128f0f0b19f7fae86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 10 Oct 2026 12:22:14 +0500 Subject: [PATCH 04/17] Check release compatibility and recover unhealthy updates automatically --- Cargo.lock | 6 +- Cargo.toml | 2 +- .../20261010-automatic-update-recovery.md | 7 ++ changelog.d/20261010-release-compatibility.md | 7 ++ crates/coa-core/src/manifest.rs | 40 +++++++++ crates/coa-core/src/package.rs | 2 + crates/coa-core/src/update.rs | 85 ++++++++++++++++++- package-lock.json | 4 +- package.json | 2 +- src-tauri/src/lib.rs | 4 +- src-tauri/tauri.conf.json | 2 +- 11 files changed, 150 insertions(+), 11 deletions(-) create mode 100644 changelog.d/20261010-automatic-update-recovery.md create mode 100644 changelog.d/20261010-release-compatibility.md diff --git a/Cargo.lock b/Cargo.lock index d19496c..85b38d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -491,7 +491,7 @@ dependencies = [ [[package]] name = "coa-core" -version = "0.6.12" +version = "0.6.13" dependencies = [ "base64 0.22.1", "chrono", @@ -520,7 +520,7 @@ dependencies = [ [[package]] name = "coa-release" -version = "0.6.12" +version = "0.6.13" dependencies = [ "chrono", "coa-core", @@ -529,7 +529,7 @@ dependencies = [ [[package]] name = "coa-server-manager" -version = "0.6.12" +version = "0.6.13" dependencies = [ "coa-core", "serde", diff --git a/Cargo.toml b/Cargo.toml index 36f32b9..c012fd0 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.12" +version = "0.6.13" edition = "2021" license = "AGPL-3.0-only" diff --git a/changelog.d/20261010-automatic-update-recovery.md b/changelog.d/20261010-automatic-update-recovery.md new file mode 100644 index 0000000..0c3b056 --- /dev/null +++ b/changelog.d/20261010-automatic-update-recovery.md @@ -0,0 +1,7 @@ +--- +area: manager +type: changed +audience: admins +title: Failed server startup after an update automatically restores the previous version +--- +The Manager restores server files and databases, then checks the recovered server. If recovery cannot be confirmed, it preserves the recovery point and blocks further startup until the problem is resolved. diff --git a/changelog.d/20261010-release-compatibility.md b/changelog.d/20261010-release-compatibility.md new file mode 100644 index 0000000..f339e9a --- /dev/null +++ b/changelog.d/20261010-release-compatibility.md @@ -0,0 +1,7 @@ +--- +area: manager +type: added +audience: admins +title: Server updates can require a tested combination of platform and installed version +--- +Signed release metadata identifies the core and module revisions and supported starting versions. Incompatible update packages are rejected before the server is stopped. diff --git a/crates/coa-core/src/manifest.rs b/crates/coa-core/src/manifest.rs index b87dbe9..9700f02 100644 --- a/crates/coa-core/src/manifest.rs +++ b/crates/coa-core/src/manifest.rs @@ -78,9 +78,22 @@ pub struct Revision { pub commit: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Compatibility { + pub schema: u32, + pub platform: String, + pub core_commit: String, + pub module_commits: std::collections::BTreeMap, + pub client_patch_version: String, + pub source_versions: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Manifest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub compatibility: Option, pub schema: u32, pub kind: Kind, pub version: String, @@ -136,6 +149,18 @@ impl Manifest { if parse_version(&self.min_manager_version).is_none() { return bad(format!("bad minManagerVersion {:?}", self.min_manager_version)); } + if let Some(c) = &self.compatibility { + if parse_version(&self.min_manager_version).is_none_or(|v| v < (0, 6, 13)) { + return bad("Release compatibility matrices require Manager 0.6.13 or newer".into()); + } + if c.schema != 1 || !matches!(c.platform.as_str(), "windows-x86_64" | "linux-x86_64") + || !is_commit(&c.core_commit) || self.core.commit.as_deref() != Some(&c.core_commit) + || c.module_commits.is_empty() || c.module_commits.values().any(|sha| !is_commit(sha)) + || parse_version(&c.client_patch_version).is_none() + || c.source_versions.is_empty() || c.source_versions.iter().any(|v| parse_version(v).is_none()) { + return bad("Invalid release compatibility matrix".into()); + } + } 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)); @@ -232,6 +257,21 @@ mod tests { assert!(!m.compatible_with_manager("0.0.9")); } + #[test] + fn compatibility_matrix_binds_core_and_requires_tested_sources() { + let mut m = Manifest::parse(sample("").as_bytes()).unwrap(); + m.min_manager_version = "0.6.13".into(); + m.compatibility = Some(Compatibility { schema: 1, platform: "windows-x86_64".into(), + core_commit: "a".repeat(40), module_commits: [("squid".into(), "b".repeat(40))].into(), + client_patch_version: "1.5.1".into(), source_versions: vec!["0.261010.32".into()] }); + m.validate().unwrap(); + m.compatibility.as_mut().unwrap().core_commit = "c".repeat(40); + assert!(m.validate().is_err()); + m.compatibility.as_mut().unwrap().core_commit = "a".repeat(40); + m.compatibility.as_mut().unwrap().source_versions.clear(); + assert!(m.validate().is_err()); + } + #[test] fn rejects_traversal_bad_hash_duplicates_and_short_commit() { let ok = "b".repeat(64); diff --git a/crates/coa-core/src/package.rs b/crates/coa-core/src/package.rs index 26e54e8..50dafe6 100644 --- a/crates/coa-core/src/package.rs +++ b/crates/coa-core/src/package.rs @@ -217,6 +217,7 @@ pub fn build(src: &Path, out: &Path, opts: &BuildOptions, progress: &dyn Fn(&str split.close_current()?; let manifest = Manifest { + compatibility: None, schema: crate::manifest::SCHEMA, kind: opts.kind, version: opts.version.clone(), @@ -503,6 +504,7 @@ mod tests { fs::write(d.path().join(name), &bytes).unwrap(); let ok_sha = fsx::sha256_bytes(b"data"); let m = Manifest { + compatibility: None, schema: 1, kind: Kind::Base, version: "0.1.0".into(), diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index c90711f..2b415c4 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -85,6 +85,8 @@ fn legacy_databases_started() -> bool { true } /// Everything the transaction needs from the outside world; tests provide a fake. pub trait Env { + fn automatic_rollback(&self) -> bool { false } + fn validate_recovery(&self) -> Result<()> { self.validate() } fn ensure_stopped(&self) -> Result<()>; fn preflight(&self, _manifest: &Manifest) -> Result<()> { Ok(()) } fn verify_snapshot(&self, _id: &str) -> Result<()> { Ok(()) } @@ -360,6 +362,15 @@ fn launcher_already_integrated(root: &Path, meta_dir: &Path, meta: &InstallMeta, } fn reject_downgrade(meta: &InstallMeta, manifest: &Manifest) -> Result<()> { + if let Some(c) = &manifest.compatibility { + let platform = if cfg!(windows) { "windows-x86_64" } else { "linux-x86_64" }; + if c.platform != platform { + return Err(Error::Invalid(format!("This package targets {} rather than {platform}.", c.platform))); + } + if meta.core.version.as_ref().is_none_or(|version| !c.source_versions.contains(version)) { + return Err(Error::Invalid("This server version is outside the release's tested compatibility matrix.".into())); + } + } 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 { @@ -380,6 +391,11 @@ fn check_manifest(m: &Manifest) -> Result<()> { Ok(()) } +pub fn validate_candidate(meta: &InstallMeta, manifest: &Manifest) -> Result<()> { + check_manifest(manifest)?; + reject_downgrade(meta, manifest) +} + pub struct Params<'a> { pub root: &'a Path, pub meta_dir: &'a Path, @@ -515,6 +531,10 @@ pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result { step(report, "Starting the updated server", 85); if let Err(e) = p.env.validate() { + if p.env.automatic_rollback() { + return fail_after_apply(p, &mut txn, &before, &tree, + format!("The updated server did not start correctly: {e}"), migrated); + } txn.state = State::NeedsDecision; txn.message = Some(format!("The updated server did not start correctly: {e}")); save(meta_dir, &txn)?; @@ -599,7 +619,10 @@ pub fn retry_validation(root: &Path, meta_dir: &Path, id: &str, fallback: &Sourc fn fail_after_apply(p: &Params, txn: &mut Txn, before: &Path, tree: &Path, why: String, report: Option) -> Result { tracing::error!(transaction = %txn.id, databases_started = txn.databases_started, "server update failed; restoring recovery point"); - let restored = restore_transaction(p.root, p.meta_dir, before, txn, p.env); + let restored = restore_transaction(p.root, p.meta_dir, before, txn, p.env).and_then(|()| { + if p.env.automatic_rollback() { p.env.validate_recovery() } else { Ok(()) } + }); + if restored.is_err() { let _ = p.env.ensure_stopped(); } tracing::info!(transaction = %txn.id, recovered = restored.is_ok(), "server update recovery finished"); txn.state = if restored.is_ok() { State::RolledBack } else { State::Failed }; txn.message = Some(match &restored { Ok(()) => why.clone(), Err(e) => format!("{why} Recovery failed: {e}") }); @@ -839,6 +862,7 @@ impl RepackEnv<'_> { } impl Env for RepackEnv<'_> { + fn automatic_rollback(&self) -> bool { true } fn preflight(&self, manifest: &Manifest) -> Result<()> { self.pending_migrations(manifest).map(|_| ()) } @@ -983,6 +1007,8 @@ mod tests { use std::cell::{Cell, RefCell}; struct Fake { + automatic_rollback: Cell, + recovery_healthy: Cell, stopped: Cell, healthy: Cell, snapshot_ok: Cell, @@ -994,11 +1020,16 @@ mod tests { 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), preflight_fail: Cell::new(false), restore_fail: Cell::new(false) } + Fake { automatic_rollback: Cell::new(false), recovery_healthy: Cell::new(true), 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 automatic_rollback(&self) -> bool { self.automatic_rollback.get() } + fn validate_recovery(&self) -> Result<()> { + self.calls.borrow_mut().push("validate-recovery"); + if self.recovery_healthy.get() { Ok(()) } else { Err(Error::Invalid("Restored server is unhealthy".into())) } + } fn preflight(&self, _: &Manifest) -> Result<()> { if self.preflight_fail.get() { Err(Error::Invalid("Conflicting migration history".into())) } else { Ok(()) } } @@ -1304,6 +1335,56 @@ mod tests { assert!(rollback(&w.root, &w.meta, &out.txn.id, &env).is_err(), "already rolled back"); } + #[test] + fn automatic_health_failure_restores_databases_files_and_checks_recovery() { + let w = world(&[], true); + let env = Fake::ok(); + env.automatic_rollback.set(true); + env.healthy.set(false); + assert!(run(&w, &env, BTreeMap::new(), None).unwrap_err().to_string().contains("restored")); + assert_eq!(read(&w, "Core/worldserver.exe"), b"world-v1"); + assert!(!w.root.join("Core/newfile.dll").exists()); + assert!(env.calls.borrow().contains(&"restore-databases")); + assert!(env.calls.borrow().contains(&"validate-recovery")); + assert!(unfinished(&w.meta).is_none()); + } + + #[test] + fn incompatible_source_or_platform_is_rejected_without_touching_installation() { + let w = world(&[], false); + let (_, meta) = MetaDir::open(&w.meta).unwrap(); + let mut manifest = Manifest::parse(&fs::read(w.pkg.join("manifest.json")).unwrap()).unwrap(); + manifest.min_manager_version = "0.6.13".into(); + manifest.core.commit = Some("a".repeat(40)); + manifest.compatibility = Some(crate::manifest::Compatibility { schema: 1, + platform: if cfg!(windows) { "windows-x86_64" } else { "linux-x86_64" }.into(), + core_commit: "a".repeat(40), module_commits: [("squid".into(), "b".repeat(40))].into(), + client_patch_version: "1.5.1".into(), source_versions: vec!["1.0.0".into()] }); + manifest.validate().unwrap(); + validate_candidate(&meta, &manifest).unwrap(); + manifest.compatibility.as_mut().unwrap().source_versions = vec!["0.9.0".into()]; + assert!(validate_candidate(&meta, &manifest).unwrap_err().to_string().contains("compatibility matrix")); + manifest.compatibility.as_mut().unwrap().source_versions = vec!["1.0.0".into()]; + manifest.compatibility.as_mut().unwrap().platform = if cfg!(windows) { "linux-x86_64" } else { "windows-x86_64" }.into(); + assert!(validate_candidate(&meta, &manifest).unwrap_err().to_string().contains("targets")); + assert_eq!(read(&w, "Core/worldserver.exe"), b"world-v1"); + assert!(unfinished(&w.meta).is_none()); + } + + #[test] + fn unhealthy_restoration_preserves_recovery_and_blocks_startup() { + let w = world(&[], true); + let env = Fake::ok(); + env.automatic_rollback.set(true); + env.healthy.set(false); + env.recovery_healthy.set(false); + assert!(run(&w, &env, BTreeMap::new(), None).unwrap_err().to_string().contains("Recovery failed")); + let pending = unfinished(&w.meta).unwrap(); + assert_eq!(pending.state, State::Failed); + assert!(txn_dir(&w.meta, &pending.id).unwrap().join("before").exists()); + assert!(ensure_recovered(&w.meta).is_err()); + } + #[test] fn retrying_startup_commits_without_reapplying_sql_or_restoring_databases() { let w = world(&[], true); diff --git a/package-lock.json b/package-lock.json index 0956d7a..449c0ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coa-server-manager", - "version": "0.6.12", + "version": "0.6.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coa-server-manager", - "version": "0.6.12", + "version": "0.6.13", "dependencies": { "@tauri-apps/api": "^2.12.0", "@tauri-apps/plugin-dialog": "^2.8.0", diff --git a/package.json b/package.json index 6a3d5fa..0265fc9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coa-server-manager", "private": true, - "version": "0.6.12", + "version": "0.6.13", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1110b03..7dc9d80 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -831,7 +831,9 @@ async fn apply_update( let _guard = BusyGuard::acquire(&state, &id)?; blocking(move || { let src = update_source(source)?; - let (dir, _) = install_meta(&root)?; + let (dir, meta) = install_meta(&root)?; + let (manifest, _) = coa_core::pkgsource::fetch_manifest(&src, coa_core::signing::EMBEDDED_PUBLIC_KEY)?; + update::validate_candidate(&meta, &manifest)?; // Files cannot be replaced while the server runs: stop it first (gracefully), like the Stop button. let observed = coa_core::process::observe(&root, &layout::read_ports(&root)); if observed.world.state != coa_core::process::ServiceState::Stopped || observed.auth.state != coa_core::process::ServiceState::Stopped || coa_core::multiworld::is_running(&root) { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index fb36d12..43df25d 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.12", + "version": "0.6.13", "identifier": "com.coa.servermanager", "build": { "beforeDevCommand": "npm run dev", From 5b168427203bb23a1fdcbcca214678acbe1eba30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 10 Oct 2026 12:34:48 +0500 Subject: [PATCH 05/17] Isolate update startup checks and recover validation configurations --- .../20261010-isolated-update-validation.md | 7 + crates/coa-core/src/lib.rs | 1 + crates/coa-core/src/update.rs | 173 ++++++++++++------ crates/coa-core/src/update_isolation.rs | 163 +++++++++++++++++ 4 files changed, 292 insertions(+), 52 deletions(-) create mode 100644 changelog.d/20261010-isolated-update-validation.md create mode 100644 crates/coa-core/src/update_isolation.rs diff --git a/changelog.d/20261010-isolated-update-validation.md b/changelog.d/20261010-isolated-update-validation.md new file mode 100644 index 0000000..7c9a14e --- /dev/null +++ b/changelog.d/20261010-isolated-update-validation.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Server updates check startup privately before allowing players to connect +--- +Startup checks use temporary local ports. Original settings are restored before opening the server, and interrupted checks retain a recovery journal. diff --git a/crates/coa-core/src/lib.rs b/crates/coa-core/src/lib.rs index 18d2b35..745faba 100644 --- a/crates/coa-core/src/lib.rs +++ b/crates/coa-core/src/lib.rs @@ -44,6 +44,7 @@ pub mod release; pub mod report; pub mod release_schema; pub mod update; +mod update_isolation; pub mod wine; pub mod upnp; pub mod signing; diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index 2b415c4..02c5705 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -85,6 +85,8 @@ fn legacy_databases_started() -> bool { true } /// Everything the transaction needs from the outside world; tests provide a fake. pub trait Env { + fn activate(&self) -> Result<()> { Ok(()) } + fn recover_validation(&self) -> Result<()> { Ok(()) } fn automatic_rollback(&self) -> bool { false } fn validate_recovery(&self) -> Result<()> { self.validate() } fn ensure_stopped(&self) -> Result<()>; @@ -179,6 +181,9 @@ pub fn unfinished(meta: &Path) -> Option { } pub fn ensure_recovered(meta: &Path) -> Result<()> { + if crate::update_isolation::pending(meta) { + return Err(Error::Invalid("Interrupted startup validation must be recovered before starting the server.".into())); + } if let Some(t) = pending_checked(meta)? { return Err(Error::Invalid(format!("Resolve unfinished update {} before changing or starting the server.", t.id))); } @@ -542,6 +547,7 @@ pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result { } finish(root, meta_dir, &mut meta, &m, &tree, &mut txn)?; + p.env.activate()?; step(report, "Done", 100); let _ = archive; Ok(Outcome { txn, migrations: migrated }) @@ -614,6 +620,7 @@ pub fn retry_validation(root: &Path, meta_dir: &Path, id: &str, fallback: &Sourc } let (_, mut meta) = MetaDir::open(meta_dir)?; finish(root, meta_dir, &mut meta, &manifest, &dir.join("tree"), &mut txn)?; + env.activate()?; Ok(txn) } @@ -627,7 +634,10 @@ fn fail_after_apply(p: &Params, txn: &mut Txn, before: &Path, tree: &Path, why: txn.state = if restored.is_ok() { State::RolledBack } else { State::Failed }; txn.message = Some(match &restored { Ok(()) => why.clone(), Err(e) => format!("{why} Recovery failed: {e}") }); save(p.meta_dir, txn)?; - if restored.is_ok() { let _ = fs::remove_dir_all(tree); } + if restored.is_ok() { + let _ = fs::remove_dir_all(tree); + if p.env.automatic_rollback() { p.env.activate()?; } + } let _ = report; Err(Error::Invalid(match restored { Ok(()) if txn.databases_started => format!("{why} The server files and databases were restored to the recovery point."), @@ -651,6 +661,7 @@ fn restore_transaction(root: &Path, meta: &Path, before: &Path, txn: &mut Txn, e } 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()?; + env.recover_validation()?; if txn.databases_started { let id = txn.recovery_point.as_deref().ok_or_else(|| Error::Invalid("The database recovery point is missing.".into()))?; env.restore_snapshot(id)?; @@ -862,6 +873,18 @@ impl RepackEnv<'_> { } impl Env for RepackEnv<'_> { + fn recover_validation(&self) -> Result<()> { + crate::update_isolation::restore(self.root, self.meta_dir) + } + + fn activate(&self) -> Result<()> { + ensure_recovered(self.meta_dir)?; + let outcome = crate::driver::validate_update(self.root)?; + if outcome.ok { Ok(()) } else { + Err(Error::Invalid(format!("The update transaction finished, but the server could not start: {}", crate::driver::startup_failure(self.root, &outcome)))) + } + } + fn automatic_rollback(&self) -> bool { true } fn preflight(&self, manifest: &Manifest) -> Result<()> { self.pending_migrations(manifest).map(|_| ()) @@ -940,61 +963,76 @@ impl Env for RepackEnv<'_> { } fn validate(&self) -> Result<()> { - use crate::process::{observe, ServiceState}; - crate::backup::with_database(self.root, |db| { - let mut modes = vec![crate::realms::Mode::Coa]; - if crate::realms::state(self.root)?.wildcard_created { modes.push(crate::realms::Mode::Wildcard); } - for mode in modes { - if let Some(p) = crate::schema_check::check(&db.clone().for_realm(mode), self.root)?.first() { - return Err(Error::Invalid(format!("Database validation failed on {}: {}.{}: {}", mode.name(), p.database, p.table, p.detail))); + let stopped = crate::driver::run(self.root, crate::driver::Verb::StopAll)?; + if !stopped.ok { return Err(Error::Invalid("Could not stop the server before isolated validation.".into())); } + self.ensure_stopped()?; + self.recover_validation()?; + let result = (|| -> Result<()> { + crate::update_isolation::begin(self.root, self.meta_dir)?; + use crate::process::{observe, ServiceState}; + crate::backup::with_database(self.root, |db| { + let mut modes = vec![crate::realms::Mode::Coa]; + if crate::realms::state(self.root)?.wildcard_created { modes.push(crate::realms::Mode::Wildcard); } + for mode in modes { + if let Some(p) = crate::schema_check::check(&db.clone().for_realm(mode), self.root)?.first() { + return Err(Error::Invalid(format!("Database validation failed on {}: {}.{}: {}", mode.name(), p.database, p.table, p.detail))); + } + } + Ok(()) + })?; + // The check start is the first start of the new build: give the module configs the settings the update added + // (as a normal start does), or the server logs a "missing property" line for every one of them. + match crate::registry::MetaDir::open(self.meta_dir) { + Ok((_, meta)) if meta.kind == crate::registry::InstallKind::New => { + let _ = crate::config::materialize_module_configs(self.root); + } + // An imported server gets the files it lacks (nothing it has is changed). + _ => { + let _ = crate::config::create_missing_module_configs(self.root); } } - Ok(()) - })?; - // The check start is the first start of the new build: give the module configs the settings the update added - // (as a normal start does), or the server logs a "missing property" line for every one of them. - match crate::registry::MetaDir::open(self.meta_dir) { - Ok((_, meta)) if meta.kind == crate::registry::InstallKind::New => { - let _ = crate::config::materialize_module_configs(self.root); - } - // An imported server gets the files it lacks (nothing it has is changed). - _ => { - let _ = crate::config::create_missing_module_configs(self.root); + let started = match crate::driver::validate_update(self.root) { + Ok(out) => out, + Err(e) => { + let _ = crate::driver::run(self.root, crate::driver::Verb::StopAll); + return Err(e); + } + }; + let ports = crate::layout::read_ports(self.root); + let healthy = started.ok && { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(180); + let mut ready_since = None; + loop { + let o = observe(self.root, &ports); + if o.mysql.state == ServiceState::Running && o.auth.state == ServiceState::Running && o.world.state == ServiceState::Running && o.secondary_world.as_ref().is_none_or(|s| s.state == ServiceState::Running) { + let since = ready_since.get_or_insert_with(std::time::Instant::now); + if since.elapsed() >= std::time::Duration::from_secs(10) { break true; } + } else { + ready_since = None; + } + if std::time::Instant::now() > deadline { + break false; + } + std::thread::sleep(std::time::Duration::from_secs(2)); + } + }; + if healthy { + return Ok(()); } + let cause = crate::driver::startup_failure(self.root, &started); + // Leave the files unlocked so that a rollback can replace them. + let _ = crate::driver::run(self.root, crate::driver::Verb::StopAll); + Err(Error::Invalid(cause)) + })(); + let stopped = crate::driver::run(self.root, crate::driver::Verb::StopAll)?; + if !stopped.ok { + return Err(Error::Invalid("Isolated validation could not stop the server; recovery configurations were preserved.".into())); } - let started = match crate::driver::validate_update(self.root) { - Ok(out) => out, - Err(e) => { - let _ = crate::driver::run(self.root, crate::driver::Verb::StopAll); - return Err(e); - } - }; - let ports = crate::layout::read_ports(self.root); - let healthy = started.ok && { - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(180); - let mut ready_since = None; - loop { - let o = observe(self.root, &ports); - if o.mysql.state == ServiceState::Running && o.auth.state == ServiceState::Running && o.world.state == ServiceState::Running && o.secondary_world.as_ref().is_none_or(|s| s.state == ServiceState::Running) { - let since = ready_since.get_or_insert_with(std::time::Instant::now); - if since.elapsed() >= std::time::Duration::from_secs(10) { break true; } - } else { - ready_since = None; - } - if std::time::Instant::now() > deadline { - break false; - } - std::thread::sleep(std::time::Duration::from_secs(2)); - } - }; - if healthy { - return Ok(()); - } - let cause = crate::driver::startup_failure(self.root, &started); - // Leave the files unlocked so that a rollback can replace them. - let _ = crate::driver::run(self.root, crate::driver::Verb::StopAll); - Err(Error::Invalid(cause)) + self.ensure_stopped()?; + self.recover_validation()?; + result } + } #[cfg(test)] @@ -1007,6 +1045,9 @@ mod tests { use std::cell::{Cell, RefCell}; struct Fake { + activation_expected: RefCell>, + activation_failed: Cell, + activated: Cell, automatic_rollback: Cell, recovery_healthy: Cell, stopped: Cell, @@ -1020,11 +1061,19 @@ mod tests { impl Fake { fn ok() -> Fake { - Fake { automatic_rollback: Cell::new(false), recovery_healthy: Cell::new(true), 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) } + Fake { activation_expected: RefCell::new(None), activation_failed: Cell::new(false), activated: Cell::new(false), automatic_rollback: Cell::new(false), recovery_healthy: Cell::new(true), 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 activate(&self) -> Result<()> { + if let Some((meta, version)) = self.activation_expected.borrow().as_ref() { + ensure_recovered(meta)?; + assert_eq!(MetaDir::open(meta)?.1.core.version.as_ref(), Some(version)); + } + self.activated.set(true); + if self.activation_failed.get() { Err(Error::Invalid("Public startup failed".into())) } else { Ok(()) } + } fn automatic_rollback(&self) -> bool { self.automatic_rollback.get() } fn validate_recovery(&self) -> Result<()> { self.calls.borrow_mut().push("validate-recovery"); @@ -1226,6 +1275,26 @@ mod tests { assert!(!w.root.join("Core/worldserver.exe.coa-new").exists()); } + #[test] + fn public_startup_requires_a_completed_transaction_and_saved_version() { + let w = world(&[], false); + let env = Fake::ok(); + *env.activation_expected.borrow_mut() = Some((w.meta.clone(), "2.0.0".into())); + run(&w, &env, BTreeMap::new(), None).unwrap(); + assert!(env.activated.get()); + } + + #[test] + fn startup_failure_after_commit_does_not_restore_a_live_database() { + let w = world(&[], true); + let env = Fake::ok(); + env.activation_failed.set(true); + assert!(run(&w, &env, BTreeMap::new(), None).is_err()); + assert!(ensure_recovered(&w.meta).is_ok()); + assert_eq!(MetaDir::open(&w.meta).unwrap().1.core.version.as_deref(), Some("2.0.0")); + assert!(!env.calls.borrow().contains(&"restore-databases")); + } + #[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"; diff --git a/crates/coa-core/src/update_isolation.rs b/crates/coa-core/src/update_isolation.rs new file mode 100644 index 0000000..2c18f1f --- /dev/null +++ b/crates/coa-core/src/update_isolation.rs @@ -0,0 +1,163 @@ +use std::{collections::BTreeMap, fs, path::Path}; +use serde::{Deserialize, Serialize}; +use crate::{config::parser::ConfFile, fsx, Error, Result}; + +const FILES: &[&str] = &[ + "Settings/repack.json", "Settings/realm-profile.json", + "Settings/worldserver.conf.template", "Settings/authserver.conf.template", + "Core/configs/worldserver.conf", "Core/configs/authserver.conf", + "Settings/realm-profiles/coa.json", "Settings/realm-profiles/wildcard.json", + ".realms/secondary/Settings/repack.json", ".realms/secondary/Settings/realm-profile.json", + ".realms/secondary/Settings/worldserver.conf.template", ".realms/secondary/Settings/authserver.conf.template", + ".realms/secondary/Core/configs/worldserver.conf", ".realms/secondary/Core/configs/authserver.conf", +]; + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Saved { bytes: Option>, sha256: Option } + +pub(crate) fn pending(meta: &Path) -> bool { meta.join("validation-configs.json").exists() } + +pub(crate) fn begin(root: &Path, meta: &Path) -> Result<()> { + if pending(meta) { return Err(Error::Invalid("Interrupted isolated validation must be recovered first.".into())); } + let mut saved = BTreeMap::new(); + for rel in FILES { + let path = fsx::ensure_within(root, &fsx::safe_join(root, rel)?)?; + let bytes = if path.exists() { Some(fs::read(path)?) } else { None }; + let sha256 = bytes.as_ref().map(|b| fsx::sha256_bytes(b)); + saved.insert(*rel, Saved { bytes, sha256 }); + } + let mut settings: serde_json::Value = fsx::read_json(&root.join("Settings/repack.json"))?; + let mut realm: Option = if root.join("Settings/realm-profile.json").exists() { + Some(fsx::read_json(&root.join("Settings/realm-profile.json"))?) + } else { None }; + let mut excluded: std::collections::BTreeSet = ["mysqlPort", "authPort", "worldPort", "raPort"].iter() + .filter_map(|key| settings[*key].as_u64()).collect(); + if let Some(state) = &realm { + excluded.extend(["secondary_world_port", "secondary_ra_port"].iter().filter_map(|key| state[*key].as_u64())); + } + let mut sockets = Vec::new(); + let mut reserve = || -> Result { + loop { + let socket = std::net::TcpListener::bind("127.0.0.1:0")?; + let port = socket.local_addr()?.port(); + if excluded.insert(u64::from(port)) { + sockets.push(socket); + return Ok(port); + } + } + }; + for key in ["authPort", "worldPort", "raPort"] { settings[key] = reserve()?.into(); } + if let Some(state) = &mut realm { + for key in ["secondary_world_port", "secondary_ra_port"] { + state[key] = reserve()?.into(); + } + } + let mut configs = Vec::new(); + for rel in ["Settings/worldserver.conf.template", "Settings/authserver.conf.template", "Core/configs/worldserver.conf", "Core/configs/authserver.conf"] { + let path = root.join(rel); + if !path.exists() && rel.ends_with(".template") { + return Err(Error::Invalid(format!("Cannot isolate validation without {rel}."))); + } + if path.exists() { + let mut conf = ConfFile::parse_bytes(&fs::read(&path)?)?; + conf.set("BindIP", "\"127.0.0.1\"", &[]); + if rel.contains("worldserver") { conf.set("Ra.IP", "\"127.0.0.1\"", &[]); } + configs.push((path, conf.to_text())); + } + } + fsx::atomic_write_json(&meta.join("validation-configs.json"), &saved)?; + fsx::atomic_write_json(&root.join("Settings/repack.json"), &settings)?; + if let Some(state) = realm { fsx::atomic_write_json(&root.join("Settings/realm-profile.json"), &state)?; } + for (path, text) in configs { fsx::atomic_write(&path, text.as_bytes())?; } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("server"); + let meta = dir.path().join("metadata"); + fs::create_dir_all(root.join("Settings")).unwrap(); + fs::create_dir_all(&meta).unwrap(); + fsx::atomic_write_json(&root.join(FILES[0]), &serde_json::json!({"mysqlPort":3306,"authPort":3724,"worldPort":8085,"raPort":3443})).unwrap(); + fsx::atomic_write_json(&root.join(FILES[1]), &serde_json::json!({"simultaneous":true,"secondary_world_port":8086,"secondary_ra_port":3444})).unwrap(); + for rel in [FILES[2], FILES[3]] { + fsx::atomic_write(&root.join(rel), b"BindIP = \"0.0.0.0\"\r\nRa.IP = \"0.0.0.0\"\r\n").unwrap(); + } + (dir, root, meta) + } + + #[test] + fn interrupted_validation_restores_original_bytes_and_absent_files() { + let (_dir, root, meta) = fixture(); + let original: Vec<_> = FILES.iter().map(|p| fs::read(root.join(p)).ok()).collect(); + begin(&root, &meta).unwrap(); + assert!(crate::update::ensure_recovered(&meta).is_err()); + assert!(begin(&root, &meta).is_err()); + let settings: serde_json::Value = fsx::read_json(&root.join(FILES[0])).unwrap(); + assert_eq!(settings["mysqlPort"], 3306); + let realm: serde_json::Value = fsx::read_json(&root.join(FILES[1])).unwrap(); + let ports: std::collections::BTreeSet<_> = [settings["authPort"].as_u64(), settings["worldPort"].as_u64(), settings["raPort"].as_u64(), realm["secondary_world_port"].as_u64(), realm["secondary_ra_port"].as_u64()].into_iter().collect(); + assert_eq!(ports.len(), 5); + let config = ConfFile::parse_bytes(&fs::read(root.join(FILES[2])).unwrap()).unwrap(); + assert_eq!(config.get("BindIP"), Some("\"127.0.0.1\"")); + fsx::atomic_write(&root.join(FILES[4]), b"rendered temporary configuration").unwrap(); + restore(&root, &meta).unwrap(); + for (rel, bytes) in FILES.iter().zip(original) { assert_eq!(fs::read(root.join(rel)).ok(), bytes); } + assert!(!pending(&meta)); + restore(&root, &meta).unwrap(); + } + + #[test] + fn damaged_recovery_copy_blocks_all_restoration() { + let (_dir, root, meta) = fixture(); + begin(&root, &meta).unwrap(); + let journal = meta.join("validation-configs.json"); + let mut saved: BTreeMap = fsx::read_json(&journal).unwrap(); + saved.get_mut(FILES[0]).unwrap().bytes = Some(b"damaged".to_vec()); + fsx::atomic_write_json(&journal, &saved).unwrap(); + let before = fs::read(root.join(FILES[2])).unwrap(); + assert!(restore(&root, &meta).is_err()); + assert_eq!(fs::read(root.join(FILES[2])).unwrap(), before); + assert!(pending(&meta)); + } + + #[test] + fn missing_template_fails_before_any_configuration_changes() { + let (_dir, root, meta) = fixture(); + fs::remove_file(root.join(FILES[3])).unwrap(); + let before = fs::read(root.join(FILES[0])).unwrap(); + assert!(begin(&root, &meta).is_err()); + assert!(!pending(&meta)); + assert_eq!(fs::read(root.join(FILES[0])).unwrap(), before); + } +} + +pub(crate) fn restore(root: &Path, meta: &Path) -> Result<()> { + if !pending(meta) { return Ok(()); } + let journal = meta.join("validation-configs.json"); + let saved: BTreeMap = fsx::read_json(&journal)?; + if saved.len() != FILES.len() || FILES.iter().any(|rel| !saved.contains_key(*rel)) { + return Err(Error::Invalid("Isolated validation configuration journal is incomplete.".into())); + } + for (rel, file) in &saved { + fsx::ensure_within(root, &fsx::safe_join(root, rel)?)?; + if file.bytes.as_ref().map(|b| fsx::sha256_bytes(b)) != file.sha256 { + return Err(Error::Invalid(format!("Isolated validation recovery copy {rel} is damaged."))); + } + } + for (rel, file) in saved { + let path = fsx::ensure_within(root, &fsx::safe_join(root, &rel)?)?; + match file.bytes { + Some(bytes) => fsx::atomic_write(&path, &bytes)?, + None if path.exists() => fs::remove_file(path)?, + None => {}, + } + } + fs::remove_file(journal)?; + Ok(()) +} From 40f9feab84b1d2979294471412f52548f949af03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 10 Oct 2026 12:57:11 +0500 Subject: [PATCH 06/17] Bind upgrade compatibility to signed database source contracts --- changelog.d/20261010-release-compatibility.md | 2 +- crates/coa-core/src/manifest.rs | 53 ++++++++++++++++++- crates/coa-core/src/update.rs | 42 ++++++++++++++- crates/coa-release/src/main.rs | 10 ++++ 4 files changed, 103 insertions(+), 4 deletions(-) diff --git a/changelog.d/20261010-release-compatibility.md b/changelog.d/20261010-release-compatibility.md index f339e9a..ba91b80 100644 --- a/changelog.d/20261010-release-compatibility.md +++ b/changelog.d/20261010-release-compatibility.md @@ -4,4 +4,4 @@ type: added audience: admins title: Server updates can require a tested combination of platform and installed version --- -Signed release metadata identifies the core and module revisions and supported starting versions. Incompatible update packages are rejected before the server is stopped. +Signed release metadata identifies core and module revisions, supported starting versions and database contracts. Incompatible packages are rejected before shutdown; changed schema contracts and incompatible databases block installation before migration. diff --git a/crates/coa-core/src/manifest.rs b/crates/coa-core/src/manifest.rs index 9700f02..7cbd4a9 100644 --- a/crates/coa-core/src/manifest.rs +++ b/crates/coa-core/src/manifest.rs @@ -78,6 +78,13 @@ pub struct Revision { pub commit: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SourceDatabase { + pub manifest_sha256: String, + pub schema_sha256: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Compatibility { @@ -87,6 +94,8 @@ pub struct Compatibility { pub module_commits: std::collections::BTreeMap, pub client_patch_version: String, pub source_versions: Vec, + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub source_databases: std::collections::BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -156,10 +165,18 @@ impl Manifest { if c.schema != 1 || !matches!(c.platform.as_str(), "windows-x86_64" | "linux-x86_64") || !is_commit(&c.core_commit) || self.core.commit.as_deref() != Some(&c.core_commit) || c.module_commits.is_empty() || c.module_commits.values().any(|sha| !is_commit(sha)) + || self.bots.as_ref().and_then(|r| r.commit.as_ref()).is_some_and(|sha| c.module_commits.get("bots") != Some(sha)) || parse_version(&c.client_patch_version).is_none() - || c.source_versions.is_empty() || c.source_versions.iter().any(|v| parse_version(v).is_none()) { + || c.source_versions.is_empty() || c.source_versions.iter().any(|v| parse_version(v).is_none()) + || c.source_versions.iter().collect::>().len() != c.source_versions.len() { return bad("Invalid release compatibility matrix".into()); } + if !c.source_databases.is_empty() && (c.source_databases.len() != c.source_versions.len() + || c.source_versions.iter().any(|v| !c.source_databases.contains_key(v)) + || c.source_databases.values().any(|s| !is_sha256(&s.manifest_sha256) + || s.schema_sha256.as_ref().is_some_and(|sha| !is_sha256(sha)))) { + return bad("Invalid source database compatibility".into()); + } } for migration in &self.migrations { if !is_sha256(&migration.sha256) || migration.compatible_sha256.iter().any(|h| !is_sha256(h) || h == &"0".repeat(64)) { @@ -263,7 +280,7 @@ mod tests { m.min_manager_version = "0.6.13".into(); m.compatibility = Some(Compatibility { schema: 1, platform: "windows-x86_64".into(), core_commit: "a".repeat(40), module_commits: [("squid".into(), "b".repeat(40))].into(), - client_patch_version: "1.5.1".into(), source_versions: vec!["0.261010.32".into()] }); + source_databases: Default::default(), client_patch_version: "1.5.1".into(), source_versions: vec!["0.261010.32".into()] }); m.validate().unwrap(); m.compatibility.as_mut().unwrap().core_commit = "c".repeat(40); assert!(m.validate().is_err()); @@ -272,6 +289,38 @@ mod tests { assert!(m.validate().is_err()); } + #[test] + fn compatibility_cannot_claim_a_different_packaged_bot_revision() { + let mut m = Manifest::parse(sample("").as_bytes()).unwrap(); + m.min_manager_version = "0.6.13".into(); + m.bots = Some(Revision { commit: Some("b".repeat(40)) }); + m.compatibility = Some(Compatibility { schema: 1, platform: "windows-x86_64".into(), + core_commit: "a".repeat(40), module_commits: [("bots".into(), "c".repeat(40))].into(), + source_databases: Default::default(), client_patch_version: "1.5.1".into(), source_versions: vec!["0.2.0".into()] }); + assert!(m.validate().is_err()); + m.compatibility.as_mut().unwrap().module_commits.insert("bots".into(), "b".repeat(40)); + m.validate().unwrap(); + m.compatibility.as_mut().unwrap().source_versions.push("0.2.0".into()); + assert!(m.validate().is_err()); + } + + #[test] + fn source_database_contracts_must_cover_the_exact_upgrade_versions() { + let mut m = Manifest::parse(sample("").as_bytes()).unwrap(); + m.min_manager_version = "0.6.13".into(); + m.compatibility = Some(Compatibility { schema: 1, platform: "windows-x86_64".into(), + core_commit: "a".repeat(40), module_commits: [("squid".into(), "b".repeat(40))].into(), + client_patch_version: "1.5.1".into(), source_versions: vec!["0.2.0".into()], + source_databases: [("0.2.0".into(), SourceDatabase { manifest_sha256: "a".repeat(64), schema_sha256: None })].into() }); + m.validate().unwrap(); + m.compatibility.as_mut().unwrap().source_databases.get_mut("0.2.0").unwrap().schema_sha256 = Some("bad".into()); + assert!(m.validate().is_err()); + let c = m.compatibility.as_mut().unwrap(); + c.source_databases.get_mut("0.2.0").unwrap().schema_sha256 = None; + c.source_versions = vec!["0.2.1".into()]; + assert!(m.validate().is_err()); + } + #[test] fn rejects_traversal_bad_hash_duplicates_and_short_commit() { let ok = "b".repeat(64); diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index 02c5705..6ab2cac 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -887,6 +887,27 @@ impl Env for RepackEnv<'_> { fn automatic_rollback(&self) -> bool { true } fn preflight(&self, manifest: &Manifest) -> Result<()> { + if let Some(compatibility) = &manifest.compatibility { + let (_, meta) = MetaDir::open(self.meta_dir)?; + if let Some(source) = meta.core.version.as_ref().and_then(|v| compatibility.source_databases.get(v)) { + if let Some(expected) = &source.schema_sha256 { + let contract = self.root.join("Scripts/database-schema.json"); + if fsx::sha256_file(&contract)? != *expected { + return Err(Error::Invalid("The installed database schema contract differs from the qualified upgrade source; repair the installation first.".into())); + } + } + crate::backup::with_database(self.root, |db| { + let mut modes = vec![crate::realms::Mode::Coa]; + if crate::realms::state(self.root)?.wildcard_created { modes.push(crate::realms::Mode::Wildcard); } + for mode in modes { + if let Some(problem) = crate::schema_check::check(&db.clone().for_realm(mode), self.root)?.first() { + return Err(Error::Invalid(format!("The installed {} database does not match its upgrade source: {}.{}: {}", mode.name(), problem.database, problem.table, problem.detail))); + } + } + Ok(()) + })?; + } + } self.pending_migrations(manifest).map(|_| ()) } @@ -1428,7 +1449,7 @@ mod tests { manifest.compatibility = Some(crate::manifest::Compatibility { schema: 1, platform: if cfg!(windows) { "windows-x86_64" } else { "linux-x86_64" }.into(), core_commit: "a".repeat(40), module_commits: [("squid".into(), "b".repeat(40))].into(), - client_patch_version: "1.5.1".into(), source_versions: vec!["1.0.0".into()] }); + source_databases: Default::default(), client_patch_version: "1.5.1".into(), source_versions: vec!["1.0.0".into()] }); manifest.validate().unwrap(); validate_candidate(&meta, &manifest).unwrap(); manifest.compatibility.as_mut().unwrap().source_versions = vec!["0.9.0".into()]; @@ -1440,6 +1461,25 @@ mod tests { assert!(unfinished(&w.meta).is_none()); } + #[test] + fn changed_installed_schema_contract_blocks_before_database_access() { + let w = world(&[], false); + write(&w.root, "Scripts/database-schema.json", b"modified schema contract"); + let mut manifest = Manifest::parse(&fs::read(w.pkg.join("manifest.json")).unwrap()).unwrap(); + manifest.compatibility = Some(crate::manifest::Compatibility { schema: 1, + platform: "windows-x86_64".into(), core_commit: "a".repeat(40), + module_commits: [("squid".into(), "b".repeat(40))].into(), client_patch_version: "1.5.1".into(), + source_versions: vec!["1.0.0".into()], + source_databases: [("1.0.0".into(), crate::manifest::SourceDatabase { + manifest_sha256: "a".repeat(64), schema_sha256: Some("b".repeat(64)) })].into() }); + let env = RepackEnv { root: &w.root, meta_dir: &w.meta }; + let error = env.preflight(&manifest).unwrap_err().to_string(); + assert!(error.contains("schema contract differs"), "{error}"); + assert!(!w.root.join("Runtime/python/python.exe").exists()); + assert_eq!(read(&w, "Core/worldserver.exe"), b"world-v1"); + assert!(unfinished(&w.meta).is_none()); + } + #[test] fn unhealthy_restoration_preserves_recovery_and_blocks_startup() { let w = world(&[], true); diff --git a/crates/coa-release/src/main.rs b/crates/coa-release/src/main.rs index 604359b..2bb3a3c 100644 --- a/crates/coa-release/src/main.rs +++ b/crates/coa-release/src/main.rs @@ -47,6 +47,16 @@ fn run() -> Result<(), String> { let e = |x: coa_core::Error| x.to_string(); let part = a.get("part-size").and_then(|v| v.parse().ok()).unwrap_or(DEFAULT_PART_SIZE); match cmd.as_str() { + "verify-channel" => { + let bytes = std::fs::read(need(&a, "file")?).map_err(|x| x.to_string())?; + let pointer = coa_core::channels::verify(&bytes, need(&a, "channel")?, coa_core::signing::EMBEDDED_PUBLIC_KEY).map_err(e)?; + println!("{}", serde_json::to_string(&pointer).map_err(|x| x.to_string())?); + } + "verify-manifest" => { + let source = coa_core::pkgsource::Source::Dir(PathBuf::from(need(&a, "dir")?)); + let (manifest, _) = coa_core::pkgsource::fetch_manifest(&source, coa_core::signing::EMBEDDED_PUBLIC_KEY).map_err(e)?; + println!("verified signed manifest {}", manifest.version); + } "extract-schema-base" => { coa_core::release_schema::extract_base( &PathBuf::from(need(&a, "package")?), &PathBuf::from(need(&a, "fixture")?), From e34ab4544369443aa84c56b53856047b62cbafe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 10 Oct 2026 13:02:33 +0500 Subject: [PATCH 07/17] Add full isolated server startup qualification for disposable Windows fixtures --- crates/coa-core/src/release_schema.rs | 66 +++++++++++++++++++++++++++ crates/coa-release/src/main.rs | 7 +++ 2 files changed, 73 insertions(+) diff --git a/crates/coa-core/src/release_schema.rs b/crates/coa-core/src/release_schema.rs index 16d274c..4d8b6ff 100644 --- a/crates/coa-core/src/release_schema.rs +++ b/crates/coa-core/src/release_schema.rs @@ -72,6 +72,61 @@ pub fn capture_release(fixture: &Path, tree: &Path, sql: &[SqlFile]) -> Result<( })) } +pub fn validate_startup(fixture: &Path, tree: &Path, base: &Path, trusted_key: &str) -> Result<()> { + use crate::update::Env; + if !cfg!(windows) { + return Err(Error::Invalid("Repack startup qualification requires a Windows runner.".into())); + } + if !fixture.file_name().is_some_and(|n| n.to_string_lossy().starts_with("coa-schema-fixture-")) + || std::fs::read(fixture.join(MARKER)).ok().as_deref() != Some(b"disposable release-schema fixture") { + return Err(Error::Invalid("Startup qualification requires a disposable release-schema fixture.".into())); + } + let metadata = crate::registry::metadata_dir_for(fixture)?; + if metadata.exists() { return Err(Error::Invalid("Startup fixture already has Manager metadata; use a fresh fixture.".into())); } + schema_check::require_release_contract(tree)?; + for name in ["worldserver.exe", "authserver.exe"] { + if !tree.join("Core").join(name).is_file() { + return Err(Error::Invalid(format!("Startup candidate is missing {name}."))); + } + } + let (manifest, _) = pkgsource::fetch_manifest(&pkgsource::Source::Dir(base.into()), trusted_key)?; + if manifest.kind != Kind::Base { return Err(Error::Invalid("Startup qualification requires a signed base package.".into())); } + let mut directories = vec![tree.to_path_buf()]; + let mut files = Vec::new(); + while let Some(directory) = directories.pop() { + for entry in std::fs::read_dir(directory)? { + let entry = entry?; + let kind = entry.file_type()?; + if kind.is_symlink() { return Err(Error::Invalid("Startup release tree must not contain symbolic links.".into())); } + let path = entry.path(); + fsx::ensure_within(tree, &path)?; + if kind.is_dir() { directories.push(path); continue; } + if !kind.is_file() { return Err(Error::Invalid("Startup release tree contains an unsupported file type.".into())); } + let relative = path.strip_prefix(tree).map_err(|e| Error::Invalid(e.to_string()))?.to_path_buf(); + let first = relative.components().next().unwrap().as_os_str().to_string_lossy(); + if !matches!(first.as_ref(), "Core" | "Scripts" | "Data" | "Extras" | "Licenses") { + return Err(Error::Invalid("Startup release tree contains installation-specific files.".into())); + } + fsx::ensure_within(fixture, &fixture.join(&relative))?; + files.push(relative); + } + } + package::extract_selected(base, &manifest, fixture, &|path| path.starts_with("Data/"), &|_, _| {})?; + for relative in files { + let target = fsx::ensure_within(fixture, &fixture.join(&relative))?; + fsx::atomic_write(&target, &std::fs::read(tree.join(relative))?)?; + } + let meta = crate::registry::InstallMeta::new(crate::registry::InstallKind::New, fixture); + crate::registry::MetaDir::create(fixture, &meta)?; + let env = crate::update::RepackEnv { root: fixture, meta_dir: &metadata }; + env.validate()?; + env.ensure_stopped()?; + if crate::update_isolation::pending(&metadata) { + return Err(Error::Invalid("Startup qualification left unfinished isolated validation.".into())); + } + Ok(()) +} + pub fn verify_package(dir: &Path, trusted_key: &str) -> Result<()> { let (manifest, _) = pkgsource::fetch_manifest(&pkgsource::Source::Dir(dir.into()), trusted_key)?; if !manifest.files.iter().any(|f| f.path == schema_check::CONTRACT && f.size > 0) { @@ -89,6 +144,17 @@ mod tests { use base64::{engine::general_purpose::STANDARD, Engine}; use ed25519_dalek::{Signer, SigningKey}; + #[test] + fn startup_qualification_refuses_real_server_folders_before_writes() { + let directory = tempfile::tempdir().unwrap(); + let server = directory.path().join("live-server"); + std::fs::create_dir(&server).unwrap(); + fsx::atomic_write(&server.join("owner-data"), b"untouched").unwrap(); + assert!(validate_startup(&server, directory.path(), directory.path(), "unused").is_err()); + assert_eq!(std::fs::read(server.join("owner-data")).unwrap(), b"untouched"); + assert!(!crate::registry::metadata_dir_for(&server).unwrap().exists()); + } + #[test] fn signed_packages_must_carry_a_real_complete_schema_contract() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/coa-release/src/main.rs b/crates/coa-release/src/main.rs index 2bb3a3c..271c1e4 100644 --- a/crates/coa-release/src/main.rs +++ b/crates/coa-release/src/main.rs @@ -47,6 +47,13 @@ fn run() -> Result<(), String> { let e = |x: coa_core::Error| x.to_string(); let part = a.get("part-size").and_then(|v| v.parse().ok()).unwrap_or(DEFAULT_PART_SIZE); match cmd.as_str() { + "qualify-startup" => { + coa_core::release_schema::validate_startup( + &PathBuf::from(need(&a, "fixture")?), &PathBuf::from(need(&a, "tree")?), + &PathBuf::from(need(&a, "base")?), coa_core::signing::EMBEDDED_PUBLIC_KEY, + ).map_err(e)?; + println!("authserver and worldserver passed isolated startup qualification"); + } "verify-channel" => { let bytes = std::fs::read(need(&a, "file")?).map_err(|x| x.to_string())?; let pointer = coa_core::channels::verify(&bytes, need(&a, "channel")?, coa_core::signing::EMBEDDED_PUBLIC_KEY).map_err(e)?; From dc2bffc57e857f9042cbac2e96df84eb73a5a3d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 10 Oct 2026 19:34:52 +0500 Subject: [PATCH 08/17] Keep complete schema diagnostics across update rollback --- .../20261010-schema-validation-report.md | 7 ++++ crates/coa-core/src/schema_check.rs | 38 ++++++++++++++++++- crates/coa-core/src/update.rs | 8 ++-- tools/test_module_sql.py | 16 ++++++++ 4 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 changelog.d/20261010-schema-validation-report.md diff --git a/changelog.d/20261010-schema-validation-report.md b/changelog.d/20261010-schema-validation-report.md new file mode 100644 index 0000000..3c2d05a --- /dev/null +++ b/changelog.d/20261010-schema-validation-report.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Failed database checks keep a complete report after rollback +--- +Schema errors show readable default values and save every detected difference for troubleshooting, including after the server is restored. diff --git a/crates/coa-core/src/schema_check.rs b/crates/coa-core/src/schema_check.rs index 0bb5e5c..385297a 100644 --- a/crates/coa-core/src/schema_check.rs +++ b/crates/coa-core/src/schema_check.rs @@ -39,6 +39,34 @@ pub fn require_release_contract(root: &Path) -> Result { #[derive(Debug, Serialize)] pub struct Problem { pub database: String, pub table: String, pub column: String, pub detail: String } +/// Keep all differences for support; never change a database to match a contract automatically. +pub fn check_with_report(db: &Db, root: &Path) -> Result> { + let problems = check(db, root)?; + let report = serde_json::json!({ + "schema": 1, "checkedAt": chrono::Utc::now().to_rfc3339(), + "realm": db.realm().name(), "problems": problems, + }); + let path = crate::registry::metadata_dir_for(root)?.join("diagnostics") + .join(format!("schema-validation-{}.json", db.realm().name())); + // A log write failure must neither hide the original mismatch nor accept an invalid schema. + if let Err(error) = fsx::atomic_write_json(&path, &report) { + tracing::warn!(%error, path = %path.display(), "Could not save database schema report"); + } + Ok(problems) +} + +fn describe_signature(signature: &str) -> String { + let fields: Vec<_> = signature.split('|').collect(); + if fields.len() != 4 { return signature.into(); } + let decode = |value: &str| -> String { + if value == "" { return "no default".into(); } + if value == "" { return "none".into(); } + hex::decode(value).ok().and_then(|bytes| String::from_utf8(bytes).ok()) + .map(|text| format!("{text:?}")).unwrap_or_else(|| value.into()) + }; + format!("type={}, nullable={}, default={}, extra={}", fields[0], fields[1], decode(fields[2]), decode(fields[3])) +} + fn read_columns(db: &Db, kind: &str) -> Result>> { let schema = db.realm_schema(crate::db::schema_of(kind)?); let text = db.query(&format!("SELECT TABLE_NAME,COLUMN_NAME,CONCAT(COLUMN_TYPE,'|',IS_NULLABLE,'|',IF(COLUMN_DEFAULT IS NULL,'',HEX(COLUMN_DEFAULT)),'|',IF(EXTRA='','',HEX(EXTRA))) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='{schema}' ORDER BY TABLE_NAME,ORDINAL_POSITION;"))?; @@ -98,7 +126,7 @@ pub fn check(db: &Db, root: &Path) -> Result> { for (column, want) in cols { let found = actual[table].get(column); if found.is_none() || (!want.is_empty() && found != Some(want)) { - problems.push(Problem { database: kind.clone(), table: table.clone(), column: column.clone(), detail: found.map(|s| format!("Expected {want}, found {s}")).unwrap_or_else(|| "Missing column".into()) }); + problems.push(Problem { database: kind.clone(), table: table.clone(), column: column.clone(), detail: found.map(|s| format!("Expected {}, found {}", describe_signature(want), describe_signature(s))).unwrap_or_else(|| "Missing column".into()) }); } } } @@ -147,6 +175,14 @@ fn missing_coa_starts(rows: &str) -> Vec { mod tests { use super::*; + #[test] + fn schema_defaults_are_readable_and_not_conflated() { + assert_eq!(describe_signature("int unsigned|NO|30|"), "type=int unsigned, nullable=NO, default=\"0\", extra=none"); + assert!(describe_signature("int unsigned|NO||").contains("default=no default")); + assert!(describe_signature("varchar(8)|YES||").contains("default=\"\"")); + assert_eq!(describe_signature("legacy"), "legacy"); + } + #[test] fn a_release_cannot_use_a_partial_or_empty_schema_contract() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index 6ab2cac..232d6be 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -900,7 +900,7 @@ impl Env for RepackEnv<'_> { let mut modes = vec![crate::realms::Mode::Coa]; if crate::realms::state(self.root)?.wildcard_created { modes.push(crate::realms::Mode::Wildcard); } for mode in modes { - if let Some(problem) = crate::schema_check::check(&db.clone().for_realm(mode), self.root)?.first() { + if let Some(problem) = crate::schema_check::check_with_report(&db.clone().for_realm(mode), self.root)?.first() { return Err(Error::Invalid(format!("The installed {} database does not match its upgrade source: {}.{}: {}", mode.name(), problem.database, problem.table, problem.detail))); } } @@ -968,13 +968,13 @@ impl Env for RepackEnv<'_> { result.failed = extra.failed; if result.snapshot.is_none() { result.snapshot = extra.snapshot; } if result.failed.is_none() { - if let Some(p) = crate::schema_check::check(&other_db, root)?.first() { + if let Some(p) = crate::schema_check::check_with_report(&other_db, root)?.first() { return Err(Error::Invalid(format!("Database validation failed on {}: {}.{}: {}", other.name(), p.table, p.column, p.detail))); } } } if result.failed.is_none() { - let problems = crate::schema_check::check(db, root)?; + let problems = crate::schema_check::check_with_report(db, root)?; if let Some(p) = problems.first() { return Err(Error::Invalid(format!("Database validation failed: {}.{}.{}: {} ({} problems).", p.database, p.table, p.column, p.detail, problems.len()))); } @@ -995,7 +995,7 @@ impl Env for RepackEnv<'_> { let mut modes = vec![crate::realms::Mode::Coa]; if crate::realms::state(self.root)?.wildcard_created { modes.push(crate::realms::Mode::Wildcard); } for mode in modes { - if let Some(p) = crate::schema_check::check(&db.clone().for_realm(mode), self.root)?.first() { + if let Some(p) = crate::schema_check::check_with_report(&db.clone().for_realm(mode), self.root)?.first() { return Err(Error::Invalid(format!("Database validation failed on {}: {}.{}: {}", mode.name(), p.database, p.table, p.detail))); } } diff --git a/tools/test_module_sql.py b/tools/test_module_sql.py index 2ed16d5..dfa10bc 100644 --- a/tools/test_module_sql.py +++ b/tools/test_module_sql.py @@ -12,6 +12,7 @@ def main(): parser.add_argument('--mysql-bin', required=True, type=Path) parser.add_argument('--core', required=True, type=Path) parser.add_argument('--before', required=True) + parser.add_argument('--display-repair', type=Path) args = parser.parse_args() suffix = '.exe' if (args.mysql_bin / 'mysqld.exe').exists() else '' server = args.mysql_bin / ('mysqld' + suffix) @@ -66,6 +67,21 @@ def main(): effects = sql('SELECT effect_d0,effect_d1,effect_d2,effect_d3 FROM coa_boss_schedule WHERE entry=12264 AND idx=6;', schema).stdout.strip() assert effects == '2105613\t2105614\t2105615\t2105616', effects print('PASS: original SQL reproduces ERROR 1136 after migration; fixed SQL supports fresh, repeated and old-layout initialization') + if args.display_repair: + repair = args.display_repair.read_text() + sql('CREATE DATABASE display_fixture;') + sql(repair, 'display_fixture') # An absent table is not created by a default repair. + sql('CREATE TABLE creature_display_preset (entry INT UNSIGNED NOT NULL, display_id INT UNSIGNED NOT NULL, PRIMARY KEY(entry,display_id)); INSERT INTO creature_display_preset VALUES(42,123);', 'display_fixture') + before_rows = sql('SELECT * FROM creature_display_preset;', 'display_fixture').stdout + sql(repair, 'display_fixture') + sql(repair, 'display_fixture') + default = sql("SELECT COLUMN_DEFAULT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='creature_display_preset' AND COLUMN_NAME='display_id';", 'display_fixture').stdout.strip() + assert default == '0', default + assert sql('SELECT * FROM creature_display_preset;', 'display_fixture').stdout == before_rows + sql('ALTER TABLE creature_display_preset ALTER COLUMN display_id SET DEFAULT 99;', 'display_fixture') + sql(repair, 'display_fixture') + assert sql("SELECT COLUMN_DEFAULT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='creature_display_preset' AND COLUMN_NAME='display_id';", 'display_fixture').stdout.strip() == '99' + print('PASS: display default repair is repeatable, keeps rows and custom defaults, and tolerates absent tables') finally: subprocess.run([str(admin), *connection, 'shutdown'], capture_output=True, creationflags=flags) try: From a3717c08bba2af5ba32d82f0567132c12a63d66d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 10 Oct 2026 19:37:13 +0500 Subject: [PATCH 09/17] Retain each schema failure report during recovery validation --- crates/coa-core/src/schema_check.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/coa-core/src/schema_check.rs b/crates/coa-core/src/schema_check.rs index 385297a..c00244d 100644 --- a/crates/coa-core/src/schema_check.rs +++ b/crates/coa-core/src/schema_check.rs @@ -42,12 +42,13 @@ pub struct Problem { pub database: String, pub table: String, pub column: String /// Keep all differences for support; never change a database to match a contract automatically. pub fn check_with_report(db: &Db, root: &Path) -> Result> { let problems = check(db, root)?; + if problems.is_empty() { return Ok(problems); } let report = serde_json::json!({ "schema": 1, "checkedAt": chrono::Utc::now().to_rfc3339(), "realm": db.realm().name(), "problems": problems, }); let path = crate::registry::metadata_dir_for(root)?.join("diagnostics") - .join(format!("schema-validation-{}.json", db.realm().name())); + .join(format!("schema-validation-{}-{}.json", db.realm().name(), uuid::Uuid::new_v4())); // A log write failure must neither hide the original mismatch nor accept an invalid schema. if let Err(error) = fsx::atomic_write_json(&path, &report) { tracing::warn!(%error, path = %path.display(), "Could not save database schema report"); From 0589722e7191f1bc5ea899ee8a7382de10fcd641 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 10 Oct 2026 19:38:36 +0500 Subject: [PATCH 10/17] Include persisted schema failures in diagnostic exports --- crates/coa-core/src/diag.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/coa-core/src/diag.rs b/crates/coa-core/src/diag.rs index 0466bff..0f12cac 100644 --- a/crates/coa-core/src/diag.rs +++ b/crates/coa-core/src/diag.rs @@ -401,6 +401,14 @@ pub fn export_package(root: &Path, meta_dir: &Path, manager_log: &Path, meta: &I add("processes.txt", processes_report(root, &ports).as_bytes())?; // The newest update journals: the state of each update and the reason it failed. + for (path, size, _) in newest_files(&meta_dir.join("diagnostics"), "json", usize::MAX) + .into_iter().filter(|(path, _, _)| path.file_name().is_some_and(|name| name.to_string_lossy().starts_with("schema-validation-"))).take(10) { + if size <= 4 * 1024 * 1024 { + if let Ok(text) = fs::read_to_string(&path) { + add(&format!("schema/{}", path.file_name().unwrap().to_string_lossy()), redact_json(&text).as_bytes())?; + } + } + } let mut journals: Vec<_> = fs::read_dir(meta_dir.join("updates")).into_iter().flatten().flatten() .filter(|e| e.file_type().is_ok_and(|t| t.is_dir()) && e.file_name().to_string_lossy().chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))) .collect(); @@ -586,6 +594,8 @@ real error fs::write(root.join("Core/configs/worldserver.conf"), "LoginDatabaseInfo = \"127.0.0.1;3307;acore;SECRETPW;auth\"\nRate.XP.Kill = 1\n").unwrap(); let meta_dir = d.path().join("srv.manager"); fs::create_dir_all(&meta_dir).unwrap(); + fs::create_dir_all(meta_dir.join("diagnostics")).unwrap(); + fs::write(meta_dir.join("diagnostics/schema-validation-CoA-test.json"), r#"{"problems":[{"table":"creature_display_preset","column":"display_id","detail":"default mismatch"}],"password":"schema-secret"}"#).unwrap(); let meta = InstallMeta::new(InstallKind::Imported, &root); let report = run(&root, &meta); let zip_path = d.path().join("diag.zip"); @@ -600,6 +610,8 @@ real error } assert!(all.contains("boom") && all.contains("Rate.XP.Kill"), "keys and ordinary log lines are included"); assert!(all.contains("v1.8") && all.contains("48c4786a")); + assert!(all.contains("creature_display_preset") && all.contains("default mismatch")); + assert!(!all.contains("schema-secret")); assert!(!all.contains("hunter2") && !all.contains("SECRETPW") && !all.contains("upstream-secret")); } } From 3b7f77d92eb6ed23d143a6e80accdbba3db01fea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 10 Oct 2026 19:52:07 +0500 Subject: [PATCH 11/17] Check packaged MySQL Unicode regex support on fresh data --- crates/coa-core/src/cleanbase.rs | 2 +- tools/test_module_sql.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/coa-core/src/cleanbase.rs b/crates/coa-core/src/cleanbase.rs index 3f96fe4..44cef0b 100644 --- a/crates/coa-core/src/cleanbase.rs +++ b/crates/coa-core/src/cleanbase.rs @@ -294,7 +294,7 @@ mod tests { #[test] fn scaffolding_keeps_the_launcher_and_drops_runtime_state_and_live_data() { - for keep in ["Runtime/python/python.exe", "Scripts/manage.py", "Settings/worldserver.conf.template", "mysql/bin/mysqld.exe", "mysql/share/english/errmsg.sys", "BugReport/relay.py", "Core/libssl-3-x64.dll", "README.txt"] { + for keep in ["Runtime/python/python.exe", "Scripts/manage.py", "Settings/worldserver.conf.template", "mysql/bin/mysqld.exe", "mysql/share/english/errmsg.sys", "mysql/lib/private/icudt.dat", "BugReport/relay.py", "Core/libssl-3-x64.dll", "README.txt"] { assert!(scaffolding(keep), "{keep}"); } for drop in [".state/world.json", "mysql/data/acore_world/x.ibd", "mysql/logs/error.log", "Core/worldserver.exe", "Core/Logs/Server.log", "BugReport/reports/a.json", "Source/server-source.zip", "Scripts/__pycache__/x.pyc", "Testing/notes.md", "mysql/data.7z"] { diff --git a/tools/test_module_sql.py b/tools/test_module_sql.py index dfa10bc..aa954d3 100644 --- a/tools/test_module_sql.py +++ b/tools/test_module_sql.py @@ -49,6 +49,9 @@ def main(): if process.poll() is not None or time.monotonic() > deadline: raise RuntimeError('Disposable MySQL did not start: ' + (Path(temp) / 'mysql.log').read_text()) time.sleep(0.5) + regex = sql("SELECT REGEXP_LIKE(CONVERT(0xD0AF USING utf8mb4), '^[[:alpha:]]$'), REGEXP_REPLACE('abc123', '[0-9]+', '');").stdout.strip() + assert regex == '1\tabc', regex + print('PASS: packaged MySQL starts on fresh data and supports Unicode regular expressions') for schema in ('fresh', 'old', 'upgraded'): sql('CREATE DATABASE `' + schema + '`;') sql('CREATE TABLE creature_template (entry INT PRIMARY KEY, ScriptName VARCHAR(128));', schema) From b101c79e968fe374c1e37576351ce9f533b3224d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 10 Oct 2026 20:51:02 +0500 Subject: [PATCH 12/17] Qualify upgrades through the real Manager transaction in isolated fixtures --- crates/coa-core/src/release_schema.rs | 113 ++++++++++++++++++++++++++ crates/coa-release/src/main.rs | 13 +++ 2 files changed, 126 insertions(+) diff --git a/crates/coa-core/src/release_schema.rs b/crates/coa-core/src/release_schema.rs index 4d8b6ff..d784a1d 100644 --- a/crates/coa-core/src/release_schema.rs +++ b/crates/coa-core/src/release_schema.rs @@ -5,6 +5,81 @@ use crate::{backup, fsx, install, manifest::{Kind, Migration}, migrations, packa const MARKER: &str = ".release-schema-fixture"; +struct QualificationEnv<'a>(crate::update::RepackEnv<'a>); + +impl crate::update::Env for QualificationEnv<'_> { + fn activate(&self) -> Result<()> { Ok(()) } + fn ensure_stopped(&self) -> Result<()> { self.0.ensure_stopped() } + fn preflight(&self, manifest: &crate::manifest::Manifest) -> Result<()> { self.0.preflight(manifest) } + fn snapshot(&self) -> Result { self.0.snapshot() } + fn verify_snapshot(&self, id: &str) -> Result<()> { self.0.verify_snapshot(id) } + fn restore_snapshot(&self, id: &str) -> Result<()> { self.0.restore_snapshot(id) } + fn migrate(&self, manifest: &crate::manifest::Manifest, staged: &Path) -> Result { self.0.migrate(manifest, staged) } + fn validate(&self) -> Result<()> { self.0.validate() } + fn validate_recovery(&self) -> Result<()> { self.0.validate_recovery() } + fn recover_validation(&self) -> Result<()> { self.0.recover_validation() } + fn automatic_rollback(&self) -> bool { true } + // Health checks use the normal private validation; qualification never opens game ports after commit. +} + +pub fn qualify_upgrade(base: &Path, source: &Path, candidate: &Path, fixture: &Path, trusted_key: &str) -> Result { + use crate::update::Env; + if !cfg!(windows) { return Err(Error::Invalid("Manager upgrade qualification requires a Windows fixture.".into())); } + if fixture.exists() || !fixture.file_name().is_some_and(|name| name.to_string_lossy().starts_with("coa-schema-fixture-")) { + return Err(Error::Invalid("Upgrade qualification requires a new disposable coa-schema-fixture-* directory.".into())); + } + let metadata = crate::registry::metadata_dir_for(fixture)?; + if metadata.exists() { return Err(Error::Invalid("Upgrade fixture metadata already exists.".into())); } + let (base_manifest, base_bytes) = pkgsource::fetch_manifest(&pkgsource::Source::Dir(base.into()), trusted_key)?; + let (source_manifest, source_bytes) = pkgsource::fetch_manifest(&pkgsource::Source::Dir(source.into()), trusted_key)?; + let (candidate_manifest, _) = pkgsource::fetch_manifest(&pkgsource::Source::Dir(candidate.into()), trusted_key)?; + if candidate_manifest.kind != Kind::Update || !matches!(source_manifest.kind, Kind::Base | Kind::Update) + || (source_manifest.kind == Kind::Base && fsx::sha256_bytes(&base_bytes) != fsx::sha256_bytes(&source_bytes)) { + return Err(Error::Invalid("Invalid source or candidate package for upgrade qualification.".into())); + } + extract_base(base, fixture, trusted_key)?; + package::extract_selected(base, &base_manifest, fixture, &|path| path.starts_with("Data/"), &|_, _| {})?; + let mut meta = crate::registry::InstallMeta::new(crate::registry::InstallKind::New, fixture); + meta.core.version = Some(base_manifest.version.clone()); + meta.core.commit = base_manifest.core.commit.clone(); + meta.original_hashes = base_manifest.files.iter().map(|file| (file.path.clone(), file.sha256.clone())).collect(); + crate::registry::MetaDir::create(fixture, &meta)?; + let env = QualificationEnv(crate::update::RepackEnv { root: fixture, meta_dir: &metadata }); + let apply = |package: &Path| -> Result<()> { + let outcome = crate::update::apply(&crate::update::Params { + root: fixture, meta_dir: &metadata, source: pkgsource::Source::Dir(package.into()), + trusted_key, cancel: crate::download::Cancel::default(), resolutions: Default::default(), + env: &env, fail_after_ops: None, + }, &|_, _| {})?; + if outcome.txn.state != crate::update::State::Committed { + return Err(Error::Invalid("Upgrade qualification did not commit.".into())); + } + env.ensure_stopped() + }; + install::with_scratch_ports(fixture, || { + if source_manifest.kind == Kind::Update { apply(source)?; } + let user_file = fixture.join("Core/configs/coa-qualification-user.conf"); + let user_bytes = b"Qualification.UserSetting = 7391\r\n"; + fsx::atomic_write(&user_file, user_bytes)?; + apply(candidate)?; + if std::fs::read(&user_file)?.as_slice() != user_bytes { + return Err(Error::Invalid("The update changed a user configuration file.".into())); + } + let (_, installed) = crate::registry::MetaDir::open(&metadata)?; + if installed.core.version.as_deref() != Some(candidate_manifest.version.as_str()) + || crate::update_isolation::pending(&metadata) || crate::update::pending_checked(&metadata)?.is_some() { + return Err(Error::Invalid("The update left inconsistent metadata or an unfinished transaction.".into())); + } + Ok(()) + })?; + Ok(serde_json::json!({ + "schema": 1, "result": "passed", "fromVersion": source_manifest.version, + "toVersion": candidate_manifest.version, "fromManifestSha256": fsx::sha256_bytes(&source_bytes), + "fromSchemaSha256": source_manifest.files.iter().find(|file| file.path == schema_check::CONTRACT).map(|file| &file.sha256), + "checks": ["manager-transaction", "database-schema", "private-auth-world-startup", "user-file-preservation"], + })) +} + pub fn collect(core: &Path, bots: Option<&Path>, repairs: Option<&Path>) -> Result> { let mut files = release::collect_core_sql(core)?; if let Some(bots) = bots { files.extend(release::collect_bots_sql(bots)?); } @@ -138,6 +213,19 @@ pub fn verify_package(dir: &Path, trusted_key: &str) -> Result<()> { Ok(()) } +pub fn verify_upgrade_fixture(dir: &Path, trusted_key: &str) -> Result<()> { + let (manifest, _) = pkgsource::fetch_manifest(&pkgsource::Source::Dir(dir.into()), trusted_key)?; + if !matches!(manifest.kind, Kind::Base | Kind::Update) { + return Err(Error::Invalid("Upgrade fixtures must be base or server update packages.".into())); + } + let scratch = tempfile::tempdir()?; + package::extract_selected(dir, &manifest, scratch.path(), &|path| path == schema_check::CONTRACT, &|_, _| {})?; + if manifest.kind == Kind::Update || manifest.files.iter().any(|file| file.path == schema_check::CONTRACT) { + schema_check::require_release_contract(scratch.path())?; + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -193,6 +281,31 @@ mod tests { let dir = tempfile::tempdir().unwrap(); assert!(extract_base(dir.path(), dir.path(), "irrelevant").is_err()); assert!(capture_release(dir.path(), dir.path(), &[]).is_err()); + assert!(qualify_upgrade(dir.path(), dir.path(), dir.path(), dir.path(), "irrelevant").is_err()); assert!(!dir.path().join(schema_check::CONTRACT).exists()); } + + #[test] + fn only_legacy_base_fixtures_may_omit_the_schema_contract() { + let dir = tempfile::tempdir().unwrap(); + let tree = dir.path().join("tree"); + let out = dir.path().join("package"); + fsx::atomic_write(&tree.join("Core/example.txt"), b"fixture").unwrap(); + let key = SigningKey::from_bytes(&[29; 32]); + let public = STANDARD.encode(key.verifying_key().as_bytes()); + let options = package::BuildOptions { kind: Kind::Base, version: "0.2.0".into(), core_commit: None, bots_commit: None, built_at: "test".into(), part_size: 1024 * 1024, migrations: vec![] }; + package::build(&tree, &out, &options, &|_| {}).unwrap(); + let sign = || { + let bytes = std::fs::read(out.join("manifest.json")).unwrap(); + fsx::atomic_write(&out.join("manifest.json.sig"), STANDARD.encode(key.sign(&bytes).to_bytes()).as_bytes()).unwrap(); + }; + sign(); + verify_upgrade_fixture(&out, &public).unwrap(); + assert!(verify_package(&out, &public).is_err()); + let mut manifest: serde_json::Value = fsx::read_json(&out.join("manifest.json")).unwrap(); + manifest["kind"] = "update".into(); + fsx::atomic_write_json(&out.join("manifest.json"), &manifest).unwrap(); + sign(); + assert!(verify_upgrade_fixture(&out, &public).is_err()); + } } diff --git a/crates/coa-release/src/main.rs b/crates/coa-release/src/main.rs index 271c1e4..9dddaf2 100644 --- a/crates/coa-release/src/main.rs +++ b/crates/coa-release/src/main.rs @@ -47,6 +47,15 @@ fn run() -> Result<(), String> { let e = |x: coa_core::Error| x.to_string(); let part = a.get("part-size").and_then(|v| v.parse().ok()).unwrap_or(DEFAULT_PART_SIZE); match cmd.as_str() { + "qualify-upgrade" => { + let report = coa_core::release_schema::qualify_upgrade( + &PathBuf::from(need(&a, "base")?), &PathBuf::from(need(&a, "source")?), + &PathBuf::from(need(&a, "candidate")?), &PathBuf::from(need(&a, "fixture")?), + coa_core::signing::EMBEDDED_PUBLIC_KEY, + ).map_err(e)?; + coa_core::fsx::atomic_write_json(&PathBuf::from(need(&a, "report")?), &report).map_err(e)?; + println!("Manager upgrade transaction passed isolated qualification"); + } "qualify-startup" => { coa_core::release_schema::validate_startup( &PathBuf::from(need(&a, "fixture")?), &PathBuf::from(need(&a, "tree")?), @@ -64,6 +73,10 @@ fn run() -> Result<(), String> { let (manifest, _) = coa_core::pkgsource::fetch_manifest(&source, coa_core::signing::EMBEDDED_PUBLIC_KEY).map_err(e)?; println!("verified signed manifest {}", manifest.version); } + "verify-upgrade-fixture" => { + coa_core::release_schema::verify_upgrade_fixture(&PathBuf::from(need(&a, "dir")?), coa_core::signing::EMBEDDED_PUBLIC_KEY).map_err(e)?; + println!("verified signed upgrade fixture and every archive file"); + } "extract-schema-base" => { coa_core::release_schema::extract_base( &PathBuf::from(need(&a, "package")?), &PathBuf::from(need(&a, "fixture")?), From 3405c89fe8e6b959de6e957c33193cf2c4cad7f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 10 Oct 2026 20:55:38 +0500 Subject: [PATCH 13/17] Keep the RA test peer open until clients finish under parallel load --- crates/coa-core/src/ra.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/coa-core/src/ra.rs b/crates/coa-core/src/ra.rs index b1fcb7c..99eccb8 100644 --- a/crates/coa-core/src/ra.rs +++ b/crates/coa-core/src/ra.rs @@ -278,7 +278,7 @@ mod tests { } // Close gracefully: dropping a socket with unread data would send RST and could discard the reply. let _ = s.shutdown(std::net::Shutdown::Write); - let _ = s.set_read_timeout(Some(Duration::from_secs(2))); + let _ = s.set_read_timeout(Some(Duration::from_secs(15))); let mut sink = [0u8; 64]; while matches!(s.read(&mut sink), Ok(n) if n > 0) {} got From 9ee0e9f6afa55bd43c307f767f0353e047d1a009 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 10 Oct 2026 20:56:15 +0500 Subject: [PATCH 14/17] Revert "Keep the RA test peer open until clients finish under parallel load" This reverts commit 3405c89fe8e6b959de6e957c33193cf2c4cad7f2. --- crates/coa-core/src/ra.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/coa-core/src/ra.rs b/crates/coa-core/src/ra.rs index 99eccb8..b1fcb7c 100644 --- a/crates/coa-core/src/ra.rs +++ b/crates/coa-core/src/ra.rs @@ -278,7 +278,7 @@ mod tests { } // Close gracefully: dropping a socket with unread data would send RST and could discard the reply. let _ = s.shutdown(std::net::Shutdown::Write); - let _ = s.set_read_timeout(Some(Duration::from_secs(15))); + let _ = s.set_read_timeout(Some(Duration::from_secs(2))); let mut sink = [0u8; 64]; while matches!(s.read(&mut sink), Ok(n) if n > 0) {} got From 59036867d77e88a787a620ddb5eea1d9184f1424 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 10 Oct 2026 22:33:54 +0500 Subject: [PATCH 15/17] Report all database objects blocking update recovery --- .../20261010-database-recovery-diagnostics.md | 7 ++++ crates/coa-core/src/db.rs | 32 +++++++++++++++++++ crates/coa-core/src/diag.rs | 8 ++++- crates/coa-core/src/update.rs | 12 +++++-- 4 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 changelog.d/20261010-database-recovery-diagnostics.md diff --git a/changelog.d/20261010-database-recovery-diagnostics.md b/changelog.d/20261010-database-recovery-diagnostics.md new file mode 100644 index 0000000..998ea93 --- /dev/null +++ b/changelog.d/20261010-database-recovery-diagnostics.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Blocked updates identify database objects that prevent automatic recovery +--- +Diagnostics include the complete list of unsupported database objects across the recovery point, helping resolve update failures without removing user data. diff --git a/crates/coa-core/src/db.rs b/crates/coa-core/src/db.rs index 0786ad6..aace309 100644 --- a/crates/coa-core/src/db.rs +++ b/crates/coa-core/src/db.rs @@ -247,6 +247,13 @@ impl Db { self.query(&sql)?.trim().parse().map_err(|_| Error::Invalid("unexpected reply".into())) } + pub fn recovery_objects(&self, schema: &str) -> Result> { + let schema = self.realm_schema(schema); + if !ident_ok(schema) { return Err(Error::Invalid("bad schema name".into())); } + let sql = format!("SELECT routine_type,HEX(routine_name) FROM information_schema.routines WHERE routine_schema='{schema}' UNION ALL SELECT 'TRIGGER',HEX(trigger_name) FROM information_schema.triggers WHERE trigger_schema='{schema}' UNION ALL SELECT 'VIEW',HEX(table_name) FROM information_schema.views WHERE table_schema='{schema}' ORDER BY 1,2;"); + parse_recovery_objects(&schema, &self.query(&sql)?) + } + /// Consistent dump of one schema, zstd-compressed to `out`. Returns (compressed bytes, sha256 of the file). pub fn dump_to(&self, schema: &str, out: &Path) -> Result<(u64, String)> { let schema = self.realm_schema(schema); @@ -334,6 +341,18 @@ impl Db { } } +fn parse_recovery_objects(schema: &str, text: &str) -> Result> { + text.lines().filter(|line| !line.is_empty()).map(|line| { + let (kind, name) = line.split_once('\t').ok_or_else(|| Error::Invalid("unexpected database object reply".into()))?; + if !matches!(kind, "PROCEDURE" | "FUNCTION" | "TRIGGER" | "VIEW") { + return Err(Error::Invalid("unexpected database object type".into())); + } + let name = hex::decode(name).ok().and_then(|bytes| String::from_utf8(bytes).ok()) + .filter(|name| !name.is_empty()).ok_or_else(|| Error::Invalid("unexpected database object name".into()))?; + Ok(serde_json::json!({ "database": schema, "kind": kind, "name": name })) + }).collect() +} + /// Name of the service account the Manager uses for the server console (RA). Accounts are stored upper-case. pub const SERVICE_ACCOUNT: &str = "COAMANAGER"; @@ -421,6 +440,19 @@ mod tests { const ROOT_PW: &str = "rootpw-ZZ1"; const APP_PW: &str = "apppw-QQ2"; + #[test] + fn recovery_object_names_preserve_unicode_and_delimiters() { + let name = "view\tперсонажи\n"; + let text = format!("VIEW\t{}\nTRIGGER\t74726967676572\n", hex::encode(name)); + let objects = parse_recovery_objects("acore_characters", &text).unwrap(); + assert_eq!(objects.len(), 2); + assert_eq!(objects[0]["name"], name); + assert_eq!(objects[1]["kind"], "TRIGGER"); + assert!(parse_recovery_objects("test", "VIEW\tzz").is_err()); + assert!(parse_recovery_objects("test", "UNKNOWN\t61").is_err()); + assert!(parse_recovery_objects("test", "VIEW").is_err()); + } + fn folder(docker: bool) -> (tempfile::TempDir, PathBuf) { let d = tempfile::tempdir().unwrap(); let root = d.path().join("srv"); diff --git a/crates/coa-core/src/diag.rs b/crates/coa-core/src/diag.rs index 0f12cac..be21f31 100644 --- a/crates/coa-core/src/diag.rs +++ b/crates/coa-core/src/diag.rs @@ -402,7 +402,10 @@ pub fn export_package(root: &Path, meta_dir: &Path, manager_log: &Path, meta: &I // The newest update journals: the state of each update and the reason it failed. for (path, size, _) in newest_files(&meta_dir.join("diagnostics"), "json", usize::MAX) - .into_iter().filter(|(path, _, _)| path.file_name().is_some_and(|name| name.to_string_lossy().starts_with("schema-validation-"))).take(10) { + .into_iter().filter(|(path, _, _)| path.file_name().is_some_and(|name| { + let name = name.to_string_lossy(); + name.starts_with("schema-validation-") || name.starts_with("database-recovery-") + })).take(10) { if size <= 4 * 1024 * 1024 { if let Ok(text) = fs::read_to_string(&path) { add(&format!("schema/{}", path.file_name().unwrap().to_string_lossy()), redact_json(&text).as_bytes())?; @@ -596,6 +599,7 @@ real error fs::create_dir_all(&meta_dir).unwrap(); fs::create_dir_all(meta_dir.join("diagnostics")).unwrap(); fs::write(meta_dir.join("diagnostics/schema-validation-CoA-test.json"), r#"{"problems":[{"table":"creature_display_preset","column":"display_id","detail":"default mismatch"}],"password":"schema-secret"}"#).unwrap(); + fs::write(meta_dir.join("diagnostics/database-recovery-test.json"), r#"{"objects":[{"database":"acore_characters","kind":"TRIGGER","name":"user_character_trigger"}],"password":"recovery-secret"}"#).unwrap(); let meta = InstallMeta::new(InstallKind::Imported, &root); let report = run(&root, &meta); let zip_path = d.path().join("diag.zip"); @@ -612,6 +616,8 @@ real error assert!(all.contains("v1.8") && all.contains("48c4786a")); assert!(all.contains("creature_display_preset") && all.contains("default mismatch")); assert!(!all.contains("schema-secret")); + assert!(all.contains("user_character_trigger") && all.contains("acore_characters")); + assert!(!all.contains("recovery-secret")); assert!(!all.contains("hunter2") && !all.contains("SECRETPW") && !all.contains("upstream-secret")); } } diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index 232d6be..a1a3aff 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -851,11 +851,19 @@ impl RepackEnv<'_> { if !crate::backup::verify(self.meta_dir, &point.id)?.ok { return Err(Error::Invalid("The recovery point failed verification.".into())); } crate::backup::with_database(self.root, |db| { let db = db.clone().for_realm(crate::realms::Mode::Coa); + let mut objects = Vec::new(); for component in point.components.iter().filter(|c| c.sha256.is_some()) { let schema = if component.name.contains('-') || component.name == "playerbots" { crate::db::schema_of(&component.name)? } else { point.realm.schema(&component.name)? }; - if db.extra_objects(schema)? != 0 { - return Err(Error::Invalid(format!("Database {schema} has routines, triggers or views that automatic recovery cannot restore. The update was not started."))); + objects.extend(db.recovery_objects(schema)?); + } + if !objects.is_empty() { + let path = self.meta_dir.join("diagnostics").join(format!("database-recovery-{}.json", uuid::Uuid::new_v4())); + let report = serde_json::json!({ "schema": 1, "checkedAt": chrono::Utc::now().to_rfc3339(), "recoveryPoint": point.id, "objects": objects }); + if let Err(error) = fsx::atomic_write_json(&path, &report) { + tracing::warn!(%error, "Could not save database recovery compatibility report"); } + let sample = objects.iter().take(3).map(|object| format!("{}.{} ({})", object["database"].as_str().unwrap_or("?"), object["name"].as_str().unwrap_or("?"), object["kind"].as_str().unwrap_or("?"))).collect::>().join(", "); + return Err(Error::Invalid(format!("Automatic recovery does not support {} database objects: {sample}. Export diagnostics for the complete list. The update was not started.", objects.len()))); } Ok(()) }) From 9ae5fb471b18a90f2fe98650b25780574c776522 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 10 Oct 2026 23:34:29 +0500 Subject: [PATCH 16/17] Rehearse Windows updates and preserve complete MySQL recovery --- .gitignore | 2 + ...20261010-complete-mysql-update-recovery.md | 7 + .../20261010-missing-database-defaults.md | 7 + ...61010-private-database-update-rehearsal.md | 7 + .../coa-core/examples/update_recovery_e2e.rs | 7 +- crates/coa-core/src/backup.rs | 16 + crates/coa-core/src/diag.rs | 2 + crates/coa-core/src/lib.rs | 1 + crates/coa-core/src/mysql_snapshot.rs | 474 ++++++++++++++++++ crates/coa-core/src/release_schema.rs | 3 +- crates/coa-core/src/schema_check.rs | 49 ++ crates/coa-core/src/update.rs | 173 ++++++- src/lib/api.ts | 1 + src/screens/BackupsPage.tsx | 2 +- tools/make-fixture.ps1 | 6 +- 15 files changed, 746 insertions(+), 11 deletions(-) create mode 100644 changelog.d/20261010-complete-mysql-update-recovery.md create mode 100644 changelog.d/20261010-missing-database-defaults.md create mode 100644 changelog.d/20261010-private-database-update-rehearsal.md create mode 100644 crates/coa-core/src/mysql_snapshot.rs diff --git a/.gitignore b/.gitignore index 42ca83f..56ac59b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ dist *.log .env src-tauri/gen +# Disposable database fixtures and private recovery data +.cache/ diff --git a/changelog.d/20261010-complete-mysql-update-recovery.md b/changelog.d/20261010-complete-mysql-update-recovery.md new file mode 100644 index 0000000..8f6478d --- /dev/null +++ b/changelog.d/20261010-complete-mysql-update-recovery.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Windows update recovery preserves database procedures triggers and views +--- +A verified copy of stopped MySQL restores all databases and accounts together. Interrupted restoration can be retried, and the replaced database directory is retained. diff --git a/changelog.d/20261010-missing-database-defaults.md b/changelog.d/20261010-missing-database-defaults.md new file mode 100644 index 0000000..c612fc6 --- /dev/null +++ b/changelog.d/20261010-missing-database-defaults.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Updates repair missing database defaults from the verified release schema +--- +Only missing literal defaults are restored when column properties match. Existing rows, custom defaults, types and nullability are preserved; unresolved incompatibilities still block the update. diff --git a/changelog.d/20261010-private-database-update-rehearsal.md b/changelog.d/20261010-private-database-update-rehearsal.md new file mode 100644 index 0000000..4cdf780 --- /dev/null +++ b/changelog.d/20261010-private-database-update-rehearsal.md @@ -0,0 +1,7 @@ +--- +area: manager +type: added +audience: admins +title: Windows server updates are tested on a private copy before changing your installation +--- +The Manager tests migrations and server startup using your databases and configurations. A failed rehearsal leaves the installed server unchanged and preserves a diagnostic report. diff --git a/crates/coa-core/examples/update_recovery_e2e.rs b/crates/coa-core/examples/update_recovery_e2e.rs index 7e24705..fe29875 100644 --- a/crates/coa-core/examples/update_recovery_e2e.rs +++ b/crates/coa-core/examples/update_recovery_e2e.rs @@ -27,6 +27,7 @@ fn exercise(root: &Path) -> Result<()> { MetaDir::create(root, &installed)?; backup::with_database(root, |db| { db.query("CREATE TABLE acore_characters.manager_recovery_probe (id INT); INSERT INTO acore_characters.manager_recovery_probe VALUES (42); CREATE TABLE acore_world.manager_recovery_probe (id INT); INSERT INTO acore_world.manager_recovery_probe VALUES (43);")?; + db.query("CREATE VIEW acore_characters.manager_recovery_view AS SELECT id FROM acore_characters.manager_recovery_probe; CREATE TRIGGER acore_characters.manager_recovery_trigger BEFORE INSERT ON acore_characters.manager_recovery_probe FOR EACH ROW SET NEW.id=NEW.id+1; CREATE PROCEDURE acore_characters.manager_recovery_proc() SELECT id FROM acore_characters.manager_recovery_probe;")?; Ok(()) })?; let source = root.join("fixture-update-source"); @@ -54,13 +55,15 @@ fn exercise(root: &Path) -> Result<()> { cancel: Cancel::default(), resolutions: BTreeMap::new(), env: &env, fail_after_ops: None, }, &|step, pct| println!("{pct}% {step}" )).unwrap_err(); assert!(error.to_string().contains("probe_world"), "{error}"); - assert!(error.to_string().contains("were restored"), "{error}"); + assert!(error.to_string().contains("private update rehearsal failed"), "{error}"); assert!(!root.join("Core/recovery-probe.txt").exists()); assert!(update::unfinished(&meta).is_none()); assert_eq!(MetaDir::open(&meta)?.1.core.version.as_deref(), Some("1.0.0")); backup::with_database(root, |db| { assert_eq!(db.query("SELECT id FROM acore_characters.manager_recovery_probe;")?, "42"); assert_eq!(db.query("SELECT id FROM acore_world.manager_recovery_probe;")?, "43"); + assert_eq!(db.query("SELECT id FROM acore_characters.manager_recovery_view;")?, "42"); + assert_eq!(db.recovery_objects("acore_characters")?.iter().filter(|object| object["name"].as_str().is_some_and(|name| name.starts_with("manager_recovery_"))).count(), 3); assert!(!coa_core::migrations::status(db, &manifest.migrations)?.iter().any(|m| m.status == coa_core::migrations::Status::Applied)); Ok(()) })?; @@ -68,6 +71,6 @@ fn exercise(root: &Path) -> Result<()> { let point = backup::list(&meta).into_iter().find(|p| p.trigger == backup::Trigger::BeforeUpdate).ok_or_else(|| Error::Invalid("missing backup".into()))?; env.restore_snapshot(&point.id)?; assert_eq!(Db::from_repack(root, Account::Admin)?.realm(), coa_core::realms::Mode::Coa); - println!("PASS: failed SQL restored world, characters, ledger, files and version; repeated recovery succeeded"); + println!("PASS: private rehearsal caught failed SQL before changing the installed world, characters, ledger, objects, files or version; complete recovery succeeded"); Ok(()) } diff --git a/crates/coa-core/src/backup.rs b/crates/coa-core/src/backup.rs index 3914f23..51e68e4 100644 --- a/crates/coa-core/src/backup.rs +++ b/crates/coa-core/src/backup.rs @@ -67,6 +67,8 @@ pub struct RecoveryPoint { pub components: Vec, #[serde(default)] pub realm: crate::realms::Mode, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) mysql_snapshot: Option, } const LOCATION_FILE: &str = "backup-location.json"; @@ -334,6 +336,16 @@ pub fn create(root: &Path, meta: &Path, kind: Kind, trigger: Trigger, label: Opt return Err(e); } }; + let mysql_snapshot = if kind == Kind::Full && trigger == Trigger::BeforeUpdate && cfg!(windows) && !crate::docker::is_docker(root) { + progress("Saving a complete stopped MySQL recovery copy"); + match crate::mysql_snapshot::capture(root, &partial) { + Ok(snapshot) => Some(snapshot), + Err(error) => { + if let Ok(owned) = fsx::ensure_within(&backups_dir(meta), &partial) { let _ = fs::remove_dir_all(owned); } + return Err(error); + } + } + } else { None }; let point = RecoveryPoint { realm: crate::realms::state(root)?.active, schema: 1, @@ -344,6 +356,7 @@ pub fn create(root: &Path, meta: &Path, kind: Kind, trigger: Trigger, label: Opt created_at: chrono::Utc::now().to_rfc3339(), manager_version: crate::MANAGER_VERSION.into(), components, + mysql_snapshot, }; fsx::atomic_write_json(&partial.join("backup.json"), &point)?; fs::rename(&partial, &final_dir)?; @@ -375,6 +388,9 @@ pub fn verify(meta: &Path, id: &str) -> Result { let point = get(meta, id)?; let dir = point_dir(meta, id)?; let mut problems = Vec::new(); + if let Some(snapshot) = &point.mysql_snapshot { + if let Err(error) = crate::mysql_snapshot::verify(&dir, snapshot) { problems.push(error.to_string()); } + } let mut names = std::collections::BTreeSet::new(); let mut schemas = std::collections::BTreeSet::new(); for c in &point.components { diff --git a/crates/coa-core/src/diag.rs b/crates/coa-core/src/diag.rs index be21f31..a5ad4e8 100644 --- a/crates/coa-core/src/diag.rs +++ b/crates/coa-core/src/diag.rs @@ -405,6 +405,8 @@ pub fn export_package(root: &Path, meta_dir: &Path, manager_log: &Path, meta: &I .into_iter().filter(|(path, _, _)| path.file_name().is_some_and(|name| { let name = name.to_string_lossy(); name.starts_with("schema-validation-") || name.starts_with("database-recovery-") + || name.starts_with("database-rehearsal-") || name.starts_with("database-restore-") + || name.starts_with("database-repair-") })).take(10) { if size <= 4 * 1024 * 1024 { if let Ok(text) = fs::read_to_string(&path) { diff --git a/crates/coa-core/src/lib.rs b/crates/coa-core/src/lib.rs index 745faba..3476ebf 100644 --- a/crates/coa-core/src/lib.rs +++ b/crates/coa-core/src/lib.rs @@ -45,6 +45,7 @@ pub mod report; pub mod release_schema; pub mod update; mod update_isolation; +mod mysql_snapshot; pub mod wine; pub mod upnp; pub mod signing; diff --git a/crates/coa-core/src/mysql_snapshot.rs b/crates/coa-core/src/mysql_snapshot.rs new file mode 100644 index 0000000..bed8658 --- /dev/null +++ b/crates/coa-core/src/mysql_snapshot.rs @@ -0,0 +1,474 @@ +use std::{collections::{BTreeMap, BTreeSet}, fs, path::{Path, PathBuf}}; +use std::io::{BufReader, BufWriter, Write}; +use serde::{Deserialize, Serialize}; +use crate::{fsx, Error, Result}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Snapshot { + pub path: String, + pub files: BTreeMap, + pub directories: BTreeSet, + pub server_sha256: String, + #[serde(default)] + pub bytes: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub archive_sha256: Option, + #[serde(default)] + pub expanded_bytes: u64, +} + +fn reject_link(metadata: &fs::Metadata) -> Result<()> { + let linked = metadata.file_type().is_symlink(); + #[cfg(windows)] let linked = { use std::os::windows::fs::MetadataExt; linked || metadata.file_attributes() & 0x400 != 0 }; + if linked { return Err(Error::Invalid("Recovery copies cannot follow symbolic links or junctions.".into())); } + Ok(()) +} + +fn directories(root: &Path) -> Result> { + let mut result = BTreeSet::new(); + let mut pending = vec![root.to_path_buf()]; + while let Some(directory) = pending.pop() { + for entry in fs::read_dir(directory)? { + let entry = entry?; + reject_link(&fs::symlink_metadata(entry.path())?)?; + let path = fsx::ensure_within(root, &entry.path())?; + if entry.file_type()?.is_dir() { + let relative = path.strip_prefix(root).map_err(|e| Error::Invalid(e.to_string()))?.to_string_lossy().replace('\\', "/"); + result.insert(relative); + pending.push(path); + } + } + } + Ok(result) +} + +fn copy_snapshot(point: &Path, snapshot: &Snapshot, target: &Path) -> Result<()> { + if snapshot.archive_sha256.is_some() { + verify(point, snapshot)?; + if target.exists() { return Err(Error::Invalid("The recovery destination already exists.".into())); } + let parent = target.parent().ok_or_else(|| Error::Invalid("Invalid recovery destination.".into()))?; + fs::create_dir_all(parent)?; + fsx::require_space(parent, snapshot.expanded_bytes)?; + fs::create_dir(target)?; + let input = fsx::ensure_within(point, &fsx::safe_join(point, &snapshot.path)?)?; + let decoder = zstd::Decoder::new(BufReader::new(fs::File::open(input)?))?; + let mut archive = tar::Archive::new(decoder); + let mut seen = BTreeSet::new(); + let mut expanded = 0u64; + for entry in archive.entries()? { + let mut entry = entry?; + let name = entry.path()?.to_string_lossy().trim_end_matches('/').to_string(); + if !seen.insert(name.clone()) { return Err(Error::Invalid("Duplicate entry in MySQL recovery archive.".into())); } + let output = fsx::ensure_within(target, &fsx::safe_join(target, &name)?)?; + if entry.header().entry_type().is_dir() && snapshot.directories.contains(&name) { + fs::create_dir_all(output)?; + } else if entry.header().entry_type().is_file() && snapshot.files.contains_key(&name) { + expanded = expanded.checked_add(entry.size()).filter(|bytes| *bytes <= snapshot.expanded_bytes) + .ok_or_else(|| Error::Invalid("MySQL recovery archive exceeds its declared size.".into()))?; + fs::create_dir_all(output.parent().unwrap())?; + let mut file = fs::OpenOptions::new().write(true).create_new(true).open(output)?; + if std::io::copy(&mut entry, &mut file)? != entry.size() { return Err(Error::Invalid("Truncated MySQL recovery file.".into())); } + file.flush()?; + } else { return Err(Error::Invalid("Unexpected entry in MySQL recovery archive.".into())); } + } + if expanded != snapshot.expanded_bytes || inventory(target)? != snapshot.files { + return Err(Error::Invalid("MySQL recovery files failed verification.".into())); + } + } else { copy_verified(&point.join(&snapshot.path), target, &snapshot.files)?; } + for directory in &snapshot.directories { + fs::create_dir_all(fsx::ensure_within(target, &fsx::safe_join(target, directory)?)?)?; + } + if directories(target)? != snapshot.directories { return Err(Error::Invalid("The recovery directory inventory differs.".into())); } + Ok(()) +} + +fn compress(source: &Path, point: &Path, snapshot: &mut Snapshot) -> Result<()> { + snapshot.path = "mysql-data.tar.zst".into(); + let path = fsx::safe_join(point, &snapshot.path)?; + fs::create_dir_all(point)?; + fsx::require_space(point, snapshot.expanded_bytes)?; + let file = fs::OpenOptions::new().write(true).create_new(true).open(&path)?; + let encoder = zstd::Encoder::new(BufWriter::new(file), 3)?; + let mut archive = tar::Builder::new(encoder); + for directory in &snapshot.directories { + archive.append_dir(directory, fsx::ensure_within(source, &fsx::safe_join(source, directory)?)?)?; + } + for name in snapshot.files.keys() { + let input = fsx::ensure_within(source, &fsx::safe_join(source, name)?)?; + archive.append_file(name, &mut fs::File::open(input)?)?; + } + archive.into_inner()?.finish()?.flush()?; + snapshot.archive_sha256 = Some(fsx::sha256_file(&path)?); + snapshot.bytes = fs::metadata(path)?.len(); + Ok(()) +} + +pub(crate) fn inventory(root: &Path) -> Result> { + let mut files = BTreeMap::new(); + let mut directories = vec![root.to_path_buf()]; + while let Some(directory) = directories.pop() { + for entry in fs::read_dir(directory)? { + let entry = entry?; + let metadata = fs::symlink_metadata(entry.path())?; + let path = fsx::ensure_within(root, &entry.path())?; + reject_link(&metadata)?; + if metadata.is_dir() { directories.push(path); continue; } + if !metadata.is_file() { return Err(Error::Invalid("Unsupported database snapshot file.".into())); } + let relative = path.strip_prefix(root).map_err(|e| Error::Invalid(e.to_string()))?.to_string_lossy().replace('\\', "/"); + fsx::safe_join(root, &relative)?; + if relative.ends_with(".isl") { return Err(Error::Invalid("External MySQL tablespaces require a separate recovery method.".into())); } + files.insert(relative, fsx::sha256_file(&path)?); + } + } + Ok(files) +} + +pub(crate) fn copy_verified(source: &Path, target: &Path, files: &BTreeMap) -> Result<()> { + if target.exists() { return Err(Error::Invalid("Database snapshot destination already exists.".into())); } + let bytes = files.keys().try_fold(0u64, |total, name| -> Result { + let path = fsx::ensure_within(source, &fsx::safe_join(source, name)?)?; + total.checked_add(fs::metadata(path)?.len()).ok_or_else(|| Error::Invalid("Snapshot size overflow.".into())) + })?; + let parent = target.parent().ok_or_else(|| Error::Invalid("Invalid snapshot destination.".into()))?; + fs::create_dir_all(parent)?; + fsx::require_space(parent, bytes)?; + fs::create_dir(target)?; + for (relative, expected) in files { + let source_file = fsx::ensure_within(source, &fsx::safe_join(source, relative)?)?; + let target_file = fsx::ensure_within(target, &fsx::safe_join(target, relative)?)?; + fs::create_dir_all(target_file.parent().unwrap())?; + fs::copy(&source_file, &target_file)?; + if fsx::sha256_file(&target_file)? != *expected { return Err(Error::Invalid(format!("Database snapshot file {relative} failed verification."))); } + } + if inventory(target)? != *files { return Err(Error::Invalid("Database snapshot inventory differs.".into())); } + Ok(()) +} + +pub(crate) fn stop(root: &Path) -> Result<()> { + let observed = crate::process::observe(root, &crate::layout::read_ports(root)); + if [observed.mysql.state, observed.auth.state, observed.world.state].iter().all(|state| *state == crate::process::ServiceState::Stopped) + && !crate::multiworld::is_running(root) { return Ok(()); } + let outcome = crate::driver::run(root, crate::driver::Verb::StopAll)?; + let status = crate::process::observe(root, &crate::layout::read_ports(root)); + if !outcome.ok || [status.mysql.state, status.auth.state, status.world.state].iter().any(|state| *state != crate::process::ServiceState::Stopped) + || crate::multiworld::is_running(root) { + return Err(Error::Invalid("The complete server must be stopped before copying or restoring MySQL.".into())); + } + Ok(()) +} + +pub(crate) fn stop_previous_rehearsals(meta: &Path) -> Result<()> { + let location = meta.join("rehearsals"); + if !location.exists() { return Ok(()); } + for entry in fs::read_dir(&location)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().into_owned(); + if name.len() != 32 || !name.bytes().all(|byte| byte.is_ascii_hexdigit()) { continue; } + let fixture = fsx::ensure_within(&location, &entry.path().join("server"))?; + let metadata = crate::registry::metadata_dir_for(&fixture)?; + if !metadata.join("install.json").is_file() { continue; } + let (_, installed) = crate::registry::MetaDir::open(&metadata)?; + if fsx::canonicalize_lenient(Path::new(&installed.server_path))? != fsx::canonicalize_lenient(&fixture)? { + return Err(Error::Invalid("The interrupted rehearsal metadata does not match its private server.".into())); + } + stop(&fixture)?; + } + Ok(()) +} + +pub(crate) fn capture(root: &Path, partial: &Path) -> Result { + if crate::docker::is_docker(root) || !cfg!(windows) { return Err(Error::Invalid("Cold MySQL snapshots require a Windows repack.".into())); } + crate::backup::with_database(root, |db| { + if db.query("SELECT COUNT(*) FROM performance_schema.replication_connection_configuration;")?.trim() != "0" { + return Err(Error::Invalid("A replicated MySQL instance requires a separate isolated recovery method.".into())); + } + let data = fsx::ensure_within(root, &root.join("mysql/data"))?; + for path in db.query("SELECT PATH FROM information_schema.INNODB_DATAFILES;")?.lines() { + fsx::ensure_within(&data, &data.join(path))?; + } + Ok(()) + })?; + stop(root)?; + let data = fsx::ensure_within(root, &root.join("mysql/data"))?; + if !data.join("mysql").is_dir() || !data.join("ibdata1").is_file() { + return Err(Error::Invalid("The bundled MySQL data directory is incomplete.".into())); + } + let files = inventory(&data)?; + let bytes = files.keys().try_fold(0u64, |total, file| -> Result { + total.checked_add(fs::metadata(fsx::safe_join(&data, file)?)?.len()).ok_or_else(|| Error::Invalid("Snapshot size overflow.".into())) + })?; + let mut snapshot = Snapshot { path: "mysql-data".into(), files, directories:directories(&data)?, server_sha256: fsx::sha256_file(&root.join("mysql/bin/mysqld.exe"))?, bytes, expanded_bytes:bytes, archive_sha256:None }; + compress(&data, partial, &mut snapshot)?; + verify(partial, &snapshot)?; + Ok(snapshot) +} + +pub(crate) fn verify(point: &Path, snapshot: &Snapshot) -> Result<()> { + if snapshot.files.is_empty() || snapshot.server_sha256.len() != 64 { + return Err(Error::Invalid("Invalid cold MySQL recovery inventory.".into())); + } + let data = fsx::ensure_within(point, &fsx::safe_join(point, &snapshot.path)?)?; + if let Some(hash) = &snapshot.archive_sha256 { + if snapshot.path != "mysql-data.tar.zst" || snapshot.expanded_bytes == 0 || fsx::sha256_file(&data)? != *hash { + return Err(Error::Invalid("The compressed MySQL recovery copy is damaged.".into())); + } + for name in snapshot.files.keys().chain(snapshot.directories.iter()) { fsx::safe_join(point, name)?; } + return Ok(()); + } + if snapshot.path != "mysql-data" { return Err(Error::Invalid("Unsupported MySQL recovery format.".into())); } + if inventory(&data)? != snapshot.files { return Err(Error::Invalid("The cold MySQL recovery copy is damaged or incomplete.".into())); } + if directories(&data)? != snapshot.directories { return Err(Error::Invalid("The cold MySQL recovery directories are incomplete.".into())); } + Ok(()) +} + +pub(crate) fn rehearsal_copy(root: &Path, meta: &Path, point: &Path, snapshot: &Snapshot) -> Result { + verify(point, snapshot)?; + let destination = meta.join("rehearsals").join(uuid::Uuid::new_v4().simple().to_string()).join("server"); + let mut files = BTreeMap::new(); + let mut directories = vec![root.to_path_buf()]; + while let Some(directory) = directories.pop() { + for entry in fs::read_dir(directory)? { + let entry = entry?; + let raw = entry.path(); + let relative = raw.strip_prefix(root).map_err(|e| Error::Invalid(e.to_string()))?.to_string_lossy().replace('\\', "/"); + let lower = relative.to_ascii_lowercase(); + let first = lower.split('/').next().unwrap(); + if entry.file_type()?.is_dir() && !relative.contains('/') + && !["core", "data", "scripts", "settings", "runtime", "mysql", ".realms", "bugreport", "extras", "licenses"].contains(&first) { continue; } + if lower == "mysql/data" || lower.starts_with("mysql/data-") + || lower.split('/').any(|part| part == ".state") + || lower == "bugreport/reports" || lower == "core/logs" || lower == "mysql/logs" { continue; } + let metadata = fs::symlink_metadata(&raw)?; + reject_link(&metadata)?; + let path = fsx::ensure_within(root, &raw)?; + if metadata.is_dir() { directories.push(path); } + else if metadata.is_file() { files.insert(relative, fsx::sha256_file(&path)?); } + else { return Err(Error::Invalid("Unsupported rehearsal file.".into())); } + } + } + copy_verified(root, &destination, &files)?; + copy_snapshot(point, snapshot, &destination.join("mysql/data"))?; + let (_, mut installed) = crate::registry::MetaDir::open(meta)?; + installed.id = uuid::Uuid::new_v4().to_string(); + installed.server_path = destination.to_string_lossy().into_owned(); + installed.client_path = None; + crate::registry::MetaDir::create(&destination, &installed)?; + Ok(destination) +} + +pub(crate) fn isolate_connections(root: &Path) -> Result<()> { + for relative in ["Settings/worldserver.conf.template", "Settings/authserver.conf.template", "Core/configs/worldserver.conf", "Core/configs/authserver.conf"] { + let path = fsx::ensure_within(root, &root.join(relative))?; + if !path.is_file() { + if relative.ends_with(".template") { return Err(Error::Invalid("The private update copy lacks connection templates.".into())); } + continue; + } + let mut config = crate::config::parser::ConfFile::parse_bytes(&fs::read(&path)?)?; + for key in ["LoginDatabaseInfo", "WorldDatabaseInfo", "CharacterDatabaseInfo"] { + if relative.contains("worldserver") || key == "LoginDatabaseInfo" { + config.set(key, &format!("\"@{key}@\""), &[]); + } + } + config.set("BindIP", "\"127.0.0.1\"", &[]); + if relative.contains("worldserver") { + config.set("Ra.IP", "\"127.0.0.1\"", &[]); + config.set("DataDir", "\"@DATA@\"", &[]); + } + fsx::atomic_write(&path, config.to_text().as_bytes())?; + } + Ok(()) +} + +pub(crate) fn unchanged(root: &Path, snapshot: &Snapshot) -> Result<()> { + let data = fsx::ensure_within(root, &root.join("mysql/data"))?; + if inventory(&data)? != snapshot.files || directories(&data)? != snapshot.directories { + return Err(Error::Invalid("The installed database changed during the private rehearsal. Retry after stopping other database access.".into())); + } + Ok(()) +} + +pub(crate) fn validate_connections(templates: &Path, installation: &Path) -> Result<()> { + let port = crate::layout::read_ports(installation).mysql.to_string(); + let (_, password) = crate::db::credentials(installation)?; + let realm = crate::realms::state(installation)?.active; + for relative in ["Settings/worldserver.conf.template", "Settings/authserver.conf.template"] { + let path = fsx::ensure_within(templates, &templates.join(relative))?; + let config = crate::config::parser::ConfFile::parse_bytes(&fs::read(path)?)?; + for (key, kind) in [("LoginDatabaseInfo", "auth"), ("WorldDatabaseInfo", "world"), ("CharacterDatabaseInfo", "characters")] { + if relative.contains("authserver") && kind != "auth" { continue; } + let value = config.get(key).ok_or_else(|| Error::Invalid(format!("The update cannot isolate the missing {key} connection.")))?.trim().trim_matches('"'); + if value == format!("@{key}@") { continue; } + let fields: Vec<_> = value.split(';').collect(); + if fields.len() != 5 || !matches!(fields[0], "127.0.0.1" | "localhost") || fields[1] != port || fields[2] != "acore" || fields[3] != password || fields[4] != realm.schema(kind)? { + return Err(Error::Invalid(format!("The {key} connection does not use this repack's bundled database. A private update cannot safely qualify an external database."))); + } + } + } + Ok(()) +} + +// Keep the replaced data directory. If interrupted between renames, a retry can install a new verified copy. +pub(crate) fn swap(root: &Path, meta: &Path, point: &Path, snapshot: &Snapshot, fail_after_move: bool) -> Result { + verify(point, snapshot)?; + if fsx::sha256_file(&root.join("mysql/bin/mysqld.exe"))? != snapshot.server_sha256 { + return Err(Error::Invalid("The MySQL executable differs from the recovery point.".into())); + } + let mysql = fsx::ensure_within(root, &root.join("mysql"))?; + let token = uuid::Uuid::new_v4().simple().to_string(); + let staging = fsx::ensure_within(&mysql, &mysql.join(format!("data-restore-{token}")))?; + let previous = fsx::ensure_within(&mysql, &mysql.join(format!("data-before-restore-{token}")))?; + let live = fsx::ensure_within(&mysql, &mysql.join("data"))?; + copy_snapshot(point, snapshot, &staging)?; + let journal = meta.join("diagnostics").join(format!("database-restore-{token}.json")); + fsx::atomic_write_json(&journal, &serde_json::json!({"schema":1,"state":"prepared","previous":previous,"staging":staging}))?; + if live.exists() { fs::rename(&live, &previous)?; } + if fail_after_move { return Err(Error::Invalid("Simulated interruption after saving the previous MySQL directory.".into())); } + fs::rename(&staging, &live)?; + fsx::atomic_write_json(&journal, &serde_json::json!({"schema":1,"state":"restored","previous":previous}))?; + Ok(previous) +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(windows)] + #[test] + #[ignore = "requires COA_MYSQL_TEST_BIN pointing to the bundled MySQL tools"] + fn real_mysql_restores_objects_users_rows_and_missing_defaults() { + use std::process::{Child, Command, Stdio}; + use std::os::windows::process::CommandExt; + struct Server(Child); + impl Drop for Server { fn drop(&mut self) { let _ = self.0.kill(); let _ = self.0.wait(); } } + let bin = PathBuf::from(std::env::var("COA_MYSQL_TEST_BIN").unwrap()); + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("server"); + let meta = temp.path().join("metadata"); + let point = temp.path().join("point"); + copy_verified(&bin, &root.join("mysql/bin"), &inventory(&bin).unwrap()).unwrap(); + let data = root.join("mysql/data"); + let executable = root.join("mysql/bin/mysqld.exe"); + let initialized = Command::new(&executable).args(["--no-defaults", "--initialize-insecure"]) + .arg(format!("--datadir={}", data.display())).creation_flags(0x0800_0000).output().unwrap(); + assert!(initialized.status.success(), "{}", String::from_utf8_lossy(&initialized.stderr)); + let port = crate::install::free_port().unwrap(); + fsx::atomic_write_json(&root.join("Settings/repack.json"), &serde_json::json!({"mysqlPort":port})).unwrap(); + fsx::atomic_write_json(&root.join("Settings/database.json"), &serde_json::json!({"rootPassword":"","appPassword":""})).unwrap(); + let db = crate::db::Db::from_repack(&root, crate::db::Account::Admin).unwrap(); + let start = || { + let server = Server(Command::new(&executable).args(["--no-defaults", "--bind-address=127.0.0.1", "--mysqlx=0", "--skip-log-bin"]) + .arg(format!("--datadir={}", data.display())).arg(format!("--port={port}")) + .arg(format!("--log-error={}", root.join("mysql-test.log").display())) + .stdout(Stdio::null()).stderr(Stdio::null()).creation_flags(0x0800_0000).spawn().unwrap()); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while !db.ping() { assert!(std::time::Instant::now() < deadline, "{}", fs::read_to_string(root.join("mysql-test.log")).unwrap_or_default()); std::thread::sleep(std::time::Duration::from_millis(200)); } + server + }; + let mut server = start(); + db.query("CREATE DATABASE acore_characters; CREATE DATABASE acore_world; CREATE DATABASE acore_auth; CREATE TABLE acore_characters.probe(id INT PRIMARY KEY, note VARCHAR(40)); CREATE TABLE acore_world.probe(id INT NOT NULL DEFAULT 0); INSERT INTO acore_world.probe VALUES(43); CREATE TABLE acore_auth.probe(id INT); INSERT INTO acore_auth.probe VALUES(44); CREATE USER 'fixture_user'@'localhost' IDENTIFIED BY 'fixture'; CREATE TRIGGER acore_characters.probe_insert BEFORE INSERT ON acore_characters.probe FOR EACH ROW SET NEW.note='triggered'; INSERT INTO acore_characters.probe VALUES(42,'original'); CREATE VIEW acore_characters.probe_view AS SELECT c.id,w.id AS world_id FROM acore_characters.probe c CROSS JOIN acore_world.probe w; CREATE PROCEDURE acore_characters.probe_proc() SELECT id FROM acore_characters.probe; CREATE FUNCTION acore_characters.probe_func() RETURNS INT DETERMINISTIC RETURN 73;").unwrap(); + assert_eq!(db.recovery_objects("acore_characters").unwrap().len(), 4); + crate::schema_check::capture(&db, &root).unwrap(); + let hash = fsx::sha256_file(&root.join(crate::schema_check::CONTRACT)).unwrap(); + db.query("ALTER TABLE acore_world.probe ALTER COLUMN id DROP DEFAULT;").unwrap(); + crate::schema_check::repair_missing_defaults(&db, &root, Some(&hash)).unwrap(); + assert_eq!(db.query("SELECT COLUMN_DEFAULT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='acore_world' AND TABLE_NAME='probe' AND COLUMN_NAME='id';").unwrap(), "0"); + assert_eq!(db.query("SELECT id FROM acore_world.probe;").unwrap(), "43"); + db.query("ALTER TABLE acore_world.probe ALTER COLUMN id SET DEFAULT 99;").unwrap(); + crate::schema_check::repair_missing_defaults(&db, &root, Some(&hash)).unwrap(); + assert_eq!(db.query("SELECT COLUMN_DEFAULT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='acore_world' AND TABLE_NAME='probe' AND COLUMN_NAME='id';").unwrap(), "99"); + db.query("SHUTDOWN;").unwrap(); server.0.wait().unwrap(); + let files = inventory(&data).unwrap(); + copy_verified(&data, &point.join("mysql-data"), &files).unwrap(); + let expanded_bytes = files.keys().map(|name| fs::metadata(data.join(name)).unwrap().len()).sum(); + let mut snapshot = Snapshot {path:"mysql-data".into(), files, directories:directories(&data).unwrap(), server_sha256:fsx::sha256_file(&executable).unwrap(), bytes:0, expanded_bytes, archive_sha256:None}; + for directory in &snapshot.directories { fs::create_dir_all(point.join("mysql-data").join(directory)).unwrap(); } + compress(&data, &point, &mut snapshot).unwrap(); + let mut changed = start(); + db.query("DROP VIEW acore_characters.probe_view; DROP TRIGGER acore_characters.probe_insert; DROP PROCEDURE acore_characters.probe_proc; DROP FUNCTION acore_characters.probe_func; DELETE FROM acore_characters.probe; DROP USER 'fixture_user'@'localhost';").unwrap(); + db.query("SHUTDOWN;").unwrap(); changed.0.wait().unwrap(); + swap(&root, &meta, &point, &snapshot, false).unwrap(); + let mut restored = start(); + assert_eq!(db.recovery_objects("acore_characters").unwrap().len(), 4); + assert_eq!(db.query("SELECT * FROM acore_characters.probe_view; CALL acore_characters.probe_proc(); SELECT acore_characters.probe_func(); SELECT COUNT(*) FROM mysql.user WHERE user='fixture_user';").unwrap().replace("\r\n", "\n"), "42\t43\n42\n73\n1"); + db.query("INSERT INTO acore_characters.probe VALUES(45,'check');").unwrap(); + assert_eq!(db.query("SELECT note FROM acore_characters.probe WHERE id=45;").unwrap(), "triggered"); + db.query("SHUTDOWN;").unwrap(); restored.0.wait().unwrap(); + } + fn fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf, Snapshot) { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("server"); + let meta = temp.path().join("metadata"); + let point = temp.path().join("point"); + fsx::atomic_write(&root.join("mysql/bin/mysqld.exe"), b"same-engine").unwrap(); + fsx::atomic_write(&root.join("mysql/data/characters.ibd"), b"live").unwrap(); + fsx::atomic_write(&point.join("mysql-data/characters.ibd"), b"saved").unwrap(); + fs::create_dir_all(point.join("mysql-data/empty_schema")).unwrap(); + let snapshot = Snapshot { path:"mysql-data".into(), files:inventory(&point.join("mysql-data")).unwrap(), directories:directories(&point.join("mysql-data")).unwrap(), server_sha256:fsx::sha256_file(&root.join("mysql/bin/mysqld.exe")).unwrap(), bytes:5, expanded_bytes:5, archive_sha256:None }; + (temp, root, meta, point, snapshot) + } + #[test] + fn interrupted_directory_swap_is_recoverable_and_keeps_previous_bytes() { + let (_temp, root, meta, point, snapshot) = fixture(); + assert!(swap(&root, &meta, &point, &snapshot, true).is_err()); + assert!(!root.join("mysql/data").exists()); + let old = fs::read_dir(root.join("mysql")).unwrap().flatten().find(|e| e.file_name().to_string_lossy().starts_with("data-before-")).unwrap().path(); + assert_eq!(fs::read(old.join("characters.ibd")).unwrap(), b"live"); + swap(&root, &meta, &point, &snapshot, false).unwrap(); + assert_eq!(fs::read(root.join("mysql/data/characters.ibd")).unwrap(), b"saved"); + assert!(root.join("mysql/data/empty_schema").is_dir()); + } + #[test] + fn corrupt_or_different_engine_snapshots_do_not_move_live_data() { + let (_temp, root, meta, point, snapshot) = fixture(); + fsx::atomic_write(&point.join("mysql-data/characters.ibd"), b"corrupt").unwrap(); + assert!(swap(&root, &meta, &point, &snapshot, false).is_err()); + assert_eq!(fs::read(root.join("mysql/data/characters.ibd")).unwrap(), b"live"); + fsx::atomic_write(&point.join("mysql-data/characters.ibd"), b"saved").unwrap(); + fsx::atomic_write(&root.join("mysql/bin/mysqld.exe"), b"new-engine").unwrap(); + assert!(swap(&root, &meta, &point, &snapshot, false).is_err()); + assert_eq!(fs::read(root.join("mysql/data/characters.ibd")).unwrap(), b"live"); + } + #[test] + fn compressed_snapshots_verify_every_file_and_reject_unlisted_entries() { + let (_temp, root, meta, point, mut snapshot) = fixture(); + let source = point.join("mysql-data"); + compress(&source, &point, &mut snapshot).unwrap(); + assert!(snapshot.archive_sha256.is_some()); + swap(&root, &meta, &point, &snapshot, false).unwrap(); + assert_eq!(fs::read(root.join("mysql/data/characters.ibd")).unwrap(), b"saved"); + fsx::atomic_write(&root.join("mysql/data/characters.ibd"), b"preserve").unwrap(); + let archive_path = point.join(&snapshot.path); + let encoder = zstd::Encoder::new(fs::File::create(&archive_path).unwrap(), 3).unwrap(); + let mut archive = tar::Builder::new(encoder); + archive.append_file("unlisted.ibd", &mut fs::File::open(source.join("characters.ibd")).unwrap()).unwrap(); + archive.into_inner().unwrap().finish().unwrap(); + snapshot.archive_sha256 = Some(fsx::sha256_file(&archive_path).unwrap()); + assert!(swap(&root, &meta, &point, &snapshot, false).is_err()); + assert_eq!(fs::read(root.join("mysql/data/characters.ibd")).unwrap(), b"preserve"); + fsx::atomic_write(&archive_path, b"damaged").unwrap(); + assert!(verify(&point, &snapshot).is_err()); + } + #[test] + fn private_rehearsals_reject_external_database_connections() { + let (_temp, root, _meta, _point, _snapshot) = fixture(); + fsx::atomic_write_json(&root.join("Settings/repack.json"), &serde_json::json!({"mysqlPort":3307})).unwrap(); + fsx::atomic_write_json(&root.join("Settings/database.json"), &serde_json::json!({"rootPassword":"test-root","appPassword":"test-app"})).unwrap(); + fsx::atomic_write(&root.join("Settings/worldserver.conf.template"), b"LoginDatabaseInfo = \"@LoginDatabaseInfo@\"\nWorldDatabaseInfo = \"@WorldDatabaseInfo@\"\nCharacterDatabaseInfo = \"@CharacterDatabaseInfo@\"\n").unwrap(); + fsx::atomic_write(&root.join("Settings/authserver.conf.template"), b"LoginDatabaseInfo = \"127.0.0.1;3307;acore;test-app;acore_auth\"\n").unwrap(); + validate_connections(&root, &root).unwrap(); + fsx::atomic_write(&root.join("Settings/authserver.conf.template"), b"LoginDatabaseInfo = \"192.0.2.1;3307;acore;test-app;acore_auth\"\n").unwrap(); + let error = validate_connections(&root, &root).unwrap_err().to_string(); + assert!(error.contains("external database")); + assert!(!error.contains("test-app")); + } + #[test] + fn a_changed_live_database_invalidates_the_rehearsal() { + let (_temp, root, _meta, _point, snapshot) = fixture(); + assert!(unchanged(&root, &snapshot).is_err()); + fsx::atomic_write(&root.join("mysql/data/characters.ibd"), b"saved").unwrap(); + fs::create_dir_all(root.join("mysql/data/empty_schema")).unwrap(); + unchanged(&root, &snapshot).unwrap(); + fsx::atomic_write(&root.join("mysql/data/extra.ibd"), b"new data").unwrap(); + assert!(unchanged(&root, &snapshot).is_err()); + } +} diff --git a/crates/coa-core/src/release_schema.rs b/crates/coa-core/src/release_schema.rs index d784a1d..c79f57c 100644 --- a/crates/coa-core/src/release_schema.rs +++ b/crates/coa-core/src/release_schema.rs @@ -13,6 +13,7 @@ impl crate::update::Env for QualificationEnv<'_> { fn preflight(&self, manifest: &crate::manifest::Manifest) -> Result<()> { self.0.preflight(manifest) } fn snapshot(&self) -> Result { self.0.snapshot() } fn verify_snapshot(&self, id: &str) -> Result<()> { self.0.verify_snapshot(id) } + fn rehearse(&self, manifest: &crate::manifest::Manifest, tree: &Path, items: &[crate::update::PlanItem], point: &str) -> Result<()> { self.0.rehearse(manifest, tree, items, point) } fn restore_snapshot(&self, id: &str) -> Result<()> { self.0.restore_snapshot(id) } fn migrate(&self, manifest: &crate::manifest::Manifest, staged: &Path) -> Result { self.0.migrate(manifest, staged) } fn validate(&self) -> Result<()> { self.0.validate() } @@ -76,7 +77,7 @@ pub fn qualify_upgrade(base: &Path, source: &Path, candidate: &Path, fixture: &P "schema": 1, "result": "passed", "fromVersion": source_manifest.version, "toVersion": candidate_manifest.version, "fromManifestSha256": fsx::sha256_bytes(&source_bytes), "fromSchemaSha256": source_manifest.files.iter().find(|file| file.path == schema_check::CONTRACT).map(|file| &file.sha256), - "checks": ["manager-transaction", "database-schema", "private-auth-world-startup", "user-file-preservation"], + "checks": ["manager-transaction", "private-user-database-rehearsal", "database-schema", "private-auth-world-startup", "existing-identities", "user-file-preservation"], })) } diff --git a/crates/coa-core/src/schema_check.rs b/crates/coa-core/src/schema_check.rs index c00244d..8e692af 100644 --- a/crates/coa-core/src/schema_check.rs +++ b/crates/coa-core/src/schema_check.rs @@ -85,6 +85,46 @@ pub fn capture(db: &Db, root: &Path) -> Result<()> { fsx::atomic_write_json(&root.join(CONTRACT), &Contract { schema: 1, columns }) } +fn missing_default(expected: &str, actual: &str) -> Option { + let expected: Vec<_> = expected.split('|').collect(); + let actual: Vec<_> = actual.split('|').collect(); + if expected.len() != 4 || actual.len() != 4 || expected[0] != actual[0] + || expected[1] != "NO" || actual[1] != "NO" || expected[3] != "" || actual[3] != "" + || actual[2] != "" || matches!(expected[2], "" | "") { return None; } + String::from_utf8(hex::decode(expected[2]).ok()?).ok() +} + +/// Only restore a missing literal default from the exact signed target contract. Never alter data or custom defaults. +pub(crate) fn repair_missing_defaults(db: &Db, root: &Path, signed_hash: Option<&str>) -> Result<()> { + let Some(signed_hash) = signed_hash else { return Ok(()); }; + if fsx::sha256_file(&root.join(CONTRACT))? != signed_hash { + return Err(crate::Error::Invalid("The target schema contract differs from the signed update.".into())); + } + let contract = require_release_contract(root)?; + let mut repaired = Vec::new(); + let quote = |identifier: &str| format!("`{}`", identifier.replace('`', "``")); + for (kind, tables) in &contract.columns { + let schema = db.realm_schema(crate::db::schema_of(kind)?); + let actual = read_columns(db, kind)?; + let base_tables: BTreeSet<_> = db.tables(crate::db::schema_of(kind)?)?.into_iter().collect(); + for (table, columns) in tables { + if !base_tables.contains(table) { continue; } + if matches!(table.as_str(), "updates" | "updates_include" | "coa_manager_migrations") { continue; } + for (column, expected) in columns { + let Some(found) = actual.get(table).and_then(|columns| columns.get(column)) else { continue; }; + let Some(value) = missing_default(expected, found) else { continue; }; + db.query(&format!("SET SESSION sql_mode=CONCAT_WS(',',@@SESSION.sql_mode,'NO_BACKSLASH_ESCAPES'); ALTER TABLE {}.{} ALTER COLUMN {} SET DEFAULT '{}';", quote(schema), quote(table), quote(column), value.replace('\'', "''")))?; + repaired.push(serde_json::json!({"database":schema,"table":table,"column":column,"action":"restore-missing-default"})); + } + } + } + if !repaired.is_empty() { + let path = crate::registry::metadata_dir_for(root)?.join("diagnostics").join(format!("database-repair-{}.json", uuid::Uuid::new_v4())); + fsx::atomic_write_json(&path, &serde_json::json!({"schema":1,"checkedAt":chrono::Utc::now().to_rfc3339(),"contractSha256":signed_hash,"repairs":repaired}))?; + } + Ok(()) +} + pub fn check(db: &Db, root: &Path) -> Result> { let contract = if root.join(CONTRACT).is_file() { let contract: Contract = fsx::read_json(&root.join(CONTRACT))?; @@ -175,6 +215,15 @@ fn missing_coa_starts(rows: &str) -> Vec { #[cfg(test)] mod tests { use super::*; + #[test] + fn default_repair_only_accepts_missing_literal_defaults_without_other_schema_changes() { + assert_eq!(missing_default("int unsigned|NO|30|", "int unsigned|NO||"), Some("0".into())); + for actual in ["int unsigned|NO|3939|", "int|NO||", "int unsigned|YES||", "int unsigned|NO||6175746f5f696e6372656d656e74"] { + assert!(missing_default("int unsigned|NO|30|", actual).is_none()); + } + assert!(missing_default("timestamp|NO|43555252454e545f54494d455354414d50|44454641554c545f47454e455241544544", "timestamp|NO||").is_none()); + assert!(missing_default("int|NO|zz|", "int|NO||").is_none()); + } #[test] fn schema_defaults_are_readable_and_not_conflated() { diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index a1a3aff..b91bf0d 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -92,6 +92,7 @@ pub trait Env { fn ensure_stopped(&self) -> Result<()>; fn preflight(&self, _manifest: &Manifest) -> Result<()> { Ok(()) } fn verify_snapshot(&self, _id: &str) -> Result<()> { Ok(()) } + fn rehearse(&self, _manifest: &Manifest, _tree: &Path, _items: &[PlanItem], _point: &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<()>; @@ -493,6 +494,14 @@ pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result { 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 = crate::backup::point_json(meta_dir, txn.recovery_point.as_deref().unwrap())?; if point_meta.is_file() { txn.recovery_hashes.insert("@point".into(), fsx::sha256_file(&point_meta)?); } + save(meta_dir, &txn)?; + step(report, "Testing the update on a private copy of your server", 54); + if let Err(error) = p.env.rehearse(&m, &tree, &items, txn.recovery_point.as_deref().unwrap()) { + txn.state = State::Failed; + txn.message = Some(format!("The private update rehearsal failed: {error}. The installed server was not updated.")); + save(meta_dir, &txn)?; + return Err(Error::Invalid(txn.message.unwrap())); + } txn.ops = items .iter() @@ -516,7 +525,7 @@ pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result { // MySQL DDL can commit before a later statement fails. Restore the full recovery point on failure. let mut migrated = None; - if !m.migrations.is_empty() { + if !m.migrations.is_empty() || m.files.iter().any(|file| file.path == crate::schema_check::CONTRACT) { step(report, "Updating the database", 75); txn.databases_started = true; save(meta_dir, &txn)?; @@ -535,6 +544,9 @@ pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result { } step(report, "Starting the updated server", 85); + // Core startup can apply SQL and write character data even when the package has no explicit migrations. + txn.databases_started = true; + save(meta_dir, &txn)?; if let Err(e) = p.env.validate() { if p.env.automatic_rollback() { return fail_after_apply(p, &mut txn, &before, &tree, @@ -849,6 +861,12 @@ impl RepackEnv<'_> { return Err(Error::Invalid("The update recovery point does not contain every required database and configuration.".into())); } if !crate::backup::verify(self.meta_dir, &point.id)?.ok { return Err(Error::Invalid("The recovery point failed verification.".into())); } + if let Some(snapshot) = &point.mysql_snapshot { + if fsx::sha256_file(&self.root.join("mysql/bin/mysqld.exe"))? != snapshot.server_sha256 { + return Err(Error::Invalid("The MySQL executable differs from the recovery point.".into())); + } + return Ok(()); + } crate::backup::with_database(self.root, |db| { let db = db.clone().for_realm(crate::realms::Mode::Coa); let mut objects = Vec::new(); @@ -894,7 +912,79 @@ impl Env for RepackEnv<'_> { } fn automatic_rollback(&self) -> bool { true } + fn rehearse(&self, manifest: &Manifest, tree: &Path, items: &[PlanItem], id: &str) -> Result<()> { + if items.iter().any(|item| item.action != Action::Skip && item.path.to_ascii_lowercase().starts_with("mysql/")) { + return Err(Error::Invalid("A server update cannot replace MySQL while using a cold recovery copy; a separate database engine upgrade is required.".into())); + } + let point = crate::backup::get(self.meta_dir, id)?; + let Some(snapshot) = &point.mysql_snapshot else { + // Docker uses its separate experimental release channel until its clone runner is qualified. + if crate::docker::is_docker(self.root) { return Ok(()); } + return Err(Error::Invalid("This installation has no complete MySQL recovery copy for an update rehearsal.".into())); + }; + crate::mysql_snapshot::validate_connections(self.root, self.root)?; + crate::mysql_snapshot::stop(self.root)?; + let point_dir = crate::backup::point_json(self.meta_dir, id)?.parent().unwrap().to_path_buf(); + let fixture = crate::mysql_snapshot::rehearsal_copy(self.root, self.meta_dir, &point_dir, snapshot)?; + let metadata = crate::registry::metadata_dir_for(&fixture)?; + let env = RepackEnv { root: &fixture, meta_dir: &metadata }; + let result = crate::install::with_scratch_ports(&fixture, || { + let mut txn = Txn { id: "rehearsal".into(), state: State::Applying, from_version: None, + to_version: manifest.version.clone(), recovery_point: None, databases_started: false, + ops: items.iter().filter(|item| item.action != Action::Skip).map(|item| Op { + path: item.path.clone(), action: item.action, reason: item.reason.clone(), + new_sha256: manifest.files.iter().find(|file| file.path == item.path).unwrap().sha256.clone(), + had_previous: fsx::safe_join(&fixture, &item.path).is_ok_and(|path| path.is_file()), started:false, done:false, + }).collect(), message:None, recovery_hashes:BTreeMap::new() }; + let before = txn_dir(&metadata, &txn.id)?.join("before"); + fs::create_dir_all(&before)?; + apply_ops(&fixture, tree, &before, &manifest.files, &mut txn, &metadata, None)?; + crate::mysql_snapshot::validate_connections(&fixture, self.root)?; + crate::mysql_snapshot::isolate_connections(&fixture)?; + let identities = crate::backup::with_database(&fixture, |db| { + let identities = database_identities(db)?; + let realms = crate::realms::state(&fixture)?; + 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 }); } + for (index, mode) in modes.into_iter().enumerate() { + let db = db.clone().for_realm(mode); + crate::schema_check::repair_missing_defaults(&db, &fixture, manifest.files.iter().find(|file| file.path == crate::schema_check::CONTRACT).map(|file| file.sha256.as_str()))?; + let list: Vec<_> = manifest.migrations.iter().filter(|migration| index == 0 || migration.db != "auth").cloned().collect(); + let report = crate::migrations::apply_pending(&db, &list, &tree.join("_migrations"), &|| Ok("private rehearsal".into()))?; + if let Some((migration, error)) = report.failed { return Err(Error::Invalid(format!("Rehearsal migration {migration} failed: {error}"))); } + crate::schema_check::repair_missing_defaults(&db, &fixture, manifest.files.iter().find(|file| file.path == crate::schema_check::CONTRACT).map(|file| file.sha256.as_str()))?; + } + verify_database_identities(db, &identities)?; + Ok(identities) + })?; + env.validate()?; + crate::backup::with_database(&fixture, |db| verify_database_identities(db, &identities))?; + env.ensure_stopped() + }); + let stopped = crate::mysql_snapshot::stop(&fixture); + let live_state = if result.is_ok() { crate::mysql_snapshot::stop(self.root).and_then(|()| crate::mysql_snapshot::unchanged(self.root, snapshot)) } else { Ok(()) }; + let logs: BTreeMap<_, _> = ["Core/Logs/auth-console.log", "Core/Logs/world-console.log", "Core/Logs/supervisor.log", "mysql/logs/mysql-error.log"].into_iter() + .map(|path| (path, crate::diag::redact(&crate::health::tail(&fixture.join(path), 32768)))).collect(); + let mut schema_reports = Vec::::new(); + if let Ok(entries) = fs::read_dir(metadata.join("diagnostics")) { + for entry in entries.flatten().filter(|entry| entry.file_name().to_string_lossy().starts_with("schema-validation-")) { + let path = fsx::ensure_within(&metadata, &entry.path())?; + if fs::metadata(&path)?.len() <= 4 * 1024 * 1024 { schema_reports.push(fsx::read_json(&path)?); } + } + } + let report = serde_json::json!({"schema":1,"checkedAt":chrono::Utc::now().to_rfc3339(),"toVersion":manifest.version,"recoveryPoint":id, + "result":if result.is_ok() && stopped.is_ok() && live_state.is_ok() {"passed"} else {"failed"}, "error":result.as_ref().err().map(ToString::to_string), "stopError":stopped.as_ref().err().map(ToString::to_string), "liveStateError":live_state.as_ref().err().map(ToString::to_string), "logs":logs,"schemaReports":schema_reports,"fixture":fixture}); + let report_path = self.meta_dir.join("diagnostics").join(format!("database-rehearsal-{}.json", uuid::Uuid::new_v4())); + fsx::atomic_write_json(&report_path, &report)?; + stopped?; + let parent = fixture.parent().unwrap(); + let owned = fsx::ensure_within(&self.meta_dir.join("rehearsals"), parent)?; + fs::remove_dir_all(owned)?; + live_state?; + result + } fn preflight(&self, manifest: &Manifest) -> Result<()> { + crate::mysql_snapshot::stop_previous_rehearsals(self.meta_dir)?; if let Some(compatibility) = &manifest.compatibility { let (_, meta) = MetaDir::open(self.meta_dir)?; if let Some(source) = meta.core.version.as_ref().and_then(|v| compatibility.source_databases.get(v)) { @@ -909,7 +999,10 @@ impl Env for RepackEnv<'_> { if crate::realms::state(self.root)?.wildcard_created { modes.push(crate::realms::Mode::Wildcard); } for mode in modes { if let Some(problem) = crate::schema_check::check_with_report(&db.clone().for_realm(mode), self.root)?.first() { - return Err(Error::Invalid(format!("The installed {} database does not match its upgrade source: {}.{}: {}", mode.name(), problem.database, problem.table, problem.detail))); + if crate::docker::is_docker(self.root) { + return Err(Error::Invalid(format!("The installed {} database does not match its upgrade source: {}.{}: {}", mode.name(), problem.database, problem.table, problem.detail))); + } + tracing::warn!(realm = mode.name(), table = %problem.table, "Installed schema differs; the private candidate rehearsal must qualify this database"); } } Ok(()) @@ -949,6 +1042,13 @@ impl Env for RepackEnv<'_> { if !["characters", "auth", "world", "configs"].iter().all(|name| point.components.iter().any(|c| c.name == *name)) || !crate::backup::verify(self.meta_dir, id)?.ok { return Err(Error::Invalid("The full database recovery point is incomplete or damaged.".into())); } + if let Some(snapshot) = &point.mysql_snapshot { + crate::mysql_snapshot::stop(self.root)?; + let point_dir = crate::backup::point_json(self.meta_dir, id)?.parent().unwrap().to_path_buf(); + crate::mysql_snapshot::swap(self.root, self.meta_dir, &point_dir, snapshot, false)?; + crate::backup::restore_configs(self.root, self.meta_dir, id)?; + return Ok(()); + } for component in point.components.iter().filter(|c| c.sha256.is_some()) { crate::backup::restore_database(self.root, self.meta_dir, id, &component.name)?; } @@ -960,12 +1060,17 @@ impl Env for RepackEnv<'_> { let root = self.root; crate::backup::with_database(root, |db| { let realms = crate::realms::state(root)?; + if let Some(contract) = manifest.files.iter().find(|file| file.path == crate::schema_check::CONTRACT) { + self.migration_snapshot()?; + crate::schema_check::repair_missing_defaults(db, root, Some(&contract.sha256))?; + } let mut result = crate::migrations::apply_pending(db, &manifest.migrations, staged, &|| { self.migration_snapshot() })?; if realms.wildcard_created && result.failed.is_none() { let other = if realms.active == crate::realms::Mode::Coa { crate::realms::Mode::Wildcard } else { crate::realms::Mode::Coa }; let other_db = db.clone().for_realm(other); + crate::schema_check::repair_missing_defaults(&other_db, root, manifest.files.iter().find(|file| file.path == crate::schema_check::CONTRACT).map(|file| file.sha256.as_str()))?; let shared_snapshot = result.snapshot.clone(); let migrations: Vec<_> = manifest.migrations.iter().filter(|m| m.db != "auth").cloned().collect(); let extra = crate::migrations::apply_pending(&other_db, &migrations, staged, &|| { @@ -976,12 +1081,14 @@ impl Env for RepackEnv<'_> { result.failed = extra.failed; if result.snapshot.is_none() { result.snapshot = extra.snapshot; } if result.failed.is_none() { + crate::schema_check::repair_missing_defaults(&other_db, root, manifest.files.iter().find(|file| file.path == crate::schema_check::CONTRACT).map(|file| file.sha256.as_str()))?; if let Some(p) = crate::schema_check::check_with_report(&other_db, root)?.first() { return Err(Error::Invalid(format!("Database validation failed on {}: {}.{}: {}", other.name(), p.table, p.column, p.detail))); } } } if result.failed.is_none() { + crate::schema_check::repair_missing_defaults(db, root, manifest.files.iter().find(|file| file.path == crate::schema_check::CONTRACT).map(|file| file.sha256.as_str()))?; let problems = crate::schema_check::check_with_report(db, root)?; if let Some(p) = problems.first() { return Err(Error::Invalid(format!("Database validation failed: {}.{}.{}: {} ({} problems).", p.database, p.table, p.column, p.detail, problems.len()))); @@ -992,6 +1099,16 @@ impl Env for RepackEnv<'_> { } fn validate(&self) -> Result<()> { + self.validate_server(true) + } + + fn validate_recovery(&self) -> Result<()> { + self.validate_server(false) + } +} + +impl RepackEnv<'_> { + fn validate_server(&self, check_schema: bool) -> Result<()> { let stopped = crate::driver::run(self.root, crate::driver::Verb::StopAll)?; if !stopped.ok { return Err(Error::Invalid("Could not stop the server before isolated validation.".into())); } self.ensure_stopped()?; @@ -999,7 +1116,7 @@ impl Env for RepackEnv<'_> { let result = (|| -> Result<()> { crate::update_isolation::begin(self.root, self.meta_dir)?; use crate::process::{observe, ServiceState}; - crate::backup::with_database(self.root, |db| { + if check_schema { crate::backup::with_database(self.root, |db| { let mut modes = vec![crate::realms::Mode::Coa]; if crate::realms::state(self.root)?.wildcard_created { modes.push(crate::realms::Mode::Wildcard); } for mode in modes { @@ -1008,7 +1125,7 @@ impl Env for RepackEnv<'_> { } } Ok(()) - })?; + })?; } // The check start is the first start of the new build: give the module configs the settings the update added // (as a normal start does), or the server logs a "missing property" line for every one of them. match crate::registry::MetaDir::open(self.meta_dir) { @@ -1064,6 +1181,31 @@ impl Env for RepackEnv<'_> { } +fn database_identities(db: &crate::db::Db) -> Result>> { + let db = db.clone().for_realm(crate::realms::Mode::Coa); + let mut identities = BTreeMap::new(); + for (schema, table, columns) in [ + ("acore_characters", "characters", "guid,account,HEX(name)"), + ("acore_characters_wildcard", "characters", "guid,account,HEX(name)"), + ("acore_auth", "account", "id,HEX(username)"), + ] { + if !db.schema_exists(schema)? || !db.tables(schema)?.iter().any(|name| name == table) { continue; } + let rows = db.query(&format!("SELECT {columns} FROM `{schema}`.`{table}`;"))?; + identities.insert(schema.into(), rows.lines().map(str::to_string).collect()); + } + Ok(identities) +} + +fn verify_database_identities(db: &crate::db::Db, before: &BTreeMap>) -> Result<()> { + let after = database_identities(db)?; + for (schema, expected) in before { + if after.get(schema).is_none_or(|actual| !expected.is_subset(actual)) { + return Err(Error::Invalid(format!("The private update changed or removed existing character or account identities in {schema}."))); + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -1086,15 +1228,19 @@ mod tests { migrate_fail: Cell, preflight_fail: Cell, restore_fail: Cell, + rehearsal_fail: Cell, } impl Fake { fn ok() -> Fake { - Fake { activation_expected: RefCell::new(None), activation_failed: Cell::new(false), activated: Cell::new(false), automatic_rollback: Cell::new(false), recovery_healthy: Cell::new(true), 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) } + Fake { activation_expected: RefCell::new(None), activation_failed: Cell::new(false), activated: Cell::new(false), automatic_rollback: Cell::new(false), recovery_healthy: Cell::new(true), 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), rehearsal_fail: Cell::new(false) } } } impl Env for Fake { + fn rehearse(&self, _: &Manifest, _: &Path, _: &[PlanItem], _: &str) -> Result<()> { + if self.rehearsal_fail.get() { Err(Error::Invalid("SQL failed in the private copy".into())) } else { Ok(()) } + } fn activate(&self) -> Result<()> { if let Some((meta, version)) = self.activation_expected.borrow().as_ref() { ensure_recovered(meta)?; @@ -1695,6 +1841,23 @@ mod tests { assert!(fs::read_dir(w.meta.join("updates")).unwrap().next().is_none()); } + #[test] + fn failed_rehearsal_preserves_installed_files_version_and_recovery_point() { + let w = world(&[], true); + let env = Fake::ok(); + env.rehearsal_fail.set(true); + let original = fsx::sha256_file(&w.meta.join("install.json")).unwrap(); + let error = run(&w, &env, BTreeMap::new(), None).unwrap_err(); + assert!(error.to_string().contains("installed server was not updated")); + assert_eq!(read(&w, "Core/worldserver.exe"), b"world-v1"); + assert_eq!(fsx::sha256_file(&w.meta.join("install.json")).unwrap(), original); + assert_eq!(&*env.calls.borrow(), &["stop", "snapshot"]); + assert!(pending_checked(&w.meta).unwrap().is_none()); + let journal = journals(&w.meta).unwrap().remove(0); + assert_eq!(journal.recovery_point.as_deref(), Some("rp-1")); + assert!(!journal.databases_started && journal.ops.is_empty()); + } + #[test] fn missing_and_semantically_corrupt_journals_block_operations() { let w = world(&[], false); diff --git a/src/lib/api.ts b/src/lib/api.ts index 9d11c35..e3dcb13 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -186,6 +186,7 @@ export interface RecoveryPoint { label: string | null; created_at: string; components: { name: string; path: string; bytes: number; tables: number | null; files: string[] | null }[]; + mysql_snapshot?: { bytes: number } | null; } export interface DbRestore { diff --git a/src/screens/BackupsPage.tsx b/src/screens/BackupsPage.tsx index 35b5104..5dc2724 100644 --- a/src/screens/BackupsPage.tsx +++ b/src/screens/BackupsPage.tsx @@ -198,7 +198,7 @@ export function BackupsPage({ serverId }: { serverId: string }) {

{p.label && p.trigger === "manual" && p.label !== "e2e" ? p.label : trigger(p.trigger)}

- {w(p.created_at)} · {p.realm === "wildcard" ? "Wildcard" : "CoA"} · {t(`bk.kind.${p.kind}` as Key)} · {size(p.components.reduce((a, c) => a + c.bytes, 0))} + {w(p.created_at)} · {p.realm === "wildcard" ? "Wildcard" : "CoA"} · {t(`bk.kind.${p.kind}` as Key)} · {size(p.components.reduce((a, c) => a + c.bytes, p.mysql_snapshot?.bytes ?? 0))}

diff --git a/tools/make-fixture.ps1 b/tools/make-fixture.ps1 index 1f5f64c..76d2960 100644 --- a/tools/make-fixture.ps1 +++ b/tools/make-fixture.ps1 @@ -4,7 +4,8 @@ param( [string]$Source = 'C:\games\CoA-Repack', [string]$Target = 'C:\games\coa-fixture', [int]$PortBase = 13000, - [string]$DatabaseArchive = '' + [string]$DatabaseArchive = '', + [switch]$CopyData ) $ErrorActionPreference = 'Stop' if ((Resolve-Path $Source).Path -eq $Target) { throw 'source and target are the same' } @@ -32,7 +33,8 @@ if (-not $DatabaseArchive) { $DatabaseArchive = "$Source\mysql\data.7z" } & 'C:\Program Files\7-Zip\7z.exe' x $DatabaseArchive "-o$Target\mysql" -y | Out-Null if (-not (Test-Path "$Target\mysql\data\acore_characters")) { throw 'database extraction failed' } -New-Item -ItemType Junction -Path "$Target\Data" -Target "$Source\Data" | Out-Null +if ($CopyData) { Copy-Tree 'Data' } +else { New-Item -ItemType Junction -Path "$Target\Data" -Target "$Source\Data" | Out-Null } $ports = @{ mysqlPort = $PortBase + 307; authPort = $PortBase + 724; worldPort = $PortBase + 85; raPort = $PortBase + 443 } $cfg = Get-Content "$Target\Settings\repack.json" -Raw | ConvertFrom-Json From 10a6744192a2058dc73a2f20d60fa813470896f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 11 Oct 2026 01:31:22 +0500 Subject: [PATCH 17/17] Normalize MySQL inventory roots consistently on Windows --- crates/coa-core/src/mysql_snapshot.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/coa-core/src/mysql_snapshot.rs b/crates/coa-core/src/mysql_snapshot.rs index bed8658..9d445ee 100644 --- a/crates/coa-core/src/mysql_snapshot.rs +++ b/crates/coa-core/src/mysql_snapshot.rs @@ -26,6 +26,8 @@ fn reject_link(metadata: &fs::Metadata) -> Result<()> { } fn directories(root: &Path) -> Result> { + let canonical = fsx::canonicalize_lenient(root)?; + let root = canonical.as_path(); let mut result = BTreeSet::new(); let mut pending = vec![root.to_path_buf()]; while let Some(directory) = pending.pop() { @@ -105,6 +107,8 @@ fn compress(source: &Path, point: &Path, snapshot: &mut Snapshot) -> Result<()> } pub(crate) fn inventory(root: &Path) -> Result> { + let canonical = fsx::canonicalize_lenient(root)?; + let root = canonical.as_path(); let mut files = BTreeMap::new(); let mut directories = vec![root.to_path_buf()]; while let Some(directory) = directories.pop() {