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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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/62] 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 4c4d39f1de555de90a1b8753772edd1d495c0675 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:27 +0500 Subject: [PATCH 13/62] feat(races): synchronize custom races bot setting with playerbots excluded races --- .../20261010-custom-races-bot-exclusions.md | 7 ++ crates/coa-core/src/custom_races.rs | 79 +++++++++++++++++++ crates/coa-core/src/modules.rs | 76 ++++++++++++++++-- src/lib/moduleSettings.ts | 9 ++- 4 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 changelog.d/20261010-custom-races-bot-exclusions.md diff --git a/changelog.d/20261010-custom-races-bot-exclusions.md b/changelog.d/20261010-custom-races-bot-exclusions.md new file mode 100644 index 0000000..29fb894 --- /dev/null +++ b/changelog.d/20261010-custom-races-bot-exclusions.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Custom races bot setting synchronizes Playerbots excluded race lists +--- +The "Bots can be custom races" setting now configures AiPlayerbot.ExcludedBotRaces in playerbots.conf. Off excludes all added races (recommended), while on allows stable added races (experimental). diff --git a/crates/coa-core/src/custom_races.rs b/crates/coa-core/src/custom_races.rs index c675e4d..cecb935 100644 --- a/crates/coa-core/src/custom_races.rs +++ b/crates/coa-core/src/custom_races.rs @@ -54,6 +54,85 @@ pub const RACE_MPQS: &[&str] = &[ "patch-ZZR.MPQ", ]; +pub const EXCLUDED_BOT_RACES_RECOMMENDED: &str = + "19,27,65,72,77,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128"; + +pub const EXCLUDED_BOT_RACES_EXPERIMENTAL: &str = + "19,27,65,72,77,16,50,67,32,66,68,69,70,71,74,52,53,80,81"; + +/// Finds all active playerbots.conf files on this server (root, coa-bots folder, and secondary realm). +pub fn playerbots_conf_paths(root: &Path) -> Vec { + let mut paths = Vec::new(); + let candidates = [ + root.join("Core/configs/modules/playerbots.conf"), + root.join("coa-bots/Core/configs/modules/playerbots.conf"), + root.join("coa-bots/core/configs/modules/playerbots.conf"), + crate::multiworld::root(root).join("Core/configs/modules/playerbots.conf"), + ]; + for path in candidates { + if path.is_file() { + paths.push(path); + } + } + if paths.is_empty() { + for dist in [ + root.join("Core/configs/modules/playerbots.conf.dist"), + root.join("coa-bots/Core/configs/modules/playerbots.conf.dist"), + ] { + if dist.is_file() { + let target = dist.with_extension(""); + if fs::copy(&dist, &target).is_ok() { + paths.push(target); + break; + } + } + } + } + paths +} + +/// Checks whether playerbots are configured to allow custom races. +/// Returns true if AiPlayerbot.ExcludedBotRaces exists and does not exclude general custom races. +pub fn is_bot_custom_races_allowed(root: &Path) -> Option { + for path in playerbots_conf_paths(root) { + if let Ok(bytes) = fs::read(&path) { + if let Ok(conf) = crate::config::parser::ConfFile::parse_bytes(&bytes) { + if let Some(val) = conf.get("AiPlayerbot.ExcludedBotRaces") { + let clean = val.trim().trim_matches(['"', '\'']); + return Some(!clean.contains("33") && !clean.contains("128")); + } + } + } + } + None +} + +/// Sets AiPlayerbot.ExcludedBotRaces in all discovered playerbots.conf files. +pub fn sync_bot_races_config(root: &Path, allow_custom_races: bool) -> Result<()> { + let excluded = if allow_custom_races { + EXCLUDED_BOT_RACES_EXPERIMENTAL + } else { + EXCLUDED_BOT_RACES_RECOMMENDED + }; + for path in playerbots_conf_paths(root) { + if let Ok(bytes) = fs::read(&path) { + if let Ok(mut conf) = crate::config::parser::ConfFile::parse_bytes(&bytes) { + conf.set( + "AiPlayerbot.ExcludedBotRaces", + &format!("\"{excluded}\""), + &[ + "CoA Custom: races new random bots never get", + "Off (recommended): excludes all added races", + "On (experimental): allows stable added races", + ], + ); + fsx::atomic_write(&path, conf.to_text().as_bytes())?; + } + } + } + Ok(()) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum ClientPatchState { diff --git a/crates/coa-core/src/modules.rs b/crates/coa-core/src/modules.rs index ba7894b..b6d0bdd 100644 --- a/crates/coa-core/src/modules.rs +++ b/crates/coa-core/src/modules.rs @@ -214,6 +214,16 @@ pub fn set_enabled(root: &Path, meta: &Path, id: &str, on: bool) -> Result<()> { } } } + if !on { + let _ = crate::custom_races::sync_bot_races_config(root, false); + } else { + let allow = crate::custom_races::is_bot_custom_races_allowed(root).unwrap_or_else(|| { + read(&dir(root).join("coa_custom_races.conf")) + .and_then(|c| c.get("CoACustomRaces.BotsEnable").map(truthy)) + .unwrap_or(false) + }); + let _ = crate::custom_races::sync_bot_races_config(root, allow); + } } set_enabled_in(&catalog(), root, meta, id, on) } @@ -290,14 +300,27 @@ fn settings_in(cat: &[Entry], root: &Path, id: &str) -> Result> { } } } + let bot_custom_races_allowed = if id == "custom-races" { + crate::custom_races::is_bot_custom_races_allowed(root) + } else { + None + }; Ok(effective .entries() - .map(|(k, v)| Setting { - key: k.to_string(), - value: fields.get(k).and_then(|field| field.default_if_missing.as_ref()).filter(|_| active.get(k).is_none()).and_then(crate::squid::scalar).unwrap_or_else(|| v.to_string()), - default: fields.get(k).and_then(|field| crate::squid::scalar(&field.default)).or_else(|| dist.as_ref().and_then(|d| d.get(k)).map(str::to_string)), - field: fields.get(k).cloned(), - doc: dist.as_ref().map(|d| d.doc_for(k)).filter(|d| !d.is_empty()).unwrap_or_else(|| active.doc_for(k)).join(" "), + .map(|(k, v)| { + let mut val = fields.get(k).and_then(|field| field.default_if_missing.as_ref()).filter(|_| active.get(k).is_none()).and_then(crate::squid::scalar).unwrap_or_else(|| v.to_string()); + if k == "CoACustomRaces.BotsEnable" { + if let Some(allowed) = bot_custom_races_allowed { + val = if allowed { "1".into() } else { "0".into() }; + } + } + Setting { + key: k.to_string(), + value: val, + default: fields.get(k).and_then(|field| crate::squid::scalar(&field.default)).or_else(|| dist.as_ref().and_then(|d| d.get(k)).map(str::to_string)), + field: fields.get(k).cloned(), + doc: dist.as_ref().map(|d| d.doc_for(k)).filter(|d| !d.is_empty()).unwrap_or_else(|| active.doc_for(k)).join(" "), + } }) .collect()) } @@ -353,6 +376,12 @@ fn save_settings_in(cat: &[Entry], root: &Path, meta: &Path, id: &str, changes: changed.push((key.clone(), value.to_string())); } } + if id == "custom-races" { + if let Some(bots_enable) = changes.get("CoACustomRaces.BotsEnable") { + let allow = truthy(bots_enable); + crate::custom_races::sync_bot_races_config(root, allow)?; + } + } if changed.is_empty() { return Ok(Vec::new()); } @@ -672,4 +701,39 @@ mod tests { assert!(fs::read_to_string(dir(&root).join("war_games.conf")).unwrap().contains("WarGames.Enable = 0")); assert!(!list_in(&cat, &root).into_iter().find(|m| m.id == "war-games").unwrap().enabled); } + #[test] + fn custom_races_bot_settings_synchronize_playerbots_conf() { + let d = tempfile::tempdir().unwrap(); + let (root, meta) = server(d.path()); + let cat = catalog(); + fs::write(dir(&root).join("coa_custom_races.conf.dist"), "CoACustomRaces.Enable = 1\nCoACustomRaces.BotsEnable = 0\n").unwrap(); + fs::write(dir(&root).join("coa_custom_races.conf"), "CoACustomRaces.Enable = 1\nCoACustomRaces.BotsEnable = 0\n").unwrap(); + fs::write(dir(&root).join("playerbots.conf"), "AiPlayerbot.Enabled = 1\n").unwrap(); + + // 1. Initially CoACustomRaces.BotsEnable is 0 + let s = settings_in(&cat, &root, "custom-races").unwrap(); + assert_eq!(s.iter().find(|s| s.key == "CoACustomRaces.BotsEnable").unwrap().value, "0"); + + // 2. Enable bots for custom races: should write experimental exclusions to playerbots.conf + let change_on = BTreeMap::from([("CoACustomRaces.BotsEnable".into(), "1".into())]); + assert_eq!(save_settings_in(&cat, &root, &meta, "custom-races", &change_on).unwrap(), ["CoACustomRaces.BotsEnable"]); + let pb = fs::read_to_string(dir(&root).join("playerbots.conf")).unwrap(); + assert!(pb.contains("AiPlayerbot.ExcludedBotRaces")); + assert!(pb.contains(crate::custom_races::EXCLUDED_BOT_RACES_EXPERIMENTAL)); + + // Settings view now sees "1" + let s = settings_in(&cat, &root, "custom-races").unwrap(); + assert_eq!(s.iter().find(|s| s.key == "CoACustomRaces.BotsEnable").unwrap().value, "1"); + + // 3. Disable bots for custom races: should write recommended exclusions to playerbots.conf + let change_off = BTreeMap::from([("CoACustomRaces.BotsEnable".into(), "0".into())]); + assert_eq!(save_settings_in(&cat, &root, &meta, "custom-races", &change_off).unwrap(), ["CoACustomRaces.BotsEnable"]); + let pb = fs::read_to_string(dir(&root).join("playerbots.conf")).unwrap(); + assert!(pb.contains(crate::custom_races::EXCLUDED_BOT_RACES_RECOMMENDED)); + + // Settings view now sees "0" + let s = settings_in(&cat, &root, "custom-races").unwrap(); + assert_eq!(s.iter().find(|s| s.key == "CoACustomRaces.BotsEnable").unwrap().value, "0"); + } } + diff --git a/src/lib/moduleSettings.ts b/src/lib/moduleSettings.ts index 764645a..78e5117 100644 --- a/src/lib/moduleSettings.ts +++ b/src/lib/moduleSettings.ts @@ -87,7 +87,14 @@ for (let n = 1; n <= 3; n++) add(`EC.P${n}`, "string", [`NPC phrase ${n}`, `Ре add("CoACustomRaces.BotsEnable", "bool", ["Bots can be custom races", "Боты кастомных рас", "Bots benutzerdefinierter Völker", "Bots de races personnalisées", "Bots de razas personalizadas", "允许机器人为自定义种族"], - ["Allow Playerbots to spawn as custom races.", "Разрешает ботам-игрокам появляться кастомными расами.", "Erlaubt Spieler-Bots, benutzerdefinierte Völker zu nutzen.", "Autorise les bots joueurs à s’incarner avec des races personnalisées.", "Permite a los bots jugadores crearse con razas personalizadas.", "允许玩家机器人扮演自定义种族。"] + [ + "Allow Playerbots to spawn as custom races (experimental). Off excludes all custom races; on allows stable custom races.", + "Разрешает ботам-игрокам появляться кастомными расами (экспериментально). Выключено — исключает все кастомные расы, включено — разрешает стабильные.", + "Erlaubt Spieler-Bots, benutzerdefinierte Völker zu nutzen (experimentell). Aus schließt alle benutzerdefinierten Völker aus; An erlaubt stabile.", + "Autorise les bots joueurs à s’incarner avec des races personnalisées (expérimental). Désactivé exclut toutes les races personnalisées ; activé autorise les stables.", + "Permite a los bots jugadores crearse con razas personalizadas (experimental). Desactivado excluye todas las razas personalizadas; activado permite las estables.", + "允许玩家机器人扮演自定义种族(实验性)。关闭将排除所有自定义种族,开启允许稳定的自定义种族。" + ] ); const ah = "AuctionHouseBot."; 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 14/62] 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 15/62] 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 949614f11adeb3110f4e2d75a52fe4797afa5200 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:06:19 +0500 Subject: [PATCH 16/62] fix(races): update excluded bot race IDs as requested by llusixn --- crates/coa-core/src/custom_races.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/coa-core/src/custom_races.rs b/crates/coa-core/src/custom_races.rs index cecb935..915c5f3 100644 --- a/crates/coa-core/src/custom_races.rs +++ b/crates/coa-core/src/custom_races.rs @@ -55,10 +55,10 @@ pub const RACE_MPQS: &[&str] = &[ ]; pub const EXCLUDED_BOT_RACES_RECOMMENDED: &str = - "19,27,65,72,77,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128"; + "19,27,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128"; pub const EXCLUDED_BOT_RACES_EXPERIMENTAL: &str = - "19,27,65,72,77,16,50,67,32,66,68,69,70,71,74,52,53,80,81"; + "16,19,27,32,50,52,53,65,66,67,68,69,70,71,72,74,77,80,81"; /// Finds all active playerbots.conf files on this server (root, coa-bots folder, and secondary realm). pub fn playerbots_conf_paths(root: &Path) -> Vec { 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 17/62] 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 18/62] 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 19/62] 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() { From 6fd2130fc4a11bc149daf0f2a25f6f314d81ca5d 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:38:45 +0500 Subject: [PATCH 20/62] Prepare Manager version 0.7.0 --- Cargo.lock | 6 +++--- Cargo.toml | 2 +- package-lock.json | 4 ++-- package.json | 2 +- src-tauri/tauri.conf.json | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 85b38d8..ba5d5ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -491,7 +491,7 @@ dependencies = [ [[package]] name = "coa-core" -version = "0.6.13" +version = "0.7.0" dependencies = [ "base64 0.22.1", "chrono", @@ -520,7 +520,7 @@ dependencies = [ [[package]] name = "coa-release" -version = "0.6.13" +version = "0.7.0" dependencies = [ "chrono", "coa-core", @@ -529,7 +529,7 @@ dependencies = [ [[package]] name = "coa-server-manager" -version = "0.6.13" +version = "0.7.0" dependencies = [ "coa-core", "serde", diff --git a/Cargo.toml b/Cargo.toml index c012fd0..789fe1c 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.13" +version = "0.7.0" edition = "2021" license = "AGPL-3.0-only" diff --git a/package-lock.json b/package-lock.json index 449c0ae..03daeb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coa-server-manager", - "version": "0.6.13", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coa-server-manager", - "version": "0.6.13", + "version": "0.7.0", "dependencies": { "@tauri-apps/api": "^2.12.0", "@tauri-apps/plugin-dialog": "^2.8.0", diff --git a/package.json b/package.json index 0265fc9..e35a056 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coa-server-manager", "private": true, - "version": "0.6.13", + "version": "0.7.0", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 43df25d..0e79a85 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.13", + "version": "0.7.0", "identifier": "com.coa.servermanager", "build": { "beforeDevCommand": "npm run dev", From 57aa97da8a80b021f4a936a3c7fe15dd1c3d1423 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:39:35 +0500 Subject: [PATCH 21/62] Revert "Prepare Manager version 0.7.0" This reverts commit 6fd2130fc4a11bc149daf0f2a25f6f314d81ca5d. --- Cargo.lock | 6 +++--- Cargo.toml | 2 +- package-lock.json | 4 ++-- package.json | 2 +- src-tauri/tauri.conf.json | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ba5d5ab..85b38d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -491,7 +491,7 @@ dependencies = [ [[package]] name = "coa-core" -version = "0.7.0" +version = "0.6.13" dependencies = [ "base64 0.22.1", "chrono", @@ -520,7 +520,7 @@ dependencies = [ [[package]] name = "coa-release" -version = "0.7.0" +version = "0.6.13" dependencies = [ "chrono", "coa-core", @@ -529,7 +529,7 @@ dependencies = [ [[package]] name = "coa-server-manager" -version = "0.7.0" +version = "0.6.13" dependencies = [ "coa-core", "serde", diff --git a/Cargo.toml b/Cargo.toml index 789fe1c..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.7.0" +version = "0.6.13" edition = "2021" license = "AGPL-3.0-only" diff --git a/package-lock.json b/package-lock.json index 03daeb2..449c0ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coa-server-manager", - "version": "0.7.0", + "version": "0.6.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coa-server-manager", - "version": "0.7.0", + "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 e35a056..0265fc9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coa-server-manager", "private": true, - "version": "0.7.0", + "version": "0.6.13", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 0e79a85..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.7.0", + "version": "0.6.13", "identifier": "com.coa.servermanager", "build": { "beforeDevCommand": "npm run dev", From 90c308615bdc0ccdf04f9cd8a1becd958917cb53 Mon Sep 17 00:00:00 2001 From: "Fabien F." <8136686+lostmind84@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:02:22 +0200 Subject: [PATCH 22/62] fix: settings the Docker backend fixes are not offered, and the world restarts itself Found by auditing every screen for a Docker server. - Server settings: Ra.Enable, Ra.IP and Updates.EnableDatabases were on the page and written to worldserver.conf, but the containers get AC_RA_ENABLE, AC_RA_IP and AC_UPDATES_ENABLE_DATABASES, and the core reads those before the file: the change was accepted and had no effect, and the page showed the file's value while the container ran another. A Docker server no longer offers them (nor "restore recommended defaults" for them), and a change forced through is refused with the reason. The list is docker::FORCED_KEYS, and a test compares it to the variables the containers really get, so adding one without the other fails. - `server restart` (and idlerestart) make the core exit with code 2 and expect a supervisor to start it again; the Docker world had none and showed "crashed". It now gets `--restart on-failure:3`, applied once the server is up (so a start that fails stays failed instead of looping); a stop or `server shutdown` (code 0) is never restarted. --- crates/coa-core/src/config/mod.rs | 27 +++++++++ crates/coa-core/src/docker/lifecycle.rs | 35 ++++++++++++ crates/coa-core/src/docker/mod.rs | 73 +++++++++++++++++++++++++ 3 files changed, 135 insertions(+) diff --git a/crates/coa-core/src/config/mod.rs b/crates/coa-core/src/config/mod.rs index 7070eb6..f5c82a8 100644 --- a/crates/coa-core/src/config/mod.rs +++ b/crates/coa-core/src/config/mod.rs @@ -213,9 +213,12 @@ pub fn load(root: &Path, scope: Scope) -> Result { let known: BTreeSet<&str> = schema.settings.iter().map(|s| s.key.as_str()).collect(); let unknown_keys = conf.entries().filter(|(k, _)| !known.contains(k)).count(); + // On a Docker server some settings are fixed by the backend (environment beats the file): not offered. + let docker = scope == Scope::Server && crate::docker::is_docker(root); let settings = schema .settings .iter() + .filter(|s| !(docker && crate::docker::managed_setting(&s.key))) .map(|s| { let raw = conf.get(&s.key); let (value, problem) = match raw { @@ -286,9 +289,13 @@ pub fn validate(root: &Path, scope: Scope, changes: &BTreeMap) -> let schema = scope.schema(); let mut errors = Vec::new(); let mut raws = BTreeMap::new(); + let docker = scope == Scope::Server && crate::docker::is_docker(root); for (key, value) in changes { match schema.get(key) { None => errors.push(FieldError { key: key.clone(), message: "is not a setting the Manager can change".into() }), + Some(_) if docker && crate::docker::managed_setting(key) => { + errors.push(FieldError { key: key.clone(), message: "is set by the Docker backend; changing it in the file would have no effect".into() }) + } Some(s) => match s.to_raw(value) { Ok(raw) => { raws.insert(key.clone(), raw); @@ -736,6 +743,26 @@ Spellbook.New = 5 assert!(list_snapshots(&meta).is_empty(), "no snapshot for a rejected save"); } + #[test] + fn a_docker_server_is_not_offered_the_settings_its_backend_fixes() { + let (_d, root, meta) = fixture(); + let keys = |root: &Path| load(root, Scope::Server).unwrap().settings.into_iter().map(|s| s.meta.key).collect::>(); + for k in ["Ra.Enable", "Ra.IP", "Updates.EnableDatabases", "PlayerLimit"] { + assert!(keys(&root).iter().any(|x| x == k), "a repack offers {k}"); + } + fs::write(root.join("Settings/docker.json"), br#"{"project":"t1"}"#).unwrap(); + let docker = keys(&root); + for k in ["Ra.Enable", "Ra.IP", "Updates.EnableDatabases"] { + assert!(!docker.iter().any(|x| x == k), "a Docker server does not offer {k}: the containers' environment beats the file"); + } + assert!(docker.iter().any(|x| x == "PlayerLimit"), "everything else is still offered"); + // Forcing a change through is refused and says why, instead of being saved and ignored. + let err = save(&root, &meta, Scope::Server, &set(&[("Ra.Enable", json!(0))])).unwrap_err().to_string(); + assert!(err.contains("Docker backend"), "{err}"); + assert!(!fs::read_to_string(root.join("Core/configs/worldserver.conf")).unwrap().contains("Ra.Enable = 0")); + save(&root, &meta, Scope::Server, &set(&[("PlayerLimit", json!(77))])).unwrap(); + } + #[test] fn missing_key_is_appended_and_noop_saves_do_nothing() { let (_d, root, meta) = fixture(); diff --git a/crates/coa-core/src/docker/lifecycle.rs b/crates/coa-core/src/docker/lifecycle.rs index 642ed36..6620c21 100644 --- a/crates/coa-core/src/docker/lifecycle.rs +++ b/crates/coa-core/src/docker/lifecycle.rs @@ -242,6 +242,10 @@ fn start(d: &dyn Docker, root: &Path, cfg: &Config, with_game: bool, log: &mut L run_container(d, &call, &n.world, log)?; } wait_listening(d, cfg, &n.world, ports.world, WORLD_WAIT, log)?; + // The core leaves with code 2 for `server restart` and expects a supervisor to start it again (the repack has one). Docker + // does that for any non-zero exit, but only once the server has come up: a start that fails must stay failed, not loop. + // A stop by `docker stop` or `server shutdown` (code 0) is never restarted. + let _ = docker(d, &["update", "--restart", "on-failure:3", &n.world], QUICK); if !state.get(&n.auth).is_some_and(Container::running) { remove(d, &n.auth); @@ -388,6 +392,21 @@ fn database_env(s: &Secrets, world: bool, realm: crate::realms::Mode) -> Vec<(St env } +/// Every setting variable (`AC_...`) the containers get, for the check that the screens know which settings are fixed. +#[cfg(test)] +pub(crate) fn forced_variables() -> Vec { + let cfg = Config { project: "t".into(), bind_address: "127.0.0.1".into(), mysql_image: "mysql:8.4".into(), data_dir: None }; + let n = cfg.names(); + let mut found = Vec::new(); + for kind in [GameKind::World, GameKind::Auth] { + let args = game_args(&cfg, &n, kind, Path::new("/s"), Path::new("/d"), 1, 2, None); + found.extend(args.iter().filter_map(|a| a.strip_prefix("AC_").map(|rest| format!("AC_{}", rest.split('=').next().unwrap())))); + } + found.sort(); + found.dedup(); + found +} + /// Realm profiles are only in play once a profile has been chosen on this server (`Settings/realm-profile.json`). fn prepare_realm(root: &Path, log: &mut Log) -> Step { let fail_with = |e: crate::error::Error| Failure { code: ErrorCode::StartupFailed, output: e.to_string() }; @@ -670,6 +689,7 @@ mod tests { } out(0, "containerid\n", "") } + ["update", "--restart", _, _] => out(0, "", ""), ["stop", "--time", _, name] => { self.running.borrow_mut().remove(*name); self.exited.borrow_mut().insert(name.to_string(), (0, false)); @@ -757,6 +777,21 @@ mod tests { assert!(!plain.iter().any(|a| a == "--lower-case-table-names=1" || a == "--user")); } + #[test] + fn the_world_server_is_started_again_when_it_restarts_itself_but_only_once_it_is_up() { + let (_d, root) = server("t1"); + let sim = Sim::new(); + assert!(start_all(&sim, &root).ok); + let world_run = sim.calls_of("run").into_iter().find(|c| c.args.iter().any(|a| a == "coa-t1-world")).unwrap(); + assert!(!world_run.args.iter().any(|a| a == "--restart"), "no restart while it is still starting"); + let updates = sim.calls_of("update"); + assert_eq!(updates.len(), 1); + assert_eq!(updates[0].args, ["update", "--restart", "on-failure:3", "coa-t1-world"]); + let verbs = sim.verbs(); + let pos = |p: &str| verbs.iter().position(|v| v.starts_with(p)).unwrap(); + assert!(pos("run --detach") < pos("update"), "after the container exists and listens"); + } + #[test] fn the_containers_use_the_databases_of_the_selected_realm() { let s = Secrets { root: "r".into(), app: "apppw".into() }; diff --git a/crates/coa-core/src/docker/mod.rs b/crates/coa-core/src/docker/mod.rs index fae61d9..d668bf1 100644 --- a/crates/coa-core/src/docker/mod.rs +++ b/crates/coa-core/src/docker/mod.rs @@ -140,6 +140,52 @@ pub fn ensure_main_configs(root: &Path) -> Result> { Ok(created) } +/// Settings the Docker backend gives the containers as environment variables. The core reads `AC_` before the +/// configuration file, so a value written to the file for one of these keys is silently ignored. They are what makes the +/// containers work (where things are inside them, listening on every interface, the console, no built-in database +/// updater, the databases); the owner has no use for them and the screens do not offer them. +pub(crate) const FORCED_KEYS: [&str; 12] = [ + "BindIP", + "WorldServerPort", + "RealmServerPort", + "LogsDir", + "DataDir", + "Updates.EnableDatabases", + "Ra.Enable", + "Ra.IP", + "Ra.Port", + "LoginDatabaseInfo", + "WorldDatabaseInfo", + "CharacterDatabaseInfo", +]; + +/// Is this setting fixed by the Docker backend (see `FORCED_KEYS`)? +pub fn managed_setting(key: &str) -> bool { + FORCED_KEYS.iter().any(|k| k.eq_ignore_ascii_case(key)) +} + +/// The environment variable the core reads for a setting: `AC_` and the key in upper snake case, a `_` between a lower-case +/// letter and a capital, and at letter / digit boundaries (the core's `IniKeyToEnvVarKey`). +#[cfg(test)] +pub(crate) fn env_name(key: &str) -> String { + let chars: Vec = key.chars().collect(); + let mut out = String::from("AC_"); + for (i, &c) in chars.iter().enumerate() { + if matches!(c, ' ' | '.' | '-') { + out.push('_'); + continue; + } + out.push(c.to_ascii_uppercase()); + if let Some(&next) = chars.get(i + 1) { + let split = (!c.is_ascii_uppercase() && next.is_ascii_uppercase()) || (!c.is_ascii_digit() && next.is_ascii_digit()) || (c.is_ascii_digit() && !next.is_ascii_digit() && !matches!(next, ' ' | '.' | '-')); + if split { + out.push('_'); + } + } + } + out +} + /// Name of the runtime image: it follows the content of its Dockerfile, so changing the libraries builds a new image. pub(crate) fn runtime_image() -> String { format!("coa-runtime:{}", &fsx::sha256_bytes(RUNTIME_DOCKERFILE.as_bytes())[..12]) @@ -206,6 +252,33 @@ mod tests { use super::*; use std::fs; + #[test] + fn setting_names_become_the_variables_the_core_reads() { + for (key, var) in [ + ("BindIP", "AC_BIND_IP"), + ("Ra.Enable", "AC_RA_ENABLE"), + ("Ra.IP", "AC_RA_IP"), + ("Updates.EnableDatabases", "AC_UPDATES_ENABLE_DATABASES"), + ("WorldServerPort", "AC_WORLD_SERVER_PORT"), + ("LoginDatabaseInfo", "AC_LOGIN_DATABASE_INFO"), + ("DataDir", "AC_DATA_DIR"), + ("Dynamic.XP.Reminder.Interval", "AC_DYNAMIC_XP_REMINDER_INTERVAL"), + ("EtherealBazaar.Enable", "AC_ETHEREAL_BAZAAR_ENABLE"), + ] { + assert_eq!(env_name(key), var, "{key}"); + } + } + + #[test] + fn the_settings_called_managed_are_exactly_the_variables_the_containers_get() { + // If a variable is added to the containers without being listed here (or the other way round), a screen would + // offer a setting that does nothing, or hide one that works. + let given: std::collections::BTreeSet = lifecycle::forced_variables().into_iter().collect(); + let listed: std::collections::BTreeSet = FORCED_KEYS.iter().map(|k| env_name(k)).collect(); + assert_eq!(given, listed); + assert!(managed_setting("ra.enable") && managed_setting("Updates.EnableDatabases") && !managed_setting("PlayerLimit")); + } + #[test] fn the_main_configs_are_created_from_their_templates_and_never_overwritten() { let d = tempfile::tempdir().unwrap(); From dfd8a50975d94f477779357c2d095321bb093c02 Mon Sep 17 00:00:00 2001 From: "Fabien F." <8136686+lostmind84@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:08:54 +0200 Subject: [PATCH 23/62] fix: sharing with friends and the other screens work on a Docker server Second part of the audit of every screen for a Docker server. What it found, and what now happens: - Play with friends. "Servers open" read the BindIP of the configuration files, which the containers force to 0.0.0.0, so it was green on a fresh server while who can connect is decided by the address the ports are published on (bindAddress in docker.json). Opening and closing now edit bindAddress (and set CoA.AllowRemoteClients), the open state is read from it, and enabling a mode no longer fails on netsh / PowerShell after it already rewrote the files. There is no firewall row on Linux (Docker publishes the ports itself); the Windows reply is unchanged. - The "private services" check (Friends and Diagnostics) could never fail on Linux: it reads the Windows listener table. For a Docker server it is computed from how the ports are published: the database never, the console only on 127.0.0.1, the game servers on bindAddress. - Console, Database tab: read a mysql log file that does not exist; it shows `docker logs` of the database container. - The Overview showed a service that had stopped by itself as "Stopped"; it now says "Stopped unexpectedly". - Settings: a Docker server that was added but never started had no worldserver.conf and the page failed; it is created from its template. A talent file path outside the Core folder is refused (the container cannot see it). - Backups of configuration now include docker.json; verifying the files of an imported Docker server looked for worldserver.exe and reported it missing. - Linux locations: the friend package and the diagnostics go to the desktop folder (from the user directories file) instead of /tmp, the problem report names the distribution, and the size advice for companions reads /proc/meminfo instead of assuming 8 GB. Every Windows branch is the code that was there; the dispatch is the early return on docker::is_docker (or the host flavor for the firewall). --- crates/coa-core/src/backup.rs | 2 +- crates/coa-core/src/config/mod.rs | 8 ++ crates/coa-core/src/console.rs | 13 +++ crates/coa-core/src/diag.rs | 40 +++++++- crates/coa-core/src/docker/logs.rs | 72 +++++++++++++ crates/coa-core/src/docker/mod.rs | 23 +++++ crates/coa-core/src/docker/sharing.rs | 139 ++++++++++++++++++++++++++ crates/coa-core/src/friends.rs | 6 ++ crates/coa-core/src/population.rs | 20 +++- docs/ARCHITECTURE.md | 8 ++ src-tauri/src/lib.rs | 30 ++++-- src/i18n/locales/en.ts | 1 + src/lib/api.ts | 3 +- src/screens/FriendsPage.tsx | 2 +- src/screens/Overview.tsx | 7 +- 15 files changed, 360 insertions(+), 14 deletions(-) create mode 100644 crates/coa-core/src/docker/logs.rs create mode 100644 crates/coa-core/src/docker/sharing.rs diff --git a/crates/coa-core/src/backup.rs b/crates/coa-core/src/backup.rs index 51e68e4..0f59c8a 100644 --- a/crates/coa-core/src/backup.rs +++ b/crates/coa-core/src/backup.rs @@ -212,7 +212,7 @@ pub fn config_files(root: &Path) -> Vec { for e in rd.flatten() { let n = e.file_name().to_string_lossy().to_string(); // database.json holds generated database passwords; it is launcher state, not configuration. - if e.path().is_file() && (n.contains(".template") || n == "repack.json") { + if e.path().is_file() && (n.contains(".template") || n == "repack.json" || n == "docker.json") { out.push(format!("Settings/{n}")); } } diff --git a/crates/coa-core/src/config/mod.rs b/crates/coa-core/src/config/mod.rs index f5c82a8..3cee550 100644 --- a/crates/coa-core/src/config/mod.rs +++ b/crates/coa-core/src/config/mod.rs @@ -206,6 +206,10 @@ pub fn drift(root: &Path) -> Result> { } pub fn load(root: &Path, scope: Scope) -> Result { + // A Docker server that was added but never started has no active world configuration yet; create it from its template. + if scope == Scope::Server && crate::docker::is_docker(root) { + let _ = crate::docker::ensure_main_configs(root); + } let schema = scope.schema(); let t = targets(root, scope)?; let conf = load_conf(&t.read)?; @@ -298,6 +302,10 @@ pub fn validate(root: &Path, scope: Scope, changes: &BTreeMap) -> } Some(s) => match s.to_raw(value) { Ok(raw) => { + if let Some(problem) = crate::docker::setting_problem(key, &raw).filter(|_| crate::docker::is_docker(root)) { + errors.push(FieldError { key: key.clone(), message: problem.into() }); + continue; + } raws.insert(key.clone(), raw); } Err(m) => errors.push(FieldError { key: key.clone(), message: m }), diff --git a/crates/coa-core/src/console.rs b/crates/coa-core/src/console.rs index 776244e..1cb356a 100644 --- a/crates/coa-core/src/console.rs +++ b/crates/coa-core/src/console.rs @@ -51,6 +51,19 @@ fn classify(l: &str) -> Level { } } +/// The last `max_lines` lines of some text (after filtering), classified and redacted like the lines of a log file. +pub fn lines_from_text(text: &str, filter: Option<&str>, max_lines: usize) -> Vec { + let needle = filter.map(str::to_lowercase).filter(|n| !n.is_empty()); + let out: Vec = text + .lines() + .filter(|l| !l.trim().is_empty()) + .filter(|l| needle.as_ref().map(|n| l.to_lowercase().contains(n)).unwrap_or(true)) + .map(|l| Line { level: classify(l), text: crate::diag::redact(l) }) + .collect(); + let skip = out.len().saturating_sub(max_lines); + out.into_iter().skip(skip).collect() +} + /// The last `max_lines` lines (after filtering) from the last ~512 KB of a log. Works on multi-gigabyte files. pub fn tail(path: &Path, filter: Option<&str>, max_lines: usize) -> Result> { const WINDOW: u64 = 512 * 1024; diff --git a/crates/coa-core/src/diag.rs b/crates/coa-core/src/diag.rs index a5ad4e8..73d9c4c 100644 --- a/crates/coa-core/src/diag.rs +++ b/crates/coa-core/src/diag.rs @@ -109,7 +109,8 @@ pub fn run(root: &Path, meta: &InstallMeta) -> Report { c.push(if crate::client::detect(Path::new(path), None).is_some() { check("client", "Game client", Level::Ok, path.clone()) } else { check("client", "Game client", Level::Warn, "The saved game folder was not found; choose it again in Settings.") }); } - let exposed: Vec<&str> = crate::net::exposure(&ports).iter().filter(|e| (e.what == "database" || e.what == "server console") && e.reachable_from_network).map(|e| e.what).collect(); + let exposure = if crate::docker::is_docker(root) { crate::docker::exposure(root, &ports) } else { crate::net::exposure(&ports) }; + let exposed: Vec<&str> = exposure.iter().filter(|e| (e.what == "database" || e.what == "server console") && e.reachable_from_network).map(|e| e.what).collect(); c.push(if exposed.is_empty() { check("exposure", "Private services", Level::Ok, "Database and server console are not reachable from the network.") } else { check("exposure", "Private services", Level::Fail, format!("Reachable from the network: {}.", exposed.join(", "))) }); let problems = c.iter().filter(|x| x.level != Level::Ok).count(); @@ -464,16 +465,53 @@ pub fn stamp() -> String { chrono::Local::now().format("%Y%m%d-%H%M%S").to_string() } +#[cfg(windows)] pub fn desktop_or_temp() -> PathBuf { let d = std::env::var_os("USERPROFILE").map(PathBuf::from).unwrap_or_else(std::env::temp_dir).join("Desktop"); if d.is_dir() { d } else { std::env::temp_dir() } } +/// Where a file meant for the person goes on Linux: the desktop folder they have (the XDG user directories file names it), else +/// `~/Desktop`, else `~/Downloads`, else the temporary folder. +#[cfg(not(windows))] +pub fn desktop_or_temp() -> PathBuf { + let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else { return std::env::temp_dir() }; + let dirs = std::fs::read_to_string(home.join(".config/user-dirs.dirs")).ok(); + pick_desktop(&home, dirs.as_deref(), &|p| p.is_dir()) +} + +#[cfg(not(windows))] +fn pick_desktop(home: &Path, user_dirs: Option<&str>, exists: &dyn Fn(&Path) -> bool) -> PathBuf { + let named = user_dirs.and_then(|t| { + t.lines().find_map(|l| { + let v = l.trim().strip_prefix("XDG_DESKTOP_DIR=")?.trim_matches('"'); + Some(PathBuf::from(v.replace("$HOME", &home.to_string_lossy()))) + }) + }); + // A user without a desktop sets XDG_DESKTOP_DIR to the home folder itself: that is not a place to drop files into. + [named, Some(home.join("Desktop")), Some(home.join("Downloads"))].into_iter().flatten().filter(|p| p != home).find(|p| exists(p)).unwrap_or_else(std::env::temp_dir) +} + #[cfg(test)] mod tests { use super::*; use crate::registry::{InstallKind, InstallMeta}; + #[cfg(not(windows))] + #[test] + fn the_desktop_folder_on_linux_follows_the_users_own_directories() { + let home = Path::new("/home/ana"); + let only = |wanted: &'static [&'static str]| move |p: &Path| wanted.contains(&p.to_str().unwrap()); + let dirs = "# comment\nXDG_DOWNLOAD_DIR=\"$HOME/Downloads\"\nXDG_DESKTOP_DIR=\"$HOME/Bureau\"\n"; + assert_eq!(pick_desktop(home, Some(dirs), &only(&["/home/ana/Bureau", "/home/ana/Desktop"])), PathBuf::from("/home/ana/Bureau")); + assert_eq!(pick_desktop(home, None, &only(&["/home/ana/Desktop"])), PathBuf::from("/home/ana/Desktop")); + assert_eq!(pick_desktop(home, Some(dirs), &only(&["/home/ana/Downloads"])), PathBuf::from("/home/ana/Downloads")); + assert_eq!(pick_desktop(home, None, &only(&[])), std::env::temp_dir()); + // No desktop: the user directories file names the home folder itself. + let none = "XDG_DESKTOP_DIR=\"$HOME/\"\n"; + assert_eq!(pick_desktop(home, Some(none), &only(&["/home/ana", "/home/ana/Downloads"])), PathBuf::from("/home/ana/Downloads")); + } + #[test] fn repeated_config_warnings_are_folded() { let t = "start diff --git a/crates/coa-core/src/docker/logs.rs b/crates/coa-core/src/docker/logs.rs new file mode 100644 index 0000000..19ca170 --- /dev/null +++ b/crates/coa-core/src/docker/logs.rs @@ -0,0 +1,72 @@ +//! The database of a Docker server has no log file in the server folder: MySQL writes to its container's output. + +use super::cli::{Call, Docker, SystemDocker}; +use super::Config; +use crate::console::Line; +use crate::error::{Error, Result}; +use std::path::Path; +use std::time::Duration; + +/// The last lines MySQL wrote (what `docker logs` returns), filtered and classified like the lines of a log file. +pub fn database_log(root: &Path, filter: Option<&str>, max_lines: usize) -> Result> { + database_log_with(&SystemDocker, root, filter, max_lines) +} + +pub fn database_log_with(d: &dyn Docker, root: &Path, filter: Option<&str>, max_lines: usize) -> Result> { + let name = Config::load(root)?.names().db; + // Ask for more than wanted: the filter may drop most of them. + let tail = (max_lines * 4).clamp(200, 8000).to_string(); + let out = d.run(&Call::new(&["logs", "--tail", &tail, &name], Duration::from_secs(20)))?; + if !out.ok() { + return Err(Error::Invalid("This log does not exist yet.".into())); + } + // MySQL writes to the error stream. + Ok(crate::console::lines_from_text(&format!("{}\n{}", out.stderr, out.stdout), filter, max_lines)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::docker::cli::Output; + use std::cell::RefCell; + + struct Fake { + out: Output, + calls: RefCell>>, + } + + impl Docker for Fake { + fn run(&self, call: &Call) -> Result { + self.calls.borrow_mut().push(call.args.clone()); + Ok(self.out.clone()) + } + } + + fn root() -> (tempfile::TempDir, std::path::PathBuf) { + let d = tempfile::tempdir().unwrap(); + let root = d.path().join("srv"); + std::fs::create_dir_all(root.join("Settings")).unwrap(); + std::fs::write(root.join("Settings/docker.json"), r#"{"project":"t1"}"#).unwrap(); + (d, root) + } + + #[test] + fn the_database_log_is_what_the_container_printed() { + let (_d, root) = root(); + let fake = Fake { out: Output { code: Some(0), stdout: "2026-10-04T10:00:00Z [System] ready\n".into(), stderr: "2026-10-04T10:00:01Z [ERROR] [MY-010] boom\n2026-10-04T10:00:02Z [Warning] slow\n".into() }, calls: Default::default() }; + let lines = database_log_with(&fake, &root, None, 100).unwrap(); + assert_eq!(fake.calls.borrow()[0][..2], ["logs", "--tail"]); + assert_eq!(fake.calls.borrow()[0].last().unwrap(), "coa-t1-db"); + assert_eq!(lines.len(), 3); + assert!(lines.iter().any(|l| l.text.contains("boom") && l.level == crate::console::Level::Error)); + let only = database_log_with(&fake, &root, Some("slow"), 100).unwrap(); + assert_eq!(only.len(), 1); + } + + #[test] + fn a_database_that_never_ran_has_no_log_yet() { + let (_d, root) = root(); + let fake = Fake { out: Output { code: Some(1), stdout: String::new(), stderr: "Error: No such container: coa-t1-db".into() }, calls: Default::default() }; + assert!(database_log_with(&fake, &root, None, 10).unwrap_err().to_string().contains("does not exist yet")); + } +} diff --git a/crates/coa-core/src/docker/mod.rs b/crates/coa-core/src/docker/mod.rs index d668bf1..4edf3d4 100644 --- a/crates/coa-core/src/docker/mod.rs +++ b/crates/coa-core/src/docker/mod.rs @@ -18,9 +18,12 @@ mod cli; pub mod fixture; pub mod install; mod lifecycle; +pub mod logs; +pub mod sharing; pub use cli::{Call, Docker, Output, SystemDocker}; pub use lifecycle::{check_docker, observe, observe_with, run, run_with}; +pub use sharing::{bind_is_open, exposure, set_open}; pub(crate) use lifecycle::destroy; use std::net::IpAddr; @@ -186,6 +189,15 @@ pub(crate) fn env_name(key: &str) -> String { out } +/// A reason a value cannot work on a Docker server, for settings that name a path: the server runs in a container that sees only +/// its own `Core` folder (as `/srv/core`, its working directory), so a path elsewhere on this computer does not exist for it. +pub fn setting_problem(key: &str, raw: &str) -> Option<&'static str> { + let raw = raw.trim().trim_matches('"'); + let outside = Path::new(raw).is_absolute() && !raw.starts_with("/srv/core/"); + (key.eq_ignore_ascii_case("CoaBots.TalentBuildsPath") && !raw.is_empty() && outside) + .then_some("must be a path inside the server's Core folder (for example reference/ascensionsidekick-level-builds.json), because the server runs in a container that cannot see other folders") +} + /// Name of the runtime image: it follows the content of its Dockerfile, so changing the libraries builds a new image. pub(crate) fn runtime_image() -> String { format!("coa-runtime:{}", &fsx::sha256_bytes(RUNTIME_DOCKERFILE.as_bytes())[..12]) @@ -252,6 +264,17 @@ mod tests { use super::*; use std::fs; + #[cfg(unix)] + #[test] + fn a_path_outside_the_core_folder_is_refused_for_a_docker_server() { + let key = "CoaBots.TalentBuildsPath"; + assert!(setting_problem(key, "reference/builds.json").is_none()); + assert!(setting_problem(key, "").is_none()); + assert!(setting_problem(key, "\"/srv/core/reference/builds.json\"").is_none()); + assert!(setting_problem(key, "/home/ana/builds.json").is_some()); + assert!(setting_problem("Rate.XP.Kill", "/home/ana/x").is_none(), "only settings that name a path"); + } + #[test] fn setting_names_become_the_variables_the_core_reads() { for (key, var) in [ diff --git a/crates/coa-core/src/docker/sharing.rs b/crates/coa-core/src/docker/sharing.rs new file mode 100644 index 0000000..b233d4d --- /dev/null +++ b/crates/coa-core/src/docker/sharing.rs @@ -0,0 +1,139 @@ +//! Sharing a Docker server with friends. Who can reach the game is decided by the address the container ports are +//! published on (`bindAddress` in `Settings/docker.json`): `127.0.0.1` is this computer only, anything else is the network. +//! The `BindIP` of the configuration files is forced to `0.0.0.0` inside the containers and changes nothing, so the +//! repack's way of opening the servers (editing `BindIP`) is replaced here. The database is never published and the server +//! console is only ever published on the loopback address. + +use std::fs; +use std::net::IpAddr; +use std::path::{Path, PathBuf}; + +use super::{Config, MARKER}; +use crate::config::parser::ConfFile; +use crate::config::{take_snapshot, Scope}; +use crate::error::Result; +use crate::fsx; +use crate::layout::Ports; +use crate::net::Exposure; +use crate::process::{ServiceState, ServiceStatus}; + +fn is_open(cfg: &Config) -> bool { + cfg.bind_address.parse::().map(|ip| !ip.is_loopback()).unwrap_or(false) +} + +/// Whether the login and world servers are published beyond this computer. +pub fn bind_is_open(root: &Path) -> bool { + Config::load(root).map(|c| is_open(&c)).unwrap_or(false) +} + +/// Publish the login and world servers beyond this computer (`0.0.0.0`) or only on it (`127.0.0.1`). An address the owner chose +/// by hand (for example one network card) is kept when opening. Returns true if anything changed, which needs a restart of +/// the containers. The old files are snapshotted. +pub fn set_open(root: &Path, meta: &Path, open: bool) -> Result { + let mut cfg = Config::load(root)?; + let mut originals: Vec<(PathBuf, Vec)> = Vec::new(); + let mut changed = false; + if is_open(&cfg) != open { + originals.push((root.join(MARKER), fs::read(root.join(MARKER))?)); + cfg.bind_address = if open { "0.0.0.0" } else { "127.0.0.1" }.into(); + changed = true; + } + // Friends connect from other addresses, and the server only applies the CoA client protocol to loopback unless remote clients + // are allowed; opening switches that on (closing leaves it, it only matters while the servers listen beyond this computer). + let coa_conf = root.join("Core/configs/modules/coa.conf"); + let mut coa_edit = None; + if open { + if let Ok(bytes) = fs::read(&coa_conf) { + let mut conf = ConfFile::parse_bytes(&bytes)?; + if conf.get("CoA.AllowRemoteClients").map(str::trim) != Some("1") { + conf.set("CoA.AllowRemoteClients", "1", &["Set by CoA Server Manager together with the friends mode"]); + originals.push((coa_conf.clone(), bytes)); + coa_edit = Some(conf.to_text()); + } + } + } + if !changed && coa_edit.is_none() { + return Ok(false); + } + take_snapshot(meta, Scope::Server, if open { "before opening the servers to friends" } else { "before closing the servers to friends" }, &originals)?; + if changed { + fsx::atomic_write_json(&root.join(MARKER), &cfg)?; + } + if let Some(text) = coa_edit { + fsx::atomic_write(&coa_conf, text.as_bytes())?; + } + Ok(changed) +} + +/// What each service can be reached from, computed from how the containers publish their ports: the database is never published, +/// the console only on the loopback address, the game servers on `bindAddress`. +pub fn exposure(root: &Path, ports: &Ports) -> Vec { + let open = bind_is_open(root); + let observed = crate::process::observe(root, ports); + let up = |s: &ServiceStatus| matches!(s.state, ServiceState::Running | ServiceState::Starting); + vec![ + Exposure { port: ports.mysql, what: "database", listening: up(&observed.mysql), reachable_from_network: false }, + Exposure { port: ports.ra, what: "server console", listening: up(&observed.world), reachable_from_network: false }, + Exposure { port: ports.auth, what: "login server", listening: up(&observed.auth), reachable_from_network: open && up(&observed.auth) }, + Exposure { port: ports.world, what: "game world", listening: up(&observed.world), reachable_from_network: open && up(&observed.world) }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn server(bind: Option<&str>) -> (tempfile::TempDir, PathBuf, PathBuf) { + let d = tempfile::tempdir().unwrap(); + let root = d.path().join("srv"); + let meta = d.path().join("srv.manager"); + fs::create_dir_all(root.join("Settings")).unwrap(); + fs::create_dir_all(root.join("Core/configs/modules")).unwrap(); + fs::create_dir_all(&meta).unwrap(); + let extra = bind.map(|b| format!(r#","bindAddress":"{b}""#)).unwrap_or_default(); + fs::write(root.join(MARKER), format!(r#"{{"project":"t1"{extra}}}"#)).unwrap(); + fs::write(root.join("Core/configs/modules/coa.conf"), "CoA.Enable = 1\n").unwrap(); + (d, root, meta) + } + + #[test] + fn opening_and_closing_changes_the_published_address_and_allows_remote_clients() { + let (_d, root, meta) = server(None); + assert!(!bind_is_open(&root)); + assert!(set_open(&root, &meta, true).unwrap(), "a restart is needed"); + assert!(bind_is_open(&root)); + assert_eq!(Config::load(&root).unwrap().bind_address, "0.0.0.0"); + assert!(fs::read_to_string(root.join("Core/configs/modules/coa.conf")).unwrap().contains("CoA.AllowRemoteClients = 1")); + assert!(!set_open(&root, &meta, true).unwrap(), "nothing left to change"); + assert!(set_open(&root, &meta, false).unwrap()); + assert!(!bind_is_open(&root)); + assert_eq!(Config::load(&root).unwrap().bind_address, "127.0.0.1"); + } + + #[test] + fn an_address_chosen_by_hand_is_kept_when_opening_and_other_settings_survive() { + let (_d, root, meta) = server(Some("192.168.1.20")); + assert!(bind_is_open(&root), "any address but the loopback is the network"); + set_open(&root, &meta, true).unwrap(); + let cfg = Config::load(&root).unwrap(); + assert_eq!(cfg.bind_address, "192.168.1.20"); + assert_eq!(cfg.project, "t1"); + } + + #[test] + fn the_old_settings_are_snapshotted_before_a_change() { + let (_d, root, meta) = server(None); + set_open(&root, &meta, true).unwrap(); + assert_eq!(crate::config::list_snapshots(&meta).len(), 1); + } + + #[test] + fn the_database_and_the_console_are_never_reported_reachable() { + let (_d, root, _meta) = server(Some("0.0.0.0")); + let rows = exposure(&root, &Ports::default()); + let by = |what: &str| rows.iter().find(|e| e.what == what).unwrap(); + assert!(!by("database").reachable_from_network && !by("server console").reachable_from_network); + // Nothing runs in this test (docker is not asked to start anything), so nothing is reachable yet. + assert!(rows.iter().all(|e| !e.reachable_from_network)); + } +} diff --git a/crates/coa-core/src/friends.rs b/crates/coa-core/src/friends.rs index 9b8a71a..6b502e7 100644 --- a/crates/coa-core/src/friends.rs +++ b/crates/coa-core/src/friends.rs @@ -80,6 +80,9 @@ fn bind_targets(root: &Path) -> Vec { /// Whether the configuration currently lets other computers reach the login and world servers. pub fn bind_is_open(root: &Path) -> bool { + if crate::docker::is_docker(root) { + return crate::docker::bind_is_open(root); + } let files = bind_targets(root); !files.is_empty() && files @@ -90,6 +93,9 @@ pub fn bind_is_open(root: &Path) -> bool { /// Open (`0.0.0.0`) or close (`127.0.0.1`) the login and world servers to other computers. The database and the server /// console are never touched. Returns true if anything changed (a restart is then needed). The old files are snapshotted. pub fn set_open(root: &Path, meta: &Path, open: bool) -> Result { + if crate::docker::is_docker(root) { + return crate::docker::set_open(root, meta, open); + } let want = if open { "\"0.0.0.0\"" } else { "\"127.0.0.1\"" }; let mut originals = Vec::new(); let mut edits = Vec::new(); diff --git a/crates/coa-core/src/population.rs b/crates/coa-core/src/population.rs index aca46da..38ba8b3 100644 --- a/crates/coa-core/src/population.rs +++ b/crates/coa-core/src/population.rs @@ -71,7 +71,15 @@ pub fn hardware() -> Hardware { #[cfg(not(windows))] pub fn hardware() -> Hardware { - Hardware { cores: std::thread::available_parallelism().map(|n| n.get() as u32).unwrap_or(2), ram_gb: 8.0, free_ram_gb: 4.0 } + let (total, free) = std::fs::read_to_string("/proc/meminfo").ok().and_then(|t| parse_meminfo(&t)).unwrap_or((8.0, 4.0)); + Hardware { cores: std::thread::available_parallelism().map(|n| n.get() as u32).unwrap_or(2), ram_gb: total, free_ram_gb: free } +} + +/// (total, available) memory in GB from the text of `/proc/meminfo`. +#[cfg(not(windows))] +fn parse_meminfo(text: &str) -> Option<(f64, f64)> { + let kb = |key: &str| -> Option { text.lines().find_map(|l| l.strip_prefix(key)?.trim().strip_suffix("kB")?.trim().parse::().ok()) }; + Some((kb("MemTotal:")? / (1u64 << 20) as f64, kb("MemAvailable:")? / (1u64 << 20) as f64)) } #[derive(Debug, Clone, Serialize)] @@ -105,6 +113,16 @@ pub fn sizes(h: &Hardware) -> Vec { mod tests { use super::*; + #[cfg(not(windows))] + #[test] + fn memory_is_read_from_proc_meminfo() { + let t = "MemTotal: 65536000 kB\nMemFree: 1000000 kB\nMemAvailable: 16777216 kB\nBuffers: 1 kB\n"; + let (total, free) = parse_meminfo(t).unwrap(); + assert!((total - 62.5).abs() < 0.01 && (free - 16.0).abs() < 0.01, "{total} {free}"); + assert!(parse_meminfo("nothing useful").is_none()); + assert!(hardware().ram_gb > 0.0); + } + #[test] fn parses_counts_and_never_goes_negative() { let p = parse_counts("12\t9\t400").unwrap(); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1de9358..a056b2d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -422,6 +422,14 @@ hand over to `coa_core::docker`; nothing else needed to change for start, stop, DivxTac.dll deadlocks the world loading screen under wine-mono. `COA_PROTONPATH`, `COA_WINEPREFIX` or `client-launch.json` in the Manager's folder (`proton_path`, `prefix`, `runner`) override the detection. Whether the client runs is read from `/proc` (under Wine the command line holds the Windows form of the path). Output goes to `client.log`. +* **Sharing with friends** (`docker/sharing.rs`): who can reach the game is decided by the address the container ports are + published on, `bindAddress` in `docker.json`; the `BindIP` of the configuration files is forced to `0.0.0.0` inside the + containers and changes nothing. Opening and closing therefore edit `bindAddress` (and set `CoA.AllowRemoteClients`), "servers + open" and the exposure check are computed from it (the database is never published, the console only on `127.0.0.1`), and + there is no firewall row on Linux. Settings the backend fixes (`docker::FORCED_KEYS`) are not offered; a test keeps that list + equal to the variables the containers get. A world that restarts itself (exit code 2) is restarted by Docker once it has come + up. The database console shows `docker logs`, and the file-based screens read Linux locations (the desktop folder from the + user directories file, memory from `/proc/meminfo`, the distribution name for the problem report). * **Not done yet**: downloading the game data, updates, port conflict detection, the firewall and exposure checks, the Wildcard realm profiles, and the client under Wine/Proton. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7dc9d80..8138327 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -156,7 +156,9 @@ async fn add_server(state: State<'_, AppState>, path: String) -> std::result::Re meta.core.version = report.banner_revision.clone(); meta.database.port = Some(report.ports.mysql); meta.database.schemas = report.database_schemas.clone(); - for (rel, exe) in [("Core/worldserver.exe", &report.worldserver), ("Core/authserver.exe", &report.authserver)] { + // The executables are `worldserver.exe` in a repack and `worldserver` in a Docker server; the file check reads these names. + let exe_names = if coa_core::docker::is_docker(std::path::Path::new(&report.path)) { ("Core/worldserver", "Core/authserver") } else { ("Core/worldserver.exe", "Core/authserver.exe") }; + for (rel, exe) in [(exe_names.0, &report.worldserver), (exe_names.1, &report.authserver)] { if let Some(e) = exe { meta.original_hashes.insert(rel.into(), e.sha256.clone()); } @@ -1374,7 +1376,7 @@ struct FriendsStatus { exposure: Vec, /// The configuration lets other computers reach the login and world servers. servers_open: bool, - firewall: coa_core::firewall::Status, + firewall: Option, tailscale: coa_core::net::Tailscale, server_running: bool, auth_port: u16, @@ -1419,7 +1421,18 @@ fn report_targets() -> Vec { coa_core::report::targets() } +/// The distribution on Linux ("Linux (Arch Linux)"), from `/etc/os-release`; empty if it cannot be read. +#[cfg(not(windows))] +fn windows_version() -> String { + std::fs::read_to_string("/etc/os-release") + .ok() + .and_then(|t| t.lines().find_map(|l| l.strip_prefix("PRETTY_NAME=").map(|v| v.trim_matches('"').to_string()))) + .map(|name| format!("Linux ({name})")) + .unwrap_or_default() +} + /// "Windows 11 (build 26200)" from `ver`; empty if it cannot be read. +#[cfg(windows)] fn windows_version() -> String { let mut cmd = std::process::Command::new("cmd.exe"); cmd.args(["/C", "ver"]); @@ -1468,9 +1481,10 @@ async fn friends_status(state: State<'_, AppState>, id: String) -> std::result:: settings: coa_core::friends::load(&meta), lan_ip: automatic.map(|a| a.to_string()), lan_addresses: coa_core::net::lan_addresses(automatic).unwrap_or_default(), - exposure: coa_core::net::exposure(&ports), + exposure: if coa_core::docker::is_docker(&root) { coa_core::docker::exposure(&root, &ports) } else { coa_core::net::exposure(&ports) }, servers_open: coa_core::friends::bind_is_open(&root), - firewall: coa_core::firewall::status(), + // The Windows firewall exists only on Windows; on Linux Docker publishes the ports itself. + firewall: (platform::flavor() == Flavor::Repack).then(coa_core::firewall::status), tailscale: coa_core::net::tailscale(), server_running: coa_core::process::observe(&root, &ports).world.state == coa_core::process::ServiceState::Running, auth_port: ports.auth, @@ -1540,7 +1554,7 @@ async fn friends_enable( }; let open = mode != Mode::Local; let changed = friends::set_open(&root, &meta, open)?; - if open { + if open && platform::flavor() == Flavor::Repack { coa_core::firewall::ensure_rules_with_secondary(&ports, secondary)?; } if mode == Mode::Direct && use_upnp { @@ -1573,7 +1587,7 @@ async fn friends_package(state: State<'_, AppState>, id: String) -> std::result: blocking(move || { let meta = meta_dir(&root)?; let host = coa_core::friends::load(&meta).host.ok_or_else(|| Error::Invalid("Choose how friends connect first.".into()))?; - let desktop = std::env::var_os("USERPROFILE").map(PathBuf::from).unwrap_or_else(std::env::temp_dir).join("Desktop"); + let desktop = if platform::flavor() == Flavor::Docker { coa_core::diag::desktop_or_temp() } else { std::env::var_os("USERPROFILE").map(PathBuf::from).unwrap_or_else(std::env::temp_dir).join("Desktop") }; let out = if desktop.is_dir() { desktop } else { std::env::temp_dir() }.join("CoA-Friend-Setup.zip"); coa_core::friends::make_friend_package(&root, &host, true, &out)?; Ok(out.to_string_lossy().into_owned()) @@ -1634,6 +1648,10 @@ async fn console_tail( ) -> std::result::Result, UiError> { let root = path_of(&state, &id)?; blocking(move || { + // A Docker server's database writes to its container's output, not to a file. + if coa_core::docker::is_docker(&root) && matches!(source, coa_core::console::Source::Database) { + return coa_core::docker::logs::database_log(&root, filter.as_deref(), lines.unwrap_or(300).min(2000)); + } let path = coa_core::console::log_path(&root, &data_dir().join("logs").join("manager.log"), source); coa_core::console::tail(&path, filter.as_deref(), lines.unwrap_or(300).min(2000)) }) diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 6d42718..21910e5 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -101,6 +101,7 @@ const en = { "status.attention": "Needs attention", "status.running": "Running", "status.stopped": "Stopped", + "status.crashed": "Stopped unexpectedly", "btn.start": "START SERVER", "btn.stop": "STOP SERVER", diff --git a/src/lib/api.ts b/src/lib/api.ts index e3dcb13..dc0c173 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -420,7 +420,8 @@ export interface FriendsStatus { lan_addresses: LanAddress[]; exposure: { port: number; what: string; reachable_from_network: boolean; listening: boolean }[]; servers_open: boolean; - firewall: { auth: boolean; world: boolean }; + /** null where there is no Windows firewall to ask about (Linux). */ + firewall: { auth: boolean; world: boolean } | null; tailscale: { installed: boolean; ip: string | null; connected: boolean }; server_running: boolean; auth_port: number; diff --git a/src/screens/FriendsPage.tsx b/src/screens/FriendsPage.tsx index a7d3059..6371e76 100644 --- a/src/screens/FriendsPage.tsx +++ b/src/screens/FriendsPage.tsx @@ -170,7 +170,7 @@ export function FriendsPage({ serverId }: { serverId: string }) {
    {t("fr.st.running")} {t("fr.st.open")} - {t("fr.st.firewall")} + {st.firewall && {t("fr.st.firewall")}} e.what === "database" || e.what === "server console").every((e) => !e.reachable_from_network)}> {t("fr.st.private")} diff --git a/src/screens/Overview.tsx b/src/screens/Overview.tsx index 58175c7..b0f05e1 100644 --- a/src/screens/Overview.tsx +++ b/src/screens/Overview.tsx @@ -46,21 +46,22 @@ function Row({ label, s }: { label: string; s: ServiceStatus }) { const t = useT(); const running = s.state === "running"; const starting = s.state === "starting"; + const crashed = s.state === "crashed"; return (
    {label} - {running ? t("status.running") : starting ? t("status.starting") : t("status.stopped")} + {running ? t("status.running") : starting ? t("status.starting") : crashed ? t("status.crashed") : t("status.stopped")}
    ); From 0250b61c621d0fd13f969ef0e71a6e404c39019b Mon Sep 17 00:00:00 2001 From: "Fabien F." <8136686+lostmind84@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:40:03 +0200 Subject: [PATCH 24/62] feat: a wrong game data folder is diagnosed, and it can be changed from Settings The world refusing to start with the standard Data set (no CoA DBC) is now reported as GameDataMismatch. A Settings card shows the folder in use and changes it while the server is stopped, using the install checks. --- crates/coa-core/src/docker/install.rs | 2 +- crates/coa-core/src/docker/mod.rs | 45 +++++++++++++++++++++++++++ crates/coa-core/src/error.rs | 6 ++++ crates/coa-core/src/health.rs | 9 ++++++ docs/ARCHITECTURE.md | 4 +++ src-tauri/src/lib.rs | 27 ++++++++++++++++ src/dev/mock.ts | 2 ++ src/i18n/locales/en.ts | 7 +++++ src/lib/api.ts | 3 ++ src/screens/GameDataCard.tsx | 44 ++++++++++++++++++++++++++ src/screens/SettingsHome.tsx | 3 ++ 11 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 src/screens/GameDataCard.tsx diff --git a/crates/coa-core/src/docker/install.rs b/crates/coa-core/src/docker/install.rs index e427c18..48ef998 100644 --- a/crates/coa-core/src/docker/install.rs +++ b/crates/coa-core/src/docker/install.rs @@ -112,7 +112,7 @@ pub fn preflight(dest: &Path, data_dir: &Path, needed_bytes: u64, registry: &Reg Preflight { ok: problems.is_empty(), problems, free_bytes } } -fn data_problems(data_dir: &Path) -> Vec { +pub(crate) fn data_problems(data_dir: &Path) -> Vec { let s = data_dir.to_string_lossy(); if !data_dir.is_absolute() || s.contains(':') { return vec![problem("data_path", "Choose the game data folder with its full path, without a colon in it.")]; diff --git a/crates/coa-core/src/docker/mod.rs b/crates/coa-core/src/docker/mod.rs index 4edf3d4..0ad30e2 100644 --- a/crates/coa-core/src/docker/mod.rs +++ b/crates/coa-core/src/docker/mod.rs @@ -189,6 +189,29 @@ pub(crate) fn env_name(key: &str) -> String { out } +/// The folder mounted as the game data of this Docker server. +pub fn game_data_dir(root: &Path) -> Result { + Ok(Config::load(root)?.data_path(root)) +} + +/// Point a Docker server at another game data folder (used in place, read-only). The containers are recreated at the next +/// start, so the servers must be stopped: a running one keeps the folder it has. +pub fn set_game_data(root: &Path, path: &Path) -> Result<()> { + let problems = install::data_problems(path); + if !problems.is_empty() { + return Err(Error::Invalid(problems.iter().map(|p| p.message.clone()).collect::>().join(" "))); + } + let observed = crate::process::observe(root, &crate::layout::read_ports(root)); + let busy = |s: &crate::process::ServiceStatus| matches!(s.state, crate::process::ServiceState::Running | crate::process::ServiceState::Starting | crate::process::ServiceState::Stopping); + if busy(&observed.world) || busy(&observed.auth) { + return Err(Error::Invalid("Stop the server before changing the game data folder.".into())); + } + let mut cfg = Config::load(root)?; + cfg.data_dir = Some(path.to_string_lossy().into_owned()); + cfg.validate()?; + fsx::atomic_write_json(&root.join(MARKER), &cfg) +} + /// A reason a value cannot work on a Docker server, for settings that name a path: the server runs in a container that sees only /// its own `Core` folder (as `/srv/core`, its working directory), so a path elsewhere on this computer does not exist for it. pub fn setting_problem(key: &str, raw: &str) -> Option<&'static str> { @@ -264,6 +287,28 @@ mod tests { use super::*; use std::fs; + #[cfg(unix)] + #[test] + fn the_game_data_folder_can_be_changed_when_the_servers_are_stopped() { + let d = tempfile::tempdir().unwrap(); + let root = d.path().join("srv"); + std::fs::create_dir_all(root.join("Settings")).unwrap(); + std::fs::write(root.join(MARKER), r#"{"project":"t1","dataDir":"/old/data"}"#).unwrap(); + let good = d.path().join("data"); + std::fs::create_dir_all(good.join("dbc")).unwrap(); + std::fs::create_dir_all(good.join("maps")).unwrap(); + assert_eq!(game_data_dir(&root).unwrap(), std::path::PathBuf::from("/old/data")); + set_game_data(&root, &good).unwrap(); + assert_eq!(game_data_dir(&root).unwrap(), good); + assert_eq!(Config::load(&root).unwrap().project, "t1", "the other settings are kept"); + // A folder that does not look like game data is refused and nothing changes. + let empty = d.path().join("empty"); + std::fs::create_dir_all(&empty).unwrap(); + assert!(set_game_data(&root, &empty).is_err()); + assert_eq!(game_data_dir(&root).unwrap(), good); + assert!(set_game_data(&root, std::path::Path::new("relative/dir")).is_err()); + } + #[cfg(unix)] #[test] fn a_path_outside_the_core_folder_is_refused_for_a_docker_server() { diff --git a/crates/coa-core/src/error.rs b/crates/coa-core/src/error.rs index f2b236c..9ccabff 100644 --- a/crates/coa-core/src/error.rs +++ b/crates/coa-core/src/error.rs @@ -55,6 +55,7 @@ pub enum ErrorCode { OperationInProgress, StartupFailed, DockerUnavailable, + GameDataMismatch, HashMismatch, PathRejected, PackageNotPublished, @@ -138,6 +139,11 @@ impl ErrorCode { "This server runs in Docker. Install Docker, start it, and make sure your user account may use it, then try again.", &[Retry, ShowDetails], ), + ErrorCode::GameDataMismatch => ( + "The game data folder is not the CoA set", + "The server stopped because the DBC files in the game data folder are not the ones of the Conquest of Azeroth client. Choose the folder that holds the CoA data (Settings > Game data folder), then start again.", + &[ShowDetails], + ), ErrorCode::HashMismatch => ( "A downloaded file is damaged", "The file did not match its checksum and was not used.", diff --git a/crates/coa-core/src/health.rs b/crates/coa-core/src/health.rs index 65f608c..312e666 100644 --- a/crates/coa-core/src/health.rs +++ b/crates/coa-core/src/health.rs @@ -25,6 +25,8 @@ pub fn diagnose(text: &str) -> Option { Some(ErrorCode::DatabaseNotRunning) } else if l.contains("address already in use") || l.contains("could not bind") || l.contains("only one usage of each socket") { Some(ErrorCode::PortInUse) + } else if l.contains("does not hold the coa client dbc set") || l.contains("_outdated_ dbc data") { + Some(ErrorCode::GameDataMismatch) } else if l.contains("config::loadfile") && l.contains("failed open file") && l.contains("worldserver.conf") { Some(ErrorCode::ServerFilesIncomplete) } else { @@ -57,6 +59,13 @@ pub fn diagnose_installation(root: &Path) -> Option { mod tests { use super::*; + #[test] + fn a_game_data_folder_without_the_coa_dbc_set_is_named_as_such() { + let log = "Using DataDir /srv/data/\nSpellAffect.dbc not found in /srv/data/dbc/\nDataDir does not hold the CoA client DBC set. Install it with apps/coa-dbc/client_dbc.py."; + assert_eq!(diagnose(log), Some(ErrorCode::GameDataMismatch)); + assert_eq!(diagnose("You have _outdated_ DBC data. Please extract correct versions from current using client."), Some(ErrorCode::GameDataMismatch)); + } + #[test] fn recognises_missing_migrations() { let log = "ERROR [sql.sql] [1146] Table 'acore_world.coa_boss' doesn't exist\nFATAL [sql.sql] Your database structure is not up to date."; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a056b2d..476fd30 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -430,6 +430,10 @@ hand over to `coa_core::docker`; nothing else needed to change for start, stop, equal to the variables the containers get. A world that restarts itself (exit code 2) is restarted by Docker once it has come up. The database console shows `docker logs`, and the file-based screens read Linux locations (the desktop folder from the user directories file, memory from `/proc/meminfo`, the distribution name for the problem report). +* **Game data folder** (`docker::game_data_dir` / `set_game_data`, Settings card): `dataDir` in `docker.json` can be changed + after the install, only while the world and auth are stopped, and only to a folder that passes the install checks. A world + that stops with "does not hold the CoA client dbc set" is reported as `GameDataMismatch` (wrong data set: the standard one, + not Conquest of Azeroth's) instead of a bare failure. * **Not done yet**: downloading the game data, updates, port conflict detection, the firewall and exposure checks, the Wildcard realm profiles, and the client under Wine/Proton. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8138327..134d6e6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -110,6 +110,31 @@ fn summary(id: String, path: PathBuf) -> ServerSummary { ServerSummary { id, name, path: path.to_string_lossy().into_owned() } } +/// The game data folder of a Docker server; none for a repack, whose data is inside its own folder. +#[tauri::command] +fn game_data_folder(state: State<'_, AppState>, id: String) -> std::result::Result, UiError> { + let root = path_of(&state, &id)?; + if !coa_core::docker::is_docker(&root) { + return Ok(None); + } + Ok(Some(coa_core::docker::game_data_dir(&root)?.to_string_lossy().into_owned())) +} + +/// Point a Docker server at another game data folder. The servers must be stopped. +#[tauri::command] +async fn set_game_data_folder(state: State<'_, AppState>, id: String, path: String) -> std::result::Result { + let root = path_of(&state, &id)?; + let _guard = BusyGuard::acquire(&state, &id)?; + blocking(move || { + if !coa_core::docker::is_docker(&root) { + return Err(Error::Invalid("The game data of this server is inside its folder.".into())); + } + coa_core::docker::set_game_data(&root, std::path::Path::new(&path))?; + Ok(path) + }) + .await +} + #[tauri::command] fn default_install_dir() -> String { if platform::flavor() == Flavor::Docker { @@ -1688,6 +1713,8 @@ pub fn run() { .manage(AppState { registry: Registry::at(dir.join("installs.json")), busy: Mutex::new(HashSet::new()), install_cancel: Mutex::new(None), client_cancel: Mutex::new(None) }) .invoke_handler(tauri::generate_handler![ default_install_dir, + game_data_folder, + set_game_data_folder, install_environment, scan_server, add_server, diff --git a/src/dev/mock.ts b/src/dev/mock.ts index 122ad26..89b6b31 100644 --- a/src/dev/mock.ts +++ b/src/dev/mock.ts @@ -92,6 +92,8 @@ export {}; if(cmd==="open_link"){(window as any).__lastLink=args.url;return null;} if(cmd==="export_diagnostics") return "C:\\Users\\you\\Desktop\\CoA-Diagnostics-20261001-140000.zip"; if(cmd==="list_crashes") return []; + if(cmd==="game_data_folder") return new URLSearchParams(location.search).get("flavor")==="docker"?"/srv/coa/server-data":null; + if(cmd==="set_game_data_folder") return args.path; if(cmd==="install_environment"){const docker=new URLSearchParams(location.search).get("flavor")==="docker";return docker?{flavor:"docker",default_dir:"/home/you/CoaServer",docker_problem:new URLSearchParams(location.search).has("nodocker")?"permission denied while trying to connect to the Docker daemon socket":null}:{flavor:"repack",default_dir:"C:\\Games\\CoA Server",docker_problem:null};} if(cmd==="install_preflight") return {ok:true,problems:[],free_bytes:210*2**30}; if(cmd==="install_requirements") return {download_bytes:Math.round(5.9*2**30),unpacked_bytes:Math.round(11.4*2**30),version:"0.2.0"}; diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 21910e5..90c80fa 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -144,6 +144,8 @@ const en = { "err.operation_in_progress.message": "The server is busy starting or stopping. Wait a moment and try again.", "err.startup_failed.title": "Server could not start", "err.startup_failed.message": "A service stopped while starting. Check the details for the cause.", + "err.game_data_mismatch.title": "The game data folder is not the CoA set", + "err.game_data_mismatch.message": "The server stopped because the DBC files in the game data folder are not the ones of the Conquest of Azeroth client. Choose the folder that holds the CoA data (Settings > Game data folder), then start again.", "err.docker_unavailable.title": "Docker is not available", "err.docker_unavailable.message": "This server runs in Docker. Install Docker, start it, and make sure your user account may use it, then try again.", "err.hash_mismatch.title": "A downloaded file is damaged", @@ -191,6 +193,11 @@ const en = { "install.localPkg": "Install from a local package folder instead of downloading", "install.localPkgHint": "(leave empty to download)", "install.button": "Install", + "gamedata.title": "Game data folder", + "gamedata.text": "The folder with the game's dbc, maps, vmaps and mmaps that this server reads. It is used where it is and never changed. It must be the Conquest of Azeroth set, not the standard one. Stop the server before changing it.", + "gamedata.change": "Change…", + "gamedata.dialog": "Choose the game data folder", + "gamedata.saved": "Saved. It is used the next time the server starts.", "install.docker.title": "Docker is needed", "install.docker.text": "On Linux the server runs in Docker. Install Docker and start it, or allow your user to use it, then check again.", "install.docker.retry": "Check again", diff --git a/src/lib/api.ts b/src/lib/api.ts index dc0c173..3c3c153 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -498,6 +498,9 @@ export const api = { restoreConfigs: (id: string, backupId: string) => invoke("restore_backup_configs", { id, backupId }), restoreDatabase: (id: string, backupId: string, database: string) => invoke("restore_backup_database", { id, backupId, database }), installEnvironment: () => invoke("install_environment"), + /** The game data folder of a Docker server; null for a repack. */ + gameDataFolder: (id: string) => invoke("game_data_folder", { id }), + setGameDataFolder: (id: string, path: string) => invoke("set_game_data_folder", { id, path }), installPreflight: (dest: string, needed?: number, gameData?: string) => invoke("install_preflight", { dest, needed: needed ?? null, gameData: gameData ?? null }), installRequirements: (pkg?: string) => invoke("install_requirements", { package: pkg ?? null }), realmlistProfiles: (id: string) => invoke<{ profiles: RealmProfile[]; active: string | null }>("realmlist_profiles", { id }), diff --git a/src/screens/GameDataCard.tsx b/src/screens/GameDataCard.tsx new file mode 100644 index 0000000..1f3d22d --- /dev/null +++ b/src/screens/GameDataCard.tsx @@ -0,0 +1,44 @@ +import { useEffect, useState } from "react"; +import { open } from "@tauri-apps/plugin-dialog"; +import { api, asUiError } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { useT } from "@/i18n"; + +/** Where a Docker server reads the game data from. Not shown for a repack, which keeps its data in its own folder. */ +export function GameDataCard({ serverId }: { serverId: string }) { + const t = useT(); + const [folder, setFolder] = useState(null); + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); + + useEffect(() => { + let alive = true; + void api.gameDataFolder(serverId).then((f) => alive && setFolder(f)).catch(() => alive && setFolder(null)); + return () => { alive = false; }; + }, [serverId]); + + if (folder === null) return null; + + async function change() { + const picked = await open({ directory: true, multiple: false, title: t("gamedata.dialog"), defaultPath: folder ?? undefined }); + if (typeof picked !== "string") return; + try { + setFolder(await api.setGameDataFolder(serverId, picked)); + setMessage({ ok: true, text: t("gamedata.saved") }); + } catch (e) { + setMessage({ ok: false, text: asUiError(e).technical }); + } + } + + return ( + +

    {t("gamedata.title")}

    +

    {t("gamedata.text")}

    +
    + {folder} + +
    + {message &&

    {message.text}

    } +
    + ); +} diff --git a/src/screens/SettingsHome.tsx b/src/screens/SettingsHome.tsx index e3e999e..525cdd9 100644 --- a/src/screens/SettingsHome.tsx +++ b/src/screens/SettingsHome.tsx @@ -7,6 +7,7 @@ import { api, asUiError, type UiError, type UpdatePreview, type UpdateTxn } from import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { ClientCard } from "@/screens/ClientCard"; +import { GameDataCard } from "@/screens/GameDataCard"; import { DiagnosticsCard } from "@/screens/DiagnosticsCard"; import { AboutCard } from "@/screens/AboutCard"; import { RemoveServerCard } from "@/screens/RemoveServerCard"; @@ -174,6 +175,8 @@ export function SettingsHome({ serverId, path, onForget }: { serverId: string; p + + From 3fb4ee39c681d24454edbf7c7cca31d53ac7e050 Mon Sep 17 00:00:00 2001 From: "Fabien F." <8136686+lostmind84@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:02:59 +0200 Subject: [PATCH 25/62] fix: the realm is not shown offline after a start on Docker The auth server marks every realm offline when it starts, after the world server cleared its own flag. Mark the active realm online once the auth server listens. --- crates/coa-core/src/docker/lifecycle.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/coa-core/src/docker/lifecycle.rs b/crates/coa-core/src/docker/lifecycle.rs index 6620c21..6fff3c7 100644 --- a/crates/coa-core/src/docker/lifecycle.rs +++ b/crates/coa-core/src/docker/lifecycle.rs @@ -255,9 +255,26 @@ fn start(d: &dyn Docker, root: &Path, cfg: &Config, with_game: bool, log: &mut L run_container(d, &call, &n.auth, log)?; } wait_listening(d, cfg, &n.auth, ports.auth, AUTH_WAIT, log)?; + mark_realm_online(d, &n, &secrets, realm, log); Ok(()) } +/// The auth server marks every realm offline when it starts, and the world server (already up, see the order above) only +/// puts itself back after its next uptime update, ten minutes later by default: until then the client shows "Realm is +/// Offline". The world is known to be listening here, so say so for the realm it serves. The other realm of a Wildcard +/// install keeps the offline flag it is given on purpose (`realms::setup_realmlist`). +fn mark_realm_online(d: &dyn Docker, n: &Names, secrets: &Secrets, realm: crate::realms::Mode, log: &mut Log) { + let sql = format!("UPDATE acore_auth.realmlist SET flag = flag & ~2 WHERE id = {};", realm.realm_id()); + let mut call = Call::new(&["exec", "-i", "-e", "MYSQL_PWD", &n.db, "mysql", "--user=root", "--protocol=tcp", "--host=127.0.0.1"], QUICK); + call.env = vec![("MYSQL_PWD".into(), secrets.root.clone())]; + call.stdin = Some(sql.as_bytes()); + match d.run(&call) { + Ok(o) if o.ok() => {} + Ok(o) => log.say(format!("the realm could not be marked online: {}", o.text().trim())), + Err(e) => log.say(format!("the realm could not be marked online: {e}")), + } +} + fn remove(d: &dyn Docker, name: &str) { // A stopped container of the same name is replaced, so its settings always match the files on disk. let _ = d.run(&Call::new(&["rm", "-f", name], QUICK)); @@ -690,6 +707,7 @@ mod tests { out(0, "containerid\n", "") } ["update", "--restart", _, _] => out(0, "", ""), + ["exec", ..] => out(0, "", ""), ["stop", "--time", _, name] => { self.running.borrow_mut().remove(*name); self.exited.borrow_mut().insert(name.to_string(), (0, false)); @@ -732,6 +750,10 @@ mod tests { assert!(at("network create") < at("run --detach"), "database comes first"); let runs: Vec = sim.calls_of("run").iter().map(|c| c.args[c.args.iter().position(|x| x == "--name").unwrap() + 1].clone()).collect(); assert_eq!(runs, ["coa-t1-db", "coa-t1-world", "coa-t1-auth"], "database, then world, then auth"); + let exec = sim.calls_of("exec"); + assert_eq!(exec.len(), 1, "the realm is marked online once the auth server is up"); + assert!(exec[0].stdin && exec[0].env == [("MYSQL_PWD".to_string(), ROOT_PW.to_string())]); + assert!(!exec[0].args.iter().any(|a| a.contains(ROOT_PW)), "the password is not on the command line"); assert!(at("build") < verbs.iter().rposition(|v| v.starts_with("run")).unwrap(), "the runtime image exists before the game servers"); assert!(sim.calls_of("build")[0].stdin, "the Dockerfile travels on stdin"); } From 36bfc6d31e2b7e165e076e88f251a463318a6485 Mon Sep 17 00:00:00 2001 From: "Fabien F." <8136686+lostmind84@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:10:53 +0200 Subject: [PATCH 26/62] docs: changelog entry for Docker server parity --- changelog.d/20261005-linux-docker-server-settings.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog.d/20261005-linux-docker-server-settings.md diff --git a/changelog.d/20261005-linux-docker-server-settings.md b/changelog.d/20261005-linux-docker-server-settings.md new file mode 100644 index 0000000..fa6ba2b --- /dev/null +++ b/changelog.d/20261005-linux-docker-server-settings.md @@ -0,0 +1,7 @@ +--- +area: manager +type: added +audience: admins +title: Linux Docker servers follow Settings, sharing and backups (experimental) +--- +Experimental. Settings the Docker backend fixes are hidden, friends sharing and logs work, the game data folder can be changed, and the realm no longer shows offline after a start. From 4d8ca00e46ca05c444dc73a914dfe439ea6a7f1e Mon Sep 17 00:00:00 2001 From: "Fabien F." <8136686+lostmind84@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:31:30 +0200 Subject: [PATCH 27/62] test: a Docker config built by a test has no database data directory --- crates/coa-core/src/docker/lifecycle.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/coa-core/src/docker/lifecycle.rs b/crates/coa-core/src/docker/lifecycle.rs index 6fff3c7..1bd135c 100644 --- a/crates/coa-core/src/docker/lifecycle.rs +++ b/crates/coa-core/src/docker/lifecycle.rs @@ -412,7 +412,7 @@ fn database_env(s: &Secrets, world: bool, realm: crate::realms::Mode) -> Vec<(St /// Every setting variable (`AC_...`) the containers get, for the check that the screens know which settings are fixed. #[cfg(test)] pub(crate) fn forced_variables() -> Vec { - let cfg = Config { project: "t".into(), bind_address: "127.0.0.1".into(), mysql_image: "mysql:8.4".into(), data_dir: None }; + let cfg = Config { project: "t".into(), bind_address: "127.0.0.1".into(), mysql_image: "mysql:8.4".into(), data_dir: None, mysql_data: None }; let n = cfg.names(); let mut found = Vec::new(); for kind in [GameKind::World, GameKind::Auth] { From 7c866b116d4308fdc6390c559003b2661f3e6624 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 15:37:44 +0500 Subject: [PATCH 28/62] feat(client): install custom race patches in player mode --- changelog.d/20261011-player-race-patches.md | 7 +++++++ src-tauri/src/lib.rs | 10 +++++----- src/i18n/locales/de.ts | 1 + src/i18n/locales/en.ts | 1 + src/i18n/locales/es.ts | 1 + src/i18n/locales/fr.ts | 1 + src/i18n/locales/ru.ts | 1 + src/i18n/locales/zh.ts | 1 + src/screens/ClientCard.tsx | 9 +++++++++ 9 files changed, 27 insertions(+), 5 deletions(-) create mode 100644 changelog.d/20261011-player-race-patches.md diff --git a/changelog.d/20261011-player-race-patches.md b/changelog.d/20261011-player-race-patches.md new file mode 100644 index 0000000..15b050a --- /dev/null +++ b/changelog.d/20261011-player-race-patches.md @@ -0,0 +1,7 @@ +--- +area: manager +type: added +audience: players +title: Players can install custom race patches when connecting to a friend +--- +Install the custom race client patch directly from the Game client section without hosting a server. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7dc9d80..fdc9149 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -668,9 +668,9 @@ async fn module_set_enabled(state: State<'_, AppState>, id: String, module: Stri #[tauri::command] async fn custom_races_status(state: State<'_, AppState>, id: String) -> std::result::Result, UiError> { - let root = path_of(&state, &id)?; + let ctx = client_context(&state, &id)?; blocking(move || { - let client = linked_client(&root)?; + let client = ctx.linked(); Ok(client.as_deref().map(coa_core::custom_races::client_patch_status)) }) .await @@ -678,12 +678,12 @@ async fn custom_races_status(state: State<'_, AppState>, id: String) -> std::res #[tauri::command] async fn custom_races_install(app: AppHandle, state: State<'_, AppState>, id: String) -> std::result::Result<(), UiError> { - let root = path_of(&state, &id)?; + let ctx = client_context(&state, &id)?; let _guard = BusyGuard::acquire(&state, &id)?; let cancel = begin_client_job(&state)?; let sid = id.clone(); let result = blocking(move || { - let client = linked_client(&root)?.ok_or_else(|| Error::Invalid("No game client is linked to this server.".into()))?; + let client = ctx.require()?; coa_core::custom_races::install_client_patches(&client, &cancel, &|step, percent| { let _ = app.emit("custom-races-progress", serde_json::json!({ "id": sid, @@ -691,7 +691,7 @@ async fn custom_races_install(app: AppHandle, state: State<'_, AppState>, id: St "percent": percent, })); })?; - let enabled = coa_core::modules::list(&root).iter().any(|module| module.id == "custom-races" && module.enabled); + let enabled = ctx.root.as_ref().map_or(true, |root| coa_core::modules::list(root).iter().any(|module| module.id == "custom-races" && module.enabled)); coa_core::custom_races::set_client_patch_enabled(&client, enabled) }) .await; diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 8371e8b..cff440f 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -195,6 +195,7 @@ const de: Partial> = { "comp.saved": "Gespeichert. {n} Begleiter werden beim Serverstart erstellt.", // Client + "client.races.install": "Patch für benutzerdefinierte Völker installieren", "client.title": "Spielclient", "client.looking": "Suche …", "client.intro": "Sag dem Manager, wo dein Spiel installiert ist, um einen SPIELEN-Button zu erhalten. Deine Spieldateien werden nie geändert, außer der Serveradresse und dem Begleiter-Addon.", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 6d42718..96dae69 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -238,6 +238,7 @@ const en = { "comp.saved": "Saved. {n} companions will be created when the server starts.", // Client + "client.races.install": "Install custom race patch", "client.title": "Game client", "client.looking": "Looking…", "client.intro": "Tell Manager where your game is installed to get a PLAY button. Your game files are never changed except the server address and the companion addon.", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 33441b3..00ce747 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -195,6 +195,7 @@ const es: Partial> = { "comp.saved": "Guardado. Se crearán {n} compañeros cuando se inicie el servidor.", // Client + "client.races.install": "Instalar parche de razas personalizadas", "client.title": "Cliente del juego", "client.looking": "Buscando…", "client.intro": "Indica al Manager dónde está instalado el juego para obtener un botón JUGAR. Tus archivos del juego nunca se modifican, salvo la dirección del servidor y el addon de compañeros.", diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 4174370..6ba40c9 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -195,6 +195,7 @@ const fr: Partial> = { "comp.saved": "Enregistré. {n} compagnons seront créés au démarrage du serveur.", // Client + "client.races.install": "Installer le patch des races personnalisées", "client.title": "Client du jeu", "client.looking": "Recherche…", "client.intro": "Indiquez au Manager où le jeu est installé pour obtenir un bouton JOUER. Vos fichiers de jeu ne sont jamais modifiés, sauf l’adresse du serveur et l’addon des compagnons.", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index e86b34e..5bccd27 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -228,6 +228,7 @@ const ru: Partial> = { "comp.saved": "Сохранено. Компаньоны ({n}) будут созданы при запуске сервера.", // Client + "client.races.install": "Установить патч кастомных рас", "client.title": "Клиент игры", "client.looking": "Ищу…", "client.intro": "Укажите, где установлена игра, чтобы появилась кнопка «Играть». Файлы игры не изменяются, кроме адреса сервера и аддона компаньонов.", diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index a9e6fb3..466b3e0 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -207,6 +207,7 @@ const zh = { "comp.saved": "已保存。{n} 名随从将在服务器启动时创建。", // Client + "client.races.install": "安装自定义种族补丁", "client.title": "游戏客户端", "client.looking": "正在寻找…", "client.intro": "指定游戏客户端安装路径即可启用“开始游戏”按钮。除服务器地址和随从插件外,你的游戏文件绝不会被修改。", diff --git a/src/screens/ClientCard.tsx b/src/screens/ClientCard.tsx index 4103e6b..7d5baaa 100644 --- a/src/screens/ClientCard.tsx +++ b/src/screens/ClientCard.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; import { open } from "@tauri-apps/plugin-dialog"; import { api, asUiError, type ClientInfo, type ClientStatus, type UiError } from "@/lib/api"; import { checkClient } from "@/lib/clientUpdate"; @@ -16,6 +17,7 @@ export function ClientCard({ serverId, remote = false }: { serverId: string; rem const [note, setNote] = useState(null); const [busy, setBusy] = useState(false); const [status, setStatus] = useState(null); + const [patchProgress, setPatchProgress] = useState(null); const [dialog, setDialog] = useState(null); const [playAfter, setPlayAfter] = useStartAfterUpdate("client", serverId); @@ -25,6 +27,10 @@ export function ClientCard({ serverId, remote = false }: { serverId: string; rem }; useEffect(() => { void refresh(); + const unlisten = listen<{ id: string; step: string; percent: number }>("custom-races-progress", e => { + if (e.payload.id === serverId) setPatchProgress(`${e.payload.step} (${Math.round(e.payload.percent * 100)}%)`); + }); + return () => { void unlisten.then(f => f()); }; }, [serverId]); // eslint-disable-line react-hooks/exhaustive-deps async function run(fn: () => Promise) { @@ -40,6 +46,7 @@ export function ClientCard({ serverId, remote = false }: { serverId: string; rem setError(asUiError(e)); } finally { setBusy(false); + setPatchProgress(null); } } @@ -111,11 +118,13 @@ export function ClientCard({ serverId, remote = false }: { serverId: string; rem {status && !status.managed && ( )} + {remote && }
)} + {patchProgress &&

{patchProgress}

} {note &&

{note}

} {error &&

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

} {dialog && ( From 51ef88101e21773ab1735eea7a8184419e314de8 Mon Sep 17 00:00:00 2001 From: LarsSeverson Date: Sun, 11 Oct 2026 11:35:24 -0500 Subject: [PATCH 29/62] fix(friends): preserve local realm address --- changelog.d/20261010-local-host-while-sharing.md | 7 +++++++ crates/coa-core/src/friends.rs | 5 +++-- 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 changelog.d/20261010-local-host-while-sharing.md diff --git a/changelog.d/20261010-local-host-while-sharing.md b/changelog.d/20261010-local-host-while-sharing.md new file mode 100644 index 0000000..80ad6f9 --- /dev/null +++ b/changelog.d/20261010-local-host-while-sharing.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Local hosts can connect while sharing a server online +--- +The Manager no longer replaces the realm's local address with its public address when Friends mode is enabled. diff --git a/crates/coa-core/src/friends.rs b/crates/coa-core/src/friends.rs index 9b8a71a..afc8277 100644 --- a/crates/coa-core/src/friends.rs +++ b/crates/coa-core/src/friends.rs @@ -125,7 +125,8 @@ pub fn set_open(root: &Path, meta: &Path, open: bool) -> Result { Ok(true) } -/// Make the realm list advertise `host` (the launcher resets it to 127.0.0.1 at every start, so this runs after each start). +/// Make the realm list advertise `host` to remote clients (the launcher resets it to 127.0.0.1 at every start, so this +/// runs after each start). pub fn apply_realm_address(root: &Path, host: &str) -> Result<()> { if !host_ok(host) { return Err(Error::Invalid("That address is not valid.".into())); @@ -133,7 +134,7 @@ pub fn apply_realm_address(root: &Path, host: &str) -> Result<()> { let db = Db::from_repack(root, Account::Admin)?; let realm = crate::realms::state(root)?.active.realm_id(); let where_realms = if crate::realms::state(root)?.simultaneous { "id IN (1,2)".into() } else { format!("id={realm}") }; - db.query(&format!("UPDATE acore_auth.realmlist SET address='{host}', localAddress='{host}' WHERE {where_realms};"))?; + db.query(&format!("UPDATE acore_auth.realmlist SET address='{host}' WHERE {where_realms};"))?; Ok(()) } From 10daa9b74dd1222d46300f1916c36e507f489a10 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 21:49:44 +0500 Subject: [PATCH 30/62] Resolve startup fixture roots before copying candidate files --- crates/coa-core/src/release_schema.rs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/coa-core/src/release_schema.rs b/crates/coa-core/src/release_schema.rs index c79f57c..7fc7f5f 100644 --- a/crates/coa-core/src/release_schema.rs +++ b/crates/coa-core/src/release_schema.rs @@ -157,6 +157,10 @@ pub fn validate_startup(fixture: &Path, tree: &Path, base: &Path, trusted_key: & || 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 fixture_root = fsx::canonicalize_lenient(fixture)?; + let fixture = fixture_root.as_path(); + let tree_root = fsx::canonicalize_lenient(tree)?; + let tree = tree_root.as_path(); 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)?; @@ -190,7 +194,11 @@ pub fn validate_startup(fixture: &Path, tree: &Path, base: &Path, trusted_key: & 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))?)?; + fsx::atomic_write(&target, &std::fs::read(tree.join(&relative))?)?; + let expected = fsx::sha256_file(&tree.join(relative))?; + if fsx::sha256_file(&target)? != expected { + return Err(Error::Invalid("Startup candidate copy failed verification.".into())); + } } let meta = crate::registry::InstallMeta::new(crate::registry::InstallKind::New, fixture); crate::registry::MetaDir::create(fixture, &meta)?; @@ -233,6 +241,18 @@ mod tests { use base64::{engine::general_purpose::STANDARD, Engine}; use ed25519_dalek::{Signer, SigningKey}; + #[test] + fn relative_startup_fixture_targets_replace_the_original_location() { + let directory = tempfile::Builder::new().prefix("coa-schema-fixture-path-test-").tempdir_in(".").unwrap(); + let relative = directory.path(); + fsx::atomic_write(&relative.join("Core/worldserver.exe"), b"old").unwrap(); + let root = fsx::canonicalize_lenient(relative).unwrap(); + let target = fsx::ensure_within(&root, &root.join("Core/worldserver.exe")).unwrap(); + fsx::atomic_write(&target, b"candidate").unwrap(); + assert_eq!(std::fs::read(relative.join("Core/worldserver.exe")).unwrap(), b"candidate"); + assert!(!root.join(relative).join("Core/worldserver.exe").exists()); + } + #[test] fn startup_qualification_refuses_real_server_folders_before_writes() { let directory = tempfile::tempdir().unwrap(); From 7e170744bf40490e8f5427fd212f52f763e98296 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 21:50:09 +0500 Subject: [PATCH 31/62] Exercise an explicitly relative fixture path in regression --- crates/coa-core/src/release_schema.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/coa-core/src/release_schema.rs b/crates/coa-core/src/release_schema.rs index 7fc7f5f..e7aa151 100644 --- a/crates/coa-core/src/release_schema.rs +++ b/crates/coa-core/src/release_schema.rs @@ -244,7 +244,8 @@ mod tests { #[test] fn relative_startup_fixture_targets_replace_the_original_location() { let directory = tempfile::Builder::new().prefix("coa-schema-fixture-path-test-").tempdir_in(".").unwrap(); - let relative = directory.path(); + let current = std::env::current_dir().unwrap(); + let relative = directory.path().strip_prefix(¤t).unwrap(); fsx::atomic_write(&relative.join("Core/worldserver.exe"), b"old").unwrap(); let root = fsx::canonicalize_lenient(relative).unwrap(); let target = fsx::ensure_within(&root, &root.join("Core/worldserver.exe")).unwrap(); From 247159e56c2e6eb854a3a0c99d57bb5be0239cf7 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 22:03:09 +0500 Subject: [PATCH 32/62] Normalize all upgrade qualification paths before update transactions --- crates/coa-core/src/release_schema.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/coa-core/src/release_schema.rs b/crates/coa-core/src/release_schema.rs index e7aa151..28f1994 100644 --- a/crates/coa-core/src/release_schema.rs +++ b/crates/coa-core/src/release_schema.rs @@ -29,6 +29,14 @@ pub fn qualify_upgrade(base: &Path, source: &Path, candidate: &Path, fixture: &P 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 fixture_root = std::path::absolute(fixture)?; + let fixture = fixture_root.as_path(); + let base_root = fsx::canonicalize_lenient(base)?; + let base = base_root.as_path(); + let source_root = fsx::canonicalize_lenient(source)?; + let source = source_root.as_path(); + let candidate_root = fsx::canonicalize_lenient(candidate)?; + let candidate = candidate_root.as_path(); 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)?; From c45f962d02c01a4e1b824433cca8028839a7d23b 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: Mon, 12 Oct 2026 01:03:35 +0500 Subject: [PATCH 33/62] Normalize rehearsal roots to avoid prefix errors on Windows --- .../20261012-rehearsal-path-normalization.md | 7 +++++++ crates/coa-core/src/mysql_snapshot.rs | 15 +++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 changelog.d/20261012-rehearsal-path-normalization.md diff --git a/changelog.d/20261012-rehearsal-path-normalization.md b/changelog.d/20261012-rehearsal-path-normalization.md new file mode 100644 index 0000000..e558f86 --- /dev/null +++ b/changelog.d/20261012-rehearsal-path-normalization.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Normalize rehearsal server roots to prevent Windows path prefix errors +--- +Server update rehearsals now canonicalize the installation root before collecting files, resolving a "prefix not found" error during rehearsal preparation on Windows. diff --git a/crates/coa-core/src/mysql_snapshot.rs b/crates/coa-core/src/mysql_snapshot.rs index 9d445ee..ea14db9 100644 --- a/crates/coa-core/src/mysql_snapshot.rs +++ b/crates/coa-core/src/mysql_snapshot.rs @@ -228,6 +228,8 @@ pub(crate) fn verify(point: &Path, snapshot: &Snapshot) -> Result<()> { pub(crate) fn rehearsal_copy(root: &Path, meta: &Path, point: &Path, snapshot: &Snapshot) -> Result { verify(point, snapshot)?; + let canonical = fsx::canonicalize_lenient(root)?; + let root = canonical.as_path(); 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()]; @@ -475,4 +477,17 @@ mod tests { fsx::atomic_write(&root.join("mysql/data/extra.ibd"), b"new data").unwrap(); assert!(unchanged(&root, &snapshot).is_err()); } + + #[test] + fn rehearsal_copy_succeeds_with_nested_directories_and_unnormalized_root() { + let (_temp, root, meta, point, snapshot) = fixture(); + let meta_obj = crate::registry::InstallMeta::new(crate::registry::InstallKind::New, &root); + fsx::atomic_write_json(&meta.join("install.json"), &meta_obj).unwrap(); + fsx::atomic_write(&root.join("Core/worldserver.exe"), b"exe").unwrap(); + fsx::atomic_write(&root.join("Core/configs/worldserver.conf"), b"conf").unwrap(); + fsx::atomic_write(&root.join("Settings/repack.json"), b"{}").unwrap(); + let dest = rehearsal_copy(&root, &meta, &point, &snapshot).unwrap(); + assert_eq!(fs::read(dest.join("Core/configs/worldserver.conf")).unwrap(), b"conf"); + assert_eq!(fs::read(dest.join("mysql/data/characters.ibd")).unwrap(), b"saved"); + } } From 58c976a388215bea8bbf232503810c6519a33562 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: Mon, 12 Oct 2026 02:03:36 +0500 Subject: [PATCH 34/62] Prepare Manager version 0.6.14 --- Cargo.lock | 6 +++--- Cargo.toml | 2 +- package-lock.json | 4 ++-- package.json | 2 +- src-tauri/tauri.conf.json | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 85b38d8..9d8bc02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -491,7 +491,7 @@ dependencies = [ [[package]] name = "coa-core" -version = "0.6.13" +version = "0.6.14" dependencies = [ "base64 0.22.1", "chrono", @@ -520,7 +520,7 @@ dependencies = [ [[package]] name = "coa-release" -version = "0.6.13" +version = "0.6.14" dependencies = [ "chrono", "coa-core", @@ -529,7 +529,7 @@ dependencies = [ [[package]] name = "coa-server-manager" -version = "0.6.13" +version = "0.6.14" dependencies = [ "coa-core", "serde", diff --git a/Cargo.toml b/Cargo.toml index c012fd0..6e09a6c 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.13" +version = "0.6.14" edition = "2021" license = "AGPL-3.0-only" diff --git a/package-lock.json b/package-lock.json index 449c0ae..5641045 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coa-server-manager", - "version": "0.6.13", + "version": "0.6.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coa-server-manager", - "version": "0.6.13", + "version": "0.6.14", "dependencies": { "@tauri-apps/api": "^2.12.0", "@tauri-apps/plugin-dialog": "^2.8.0", diff --git a/package.json b/package.json index 0265fc9..081128b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coa-server-manager", "private": true, - "version": "0.6.13", + "version": "0.6.14", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 43df25d..c757ea7 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.13", + "version": "0.6.14", "identifier": "com.coa.servermanager", "build": { "beforeDevCommand": "npm run dev", From 53a05820c82198058068045661f6b51bc5ce1cbf 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: Mon, 12 Oct 2026 03:47:24 +0500 Subject: [PATCH 35/62] Prepare recovery once for bulk updates and release Manager 0.6.15 --- Cargo.lock | 6 +- Cargo.toml | 2 +- .../20261012-bulk-navigation-update.md | 7 ++ crates/coa-core/src/update.rs | 74 +++++++++++++++---- package-lock.json | 4 +- package.json | 2 +- src-tauri/tauri.conf.json | 2 +- 7 files changed, 76 insertions(+), 21 deletions(-) create mode 100644 changelog.d/20261012-bulk-navigation-update.md diff --git a/Cargo.lock b/Cargo.lock index 9d8bc02..a464eb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -491,7 +491,7 @@ dependencies = [ [[package]] name = "coa-core" -version = "0.6.14" +version = "0.6.15" dependencies = [ "base64 0.22.1", "chrono", @@ -520,7 +520,7 @@ dependencies = [ [[package]] name = "coa-release" -version = "0.6.14" +version = "0.6.15" dependencies = [ "chrono", "coa-core", @@ -529,7 +529,7 @@ dependencies = [ [[package]] name = "coa-server-manager" -version = "0.6.14" +version = "0.6.15" dependencies = [ "coa-core", "serde", diff --git a/Cargo.toml b/Cargo.toml index 6e09a6c..a31fa76 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.14" +version = "0.6.15" edition = "2021" license = "AGPL-3.0-only" diff --git a/changelog.d/20261012-bulk-navigation-update.md b/changelog.d/20261012-bulk-navigation-update.md new file mode 100644 index 0000000..3b8cf4b --- /dev/null +++ b/changelog.d/20261012-bulk-navigation-update.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Large server updates no longer repeatedly rewrite the recovery journal +--- +Updates containing rebuilt navigation data now prepare all recovery copies before replacing files. Interrupted updates remain recoverable without rewriting the full journal for every file. \ No newline at end of file diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index b91bf0d..677546e 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -687,6 +687,25 @@ fn restore_transaction(root: &Path, meta: &Path, before: &Path, txn: &mut Txn, e } fn apply_ops(root: &Path, tree: &Path, before: &Path, files: &[FileEntry], txn: &mut Txn, meta_dir: &Path, fail_after: Option) -> Result<()> { + if fail_after == Some(0) && !txn.ops.is_empty() { + return Err(Error::Invalid("simulated failure".into())); + } + let entries: BTreeMap<_, _> = files.iter().map(|entry| (entry.path.as_str(), entry)).collect(); + // Prepare every recovery copy before changing any destination. One durable journal + // can then cover the entire file phase: recovery restores old files even if their + // replacement was not reached, and removes newly created files only by exact hash. + // Rewriting the full journal twice per file makes large navigation updates quadratic. + for op in &txn.ops { + let target = fsx::ensure_within(root, &fsx::safe_join(root, &op.path)?)?; + if op.had_previous { + let keep = fsx::safe_join(before, &op.path)?; + fs::create_dir_all(keep.parent().unwrap())?; + fsx::atomic_write(&keep, &fs::read(&target)?)?; + txn.recovery_hashes.insert(op.path.clone(), fsx::sha256_file(&keep)?); + } + } + for op in &mut txn.ops { op.started = true; } + save(meta_dir, txn)?; let mut completed = 0usize; for i in 0..txn.ops.len() { if fail_after == Some(completed) { @@ -695,17 +714,7 @@ fn apply_ops(root: &Path, tree: &Path, before: &Path, files: &[FileEntry], txn: let (path, action) = (txn.ops[i].path.clone(), txn.ops[i].action); let target = fsx::ensure_within(root, &fsx::safe_join(root, &path)?)?; let staged = fsx::safe_join(tree, &path)?; - let entry = files.iter().find(|f| f.path == path).expect("listed"); - if target.is_file() { - let keep = fsx::safe_join(before, &path)?; - fs::create_dir_all(keep.parent().unwrap())?; - fsx::atomic_write(&keep, &fs::read(&target)?)?; - txn.recovery_hashes.insert(path.clone(), fsx::sha256_file(&keep)?); - } - // Journal only after the old bytes are safely saved; a crash during backup must not - // turn an untouched file into a rollback operation with a missing saved copy. - txn.ops[i].started = true; - save(meta_dir, txn)?; + let entry = entries.get(path.as_str()).expect("listed"); fs::create_dir_all(target.parent().unwrap())?; match action { Action::Create | Action::Replace => { @@ -730,10 +739,9 @@ fn apply_ops(root: &Path, tree: &Path, before: &Path, files: &[FileEntry], txn: Action::Skip | Action::Conflict => {} } txn.ops[i].done = true; - save(meta_dir, txn)?; completed += 1; } - Ok(()) + save(meta_dir, txn) } /// Put every touched file back the way it was. Files created by the update (no previous version) are removed only if @@ -1544,6 +1552,46 @@ mod tests { assert_eq!(fs::read(saved).unwrap(), b"my-custom-build", "the replaced custom build is kept for rollback"); } + #[test] + fn power_loss_during_bulk_files_restores_written_and_unwritten_files() { + let d = tempfile::tempdir().unwrap(); + let root = d.path().join("root"); + let meta = d.path().join("meta"); + let tree = d.path().join("tree"); + let before = meta.join("updates/bulk/before"); + let mut files = Vec::new(); + let mut ops = Vec::new(); + for (path, previous) in [("Data/maps/first.map", true), ("Data/maps/new.map", false), ("Data/maps/last.map", true)] { + if previous { write(&root, path, b"old"); } + write(&tree, path, b"new"); + let hash = fsx::sha256_file(&tree.join(path)).unwrap(); + files.push(FileEntry { path: path.into(), sha256: hash.clone(), size: 3, policy: ReplacePolicy::Replace, owner: crate::manifest::Owner::Core }); + ops.push(Op { path: path.into(), action: if previous { Action::Replace } else { Action::Create }, reason: None, + new_sha256: hash, had_previous: previous, started: false, done: false }); + } + let mut txn = Txn { id: "bulk".into(), state: State::Applying, from_version: Some("1".into()), to_version: "2".into(), + recovery_point: None, databases_started: false, ops, message: None, recovery_hashes: BTreeMap::new() }; + fs::create_dir_all(before.parent().unwrap()).unwrap(); + assert!(apply_ops(&root, &tree, &before, &files, &mut txn, &meta, Some(2)).is_err()); + // A power loss sees the pre-write checkpoint, not the in-memory completed flags. + let mut durable = load(&meta, "bulk").unwrap(); + assert!(durable.ops.iter().all(|op| op.started && !op.done)); + assert_eq!(fs::read(root.join("Data/maps/last.map")).unwrap(), b"old"); + restore_files(&root, &before, &mut durable).unwrap(); + assert_eq!(fs::read(root.join("Data/maps/first.map")).unwrap(), b"old"); + assert_eq!(fs::read(root.join("Data/maps/last.map")).unwrap(), b"old"); + assert!(!root.join("Data/maps/new.map").exists()); + // If preparing any recovery copy fails, no destination may be changed. + for op in &mut durable.ops { op.started = false; op.done = false; } + durable.recovery_hashes.clear(); + save(&meta, &durable).unwrap(); + fs::remove_file(root.join("Data/maps/last.map")).unwrap(); + assert!(apply_ops(&root, &tree, &before, &files, &mut durable, &meta, None).is_err()); + assert_eq!(fs::read(root.join("Data/maps/first.map")).unwrap(), b"old"); + assert!(!root.join("Data/maps/new.map").exists()); + assert!(load(&meta, "bulk").unwrap().ops.iter().all(|op| !op.started)); + } + #[test] fn failure_in_the_middle_of_applying_restores_every_file_byte_for_byte() { for fail_after in [0usize, 1, 2] { diff --git a/package-lock.json b/package-lock.json index 5641045..b6f48b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coa-server-manager", - "version": "0.6.14", + "version": "0.6.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coa-server-manager", - "version": "0.6.14", + "version": "0.6.15", "dependencies": { "@tauri-apps/api": "^2.12.0", "@tauri-apps/plugin-dialog": "^2.8.0", diff --git a/package.json b/package.json index 081128b..6402cd0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coa-server-manager", "private": true, - "version": "0.6.14", + "version": "0.6.15", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c757ea7..52de8b2 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.14", + "version": "0.6.15", "identifier": "com.coa.servermanager", "build": { "beforeDevCommand": "npm run dev", From 106a532092e4d0db55b1619023f5de24d5ab1b92 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: Tue, 13 Oct 2026 05:59:38 +0500 Subject: [PATCH 36/62] fix(updates): accept rechecking the installed target release --- changelog.d/20261013-recheck-installed-release.md | 7 +++++++ crates/coa-core/src/update.rs | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 changelog.d/20261013-recheck-installed-release.md diff --git a/changelog.d/20261013-recheck-installed-release.md b/changelog.d/20261013-recheck-installed-release.md new file mode 100644 index 0000000..c77c8d7 --- /dev/null +++ b/changelog.d/20261013-recheck-installed-release.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Checking an already installed server update no longer reports incompatibility +--- +The installed release can be checked again even when its compatibility list only names earlier releases. Platform checks and protection against downgrades still apply. diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index 677546e..cfb77cc 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -373,7 +373,7 @@ fn reject_downgrade(meta: &InstallMeta, manifest: &Manifest) -> Result<()> { 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)) { + if meta.core.version.as_ref().is_none_or(|version| version != &manifest.version && !c.source_versions.contains(version)) { return Err(Error::Invalid("This server version is outside the release's tested compatibility matrix.".into())); } } @@ -1656,9 +1656,13 @@ mod tests { 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")); + let mut already_updated = meta.clone(); + already_updated.core.version = Some(manifest.version.clone()); + validate_candidate(&already_updated, &manifest).unwrap(); 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!(validate_candidate(&already_updated, &manifest).unwrap_err().to_string().contains("targets")); assert_eq!(read(&w, "Core/worldserver.exe"), b"world-v1"); assert!(unfinished(&w.meta).is_none()); } From acd04fae2f9d04600bcf2337fbcb492846c7103a 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: Tue, 13 Oct 2026 06:10:56 +0500 Subject: [PATCH 37/62] fix(diagnostics): include service errors after launcher readiness --- changelog.d/20261013-startup-error-details.md | 7 +++++ crates/coa-core/src/driver.rs | 27 +++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 changelog.d/20261013-startup-error-details.md diff --git a/changelog.d/20261013-startup-error-details.md b/changelog.d/20261013-startup-error-details.md new file mode 100644 index 0000000..7324d0c --- /dev/null +++ b/changelog.d/20261013-startup-error-details.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Failed server startup shows service errors even when the launcher reported ready +--- +Update rehearsal and startup failures include recent world, authentication and database error lines. Missing tables and late startup failures are visible directly in the error details, with secrets redacted. diff --git a/crates/coa-core/src/driver.rs b/crates/coa-core/src/driver.rs index 954f4ef..b1c0aab 100644 --- a/crates/coa-core/src/driver.rs +++ b/crates/coa-core/src/driver.rs @@ -279,8 +279,19 @@ pub(crate) fn startup_failure(root: &Path, started: &DriverOutcome) -> String { let title = code.map(|c| c.human().title).unwrap_or("The server did not become ready"); let output = crate::diag::redact(&started.output); let lines: Vec<_> = output.lines().rev().take(12).collect(); - if lines.is_empty() { title.into() } - else { format!("{title}\n{}", lines.into_iter().rev().collect::>().join("\n")) } + let mut reason = if lines.is_empty() { title.into() } + else { format!("{title}\n{}", lines.into_iter().rev().collect::>().join("\n")) }; + for relative in ["Core/Logs/world-console.log", "Core/Logs/auth-console.log", "mysql/logs/mysql-error.log"] { + let tail = crate::health::tail(&root.join(relative), 64 * 1024); + let failures: Vec<_> = tail.lines().filter(|line| { + let lower = line.to_ascii_lowercase(); + crate::health::diagnose(line).is_some() || lower.contains("fatal") || lower.contains("error") || lower.contains("aborting") + }).rev().take(8).collect(); + if !failures.is_empty() { + reason.push_str(&format!("\n{relative}:\n{}", crate::diag::redact(&failures.into_iter().rev().collect::>().join("\n")))); + } + } + reason } #[cfg(test)] @@ -381,6 +392,18 @@ mod tests { assert!(run(dir.path(), Verb::StartAll).is_err()); } + #[test] + fn startup_failure_reports_late_world_errors_after_launcher_ready() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("Core/Logs")).unwrap(); + std::fs::write(dir.path().join("Core/Logs/world-console.log"), "ERROR [1146] Table 'acore_characters.playerbots_names' doesn't exist\nFATAL Could not prepare statements\n").unwrap(); + let started = DriverOutcome { ok: true, code: None, exit_code: Some(0), human: None, output: "Worldserver and automatic bug reporting are ready.".into() }; + let reason = startup_failure(dir.path(), &started); + assert!(reason.contains("playerbots_names")); + assert!(reason.contains("Core/Logs/world-console.log")); + assert!(reason.contains("Could not prepare statements")); + } + #[test] fn startup_failure_keeps_the_launcher_reason_without_secrets() { let dir = tempfile::tempdir().unwrap(); From 7d7bc530ec5a62f6e612121babc68e1f20ed293b 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: Tue, 13 Oct 2026 06:14:32 +0500 Subject: [PATCH 38/62] fix(client): preserve originals and verify race patch lifecycle --- .../20261013-preserve-client-patches.md | 7 + crates/coa-core/src/custom_races.rs | 491 +++++++++++++----- crates/coa-core/src/custom_races_hashes.json | 50 ++ src-tauri/src/lib.rs | 8 + src/lib/api.ts | 1 + src/screens/ModulesPage.tsx | 18 +- 6 files changed, 445 insertions(+), 130 deletions(-) create mode 100644 changelog.d/20261013-preserve-client-patches.md create mode 100644 crates/coa-core/src/custom_races_hashes.json diff --git a/changelog.d/20261013-preserve-client-patches.md b/changelog.d/20261013-preserve-client-patches.md new file mode 100644 index 0000000..6190ff6 --- /dev/null +++ b/changelog.d/20261013-preserve-client-patches.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Client race patches can be repaired and removed while preserving original files +--- +Every overwritten client file gets an original backup. Switching preserves newer launcher files; checksums detect missing or replaced assets. Repair and removal are always available, with rollback if a file operation fails. diff --git a/crates/coa-core/src/custom_races.rs b/crates/coa-core/src/custom_races.rs index 915c5f3..7710523 100644 --- a/crates/coa-core/src/custom_races.rs +++ b/crates/coa-core/src/custom_races.rs @@ -152,101 +152,209 @@ pub struct ClientPatchStatus { pub client_path: Option, } -/// Inspect the client folder and determine the state of custom races patches. -pub fn client_patch_status(client_path: &Path) -> ClientPatchStatus { - let data_dir = client_path.join("Data"); - let mut enabled_count = 0; - let mut disabled_count = 0; - - for mpq in RACE_MPQS { - if data_dir.join(format!("{mpq}.disabled")).is_file() { - disabled_count += 1; - } else if data_dir.join(mpq).is_file() { - enabled_count += 1; - } - } - - let has_root = client_path.join("EsteriaAppearance.dll").is_file() - || client_path.join("dinput8.dll").is_file() - || client_path.join("dinput8.dll.disabled").is_file(); - - let total = RACE_MPQS.len(); - let current_version = fs::read_to_string(client_path.join(MANIFEST_FILENAME)).ok() - .is_some_and(|manifest| manifest.lines().any(|line| line == format!("# CoA Custom {CLIENT_VERSION}"))); - let state = if enabled_count == total && current_version && client_path.join("dinput8.dll").is_file() { - ClientPatchState::Enabled - } else if disabled_count == total && current_version && client_path.join("dinput8.dll.disabled").is_file() { - ClientPatchState::Disabled - } else if (enabled_count + disabled_count) > 0 { - ClientPatchState::PartiallyInstalled - } else { - ClientPatchState::NotInstalled - }; - - ClientPatchStatus { - state, - installed_mpqs: enabled_count + disabled_count, - total_mpqs: total, - has_root_files: has_root, - client_path: Some(client_path.to_string_lossy().into_owned()), - } +#[derive(Clone, Serialize, Deserialize)] +struct PatchFile { + path: String, + sha256: String, } -/// Enable or disable client patches by renaming MPQs and `dinput8.dll` with `.disabled`. -pub fn set_client_patch_enabled(client_path: &Path, enabled: bool) -> Result<()> { - if !client_path.is_dir() { - return Err(Error::Invalid("Game client directory not found.".into())); - } - - if crate::client::is_running(client_path) { - return Err(Error::Invalid("Close the game before changing custom races patches.".into())); - } - if !client_path.join(MANIFEST_FILENAME).is_file() - && !RACE_MPQS.iter().skip(1).any(|name| client_path.join("Data").join(name).is_file() - || client_path.join("Data").join(format!("{name}.disabled")).is_file()) { - return Ok(()); - } - let mut paths: Vec = RACE_MPQS.iter().map(|name| client_path.join("Data").join(name)).collect(); - if let Ok(manifest) = fs::read_to_string(client_path.join(MANIFEST_FILENAME)) { - for line in manifest.lines() { - if let Some(name) = line.strip_prefix("MPQ:") { - if name.contains(['/', '\\']) || !name.to_ascii_lowercase().ends_with(".mpq") { - return Err(Error::Invalid("Invalid custom races patch manifest.".into())); - } - paths.push(fsx::safe_join(&client_path.join("Data"), name)?); - } +fn patch_files(client: &Path) -> Result> { + let bundled: std::collections::BTreeMap = serde_json::from_str(include_str!("custom_races_hashes.json"))?; + let manifest = fs::read_to_string(client.join(MANIFEST_FILENAME)).unwrap_or_default(); + let mut files = std::collections::BTreeMap::new(); + for line in manifest.lines() { + let path = if let Some(name) = line.strip_prefix("MPQ:") { + if name.contains(['/', '\\']) { return Err(Error::Invalid("Invalid custom races patch manifest.".into())); } + Some(format!("Data/{name}")) + } else if let Some(name) = line.strip_prefix("ROOT:") { + if name.contains(['/', '\\']) { return Err(Error::Invalid("Invalid custom races patch manifest.".into())); } + Some(name.to_string()) + } else { None }; + if let Some(path) = path { + fsx::safe_join(client, &path)?; + files.insert(path.clone(), bundled.get(&path).cloned().unwrap_or_default()); } } - paths.sort(); - paths.dedup(); - paths.push(client_path.join("dinput8.dll")); - paths.push(client_path.join("Ascension.exe")); - for active in paths { - let disabled = active.with_extension(format!("{}.disabled", active.extension().unwrap_or_default().to_string_lossy())); - let original = active.with_extension(format!("{}.original", active.extension().unwrap_or_default().to_string_lossy())); - if enabled && disabled.is_file() { - if active.is_file() { - if !original.is_file() { - return Err(Error::Invalid(format!("Cannot replace existing {} without an original backup.", active.display()))); - } - fs::remove_file(&active)?; - } - fs::rename(&disabled, &active)?; - } else if !enabled && active.is_file() && !disabled.is_file() { - if active.file_name().is_some_and(|name| name == "Ascension.exe") && !original.is_file() { - continue; - } - fs::rename(&active, &disabled)?; - if original.is_file() { - fs::copy(&original, &active)?; - } + if files.is_empty() { files = bundled; } + for line in manifest.lines().filter_map(|line| line.strip_prefix("SHA256:")) { + let (hash, path) = line.split_once(':').ok_or_else(|| Error::Invalid("Invalid custom races checksum.".into()))?; + if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) || !files.contains_key(path) { + return Err(Error::Invalid("Invalid custom races checksum.".into())); + } + files.insert(path.into(), hash.into()); + } + Ok(files.into_iter().map(|(path, sha256)| PatchFile { path, sha256 }).collect()) +} + +fn suffix(path: &Path, extension: &str) -> PathBuf { + path.with_file_name(format!("{}.{extension}", path.file_name().unwrap_or_default().to_string_lossy())) +} + +fn matches_patch(path: &Path, file: &PatchFile) -> bool { + path.is_file() && fsx::sha256_file(path).is_ok_and(|hash| hash == file.sha256) +} + +/// Only complete, verified payloads are reported as enabled or disabled. +pub fn client_patch_status(client_path: &Path) -> ClientPatchStatus { + let data = client_path.join("Data"); + let installed_mpqs = RACE_MPQS.iter().filter(|name| data.join(name).is_file() || suffix(&data.join(name), "disabled").is_file()).count(); + let has_root_files = client_path.join("EsteriaAppearance.dll").is_file() || client_path.join("dinput8.dll").is_file() || client_path.join("dinput8.dll.disabled").is_file(); + let manifest = fs::read_to_string(client_path.join(MANIFEST_FILENAME)).unwrap_or_default(); + let current = manifest.lines().any(|line| line == format!("# CoA Custom {CLIENT_VERSION}")); + let state = if !manifest.is_empty() && current && !client_path.join(".coa-races-transaction").exists() { + match patch_files(client_path) { + Ok(files) if files.iter().all(|f| matches_patch(&client_path.join(&f.path), f) && !suffix(&client_path.join(&f.path), "disabled").exists()) => ClientPatchState::Enabled, + Ok(files) if files.iter().all(|f| matches_patch(&suffix(&client_path.join(&f.path), "disabled"), f)) => ClientPatchState::Disabled, + _ => ClientPatchState::PartiallyInstalled, + } + } else if !manifest.is_empty() || patch_files(client_path).is_ok_and(|files| files.iter().filter(|file| file.path.starts_with("Data/") && file.path != "Data/patch-T.MPQ").any(|file| matches_patch(&client_path.join(&file.path), file) || matches_patch(&suffix(&client_path.join(&file.path), "disabled"), file))) { + ClientPatchState::PartiallyInstalled + } else { ClientPatchState::NotInstalled }; + ClientPatchStatus { state, installed_mpqs, total_mpqs: RACE_MPQS.len(), has_root_files, + client_path: Some(client_path.to_string_lossy().into_owned()) } +} + +#[derive(Serialize, Deserialize)] +struct SavedFile { path: String, existed: bool } + +struct PatchTransaction<'a> { root: &'a Path, entries: Vec, finished: bool } +impl<'a> PatchTransaction<'a> { + fn new(root: &'a Path) -> Result { + recover_patch_transaction(root)?; + fs::create_dir(root.join(".coa-races-transaction"))?; + Ok(Self { root, entries: Vec::new(), finished: false }) + } + fn save(&mut self, path: &Path) -> Result<()> { + let relative = path.strip_prefix(self.root).map_err(|_| Error::Invalid("Patch path escaped the client directory.".into()))?.to_string_lossy().replace('\\', "/"); + fsx::safe_join(self.root, &relative)?; + if self.entries.iter().any(|entry| entry.path == relative) { return Ok(()); } + let index = self.entries.len(); + self.entries.push(SavedFile { path: relative, existed: path.is_file() }); + fsx::atomic_write_json(&self.root.join(".coa-races-transaction/journal.json"), &self.entries)?; + if path.is_file() { fs::rename(path, self.root.join(".coa-races-transaction").join(index.to_string()))?; } + Ok(()) + } + fn copy(&mut self, source: &Path, target: &Path) -> Result<()> { + self.save(target)?; + fs::copy(source, target)?; + Ok(()) + } + fn move_file(&mut self, source: &Path, target: &Path) -> Result<()> { + self.save(source)?; + let index = self.entries.iter().position(|entry| self.root.join(&entry.path) == source).unwrap(); + // Copy from the journal so rollback retains the previous bytes. + let saved = self.root.join(".coa-races-transaction").join(index.to_string()); + self.copy(&saved, target) + } + fn finish(mut self) -> Result<()> { + fsx::atomic_write(&self.root.join(".coa-races-transaction/committed"), b"committed")?; + self.finished = true; + fs::remove_dir_all(self.root.join(".coa-races-transaction"))?; + Ok(()) + } +} +impl Drop for PatchTransaction<'_> { + fn drop(&mut self) { if !self.finished { let _ = recover_patch_transaction(self.root); } } +} + +fn recover_patch_transaction(root: &Path) -> Result<()> { + let directory = root.join(".coa-races-transaction"); + if !directory.is_dir() { return Ok(()); } + if !directory.join("committed").exists() && directory.join("journal.json").exists() { + let entries: Vec = fsx::read_json(&directory.join("journal.json"))?; + for (index, entry) in entries.iter().enumerate().rev() { + let path = fsx::safe_join(root, &entry.path)?; + let saved = directory.join(index.to_string()); + if saved.is_file() { + if path.is_file() { fs::remove_file(&path)?; } + fs::rename(saved, path)?; + } else if !entry.existed && path.is_file() { fs::remove_file(path)?; } } } - - tracing::info!(enabled, "custom races client patches toggled"); + fs::remove_dir_all(directory)?; Ok(()) } +fn patch_lock(client: &Path) -> Result { + use fs4::fs_std::FileExt; + let lock = fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(client.join(".coa-races.lock"))?; + if !lock.try_lock_exclusive()? { return Err(Error::Invalid("Another custom races operation is using this client.".into())); } + Ok(lock) +} + +fn preserve_original(tx: &mut PatchTransaction<'_>, active: &Path) -> Result<()> { + let original = suffix(active, "original"); + if original.is_file() && fsx::sha256_file(active)? == fsx::sha256_file(&original)? { return Ok(()); } + if original.is_file() { + let previous = suffix(active, &format!("original.previous-{}", uuid::Uuid::new_v4().simple())); + tx.copy(&original, &previous)?; + } + tx.copy(active, &original) +} + +/// Preserve launcher replacements, and switch every file owned by the package. +pub fn set_client_patch_enabled(client: &Path, enabled: bool) -> Result<()> { + if !client.is_dir() { return Err(Error::Invalid("Game client directory not found.".into())); } + if crate::client::is_running(client) { return Err(Error::Invalid("Close the game before changing custom races patches.".into())); } + let _lock = patch_lock(client)?; + recover_patch_transaction(client)?; + if !client.join(MANIFEST_FILENAME).is_file() { return Ok(()); } + let files = patch_files(client)?; + if enabled { + for file in &files { + let active = client.join(&file.path); + if !matches_patch(&active, file) && !matches_patch(&suffix(&active, "disabled"), file) { + return Err(Error::Invalid(format!("Custom races file {} was replaced or is missing. Repair the client patch before enabling it.", file.path))); + } + } + } + let mut tx = PatchTransaction::new(client)?; + for file in files { + let active = client.join(&file.path); + let disabled = suffix(&active, "disabled"); + let original = suffix(&active, "original"); + if enabled { + if active.is_file() && !matches_patch(&active, &file) { preserve_original(&mut tx, &active)?; } + if matches_patch(&disabled, &file) { tx.move_file(&disabled, &active)?; } + } else if matches_patch(&active, &file) { + if disabled.is_file() && !matches_patch(&disabled, &file) { + tx.move_file(&disabled, &suffix(&disabled, &format!("replaced-{}", uuid::Uuid::new_v4().simple())))?; + } + tx.move_file(&active, &disabled)?; + if original.is_file() { tx.copy(&original, &active)?; } + } else if active.is_file() { + preserve_original(&mut tx, &active)?; + } + } + tx.finish() +} + +pub fn uninstall_client_patches(client: &Path) -> Result<()> { + if crate::client::is_running(client) { return Err(Error::Invalid("Close the game before removing custom races patches.".into())); } + let _lock = patch_lock(client)?; + recover_patch_transaction(client)?; + if !client.join(MANIFEST_FILENAME).is_file() { return Ok(()); } + let mut tx = PatchTransaction::new(client)?; + for file in patch_files(client)? { + let active = client.join(&file.path); + if matches_patch(&active, &file) { + tx.save(&active)?; + let original = suffix(&active, "original"); + if original.is_file() { tx.copy(&original, &active)?; } + } + let disabled = suffix(&active, "disabled"); + if active.is_file() && !matches_patch(&active, &file) && !suffix(&active, "original").is_file() { + tx.move_file(&active, &suffix(&active, &format!("replaced-{}", uuid::Uuid::new_v4().simple())))?; + } + if disabled.is_file() { + if !matches_patch(&disabled, &file) { tx.move_file(&disabled, &suffix(&disabled, &format!("replaced-{}", uuid::Uuid::new_v4().simple())))?; } + else { tx.save(&disabled)?; } + } + } + tx.save(&client.join(MANIFEST_FILENAME))?; + tx.finish() +} + fn verify_archive(path: &Path, filename: &str) -> Result<()> { let expected = match filename { "CoA-Custom-1.5-main-20261009-cb2a0ca4.zip" => "117f5d99add18191676be6648fb0f2e99019a06c18705c71454e371e7df2afd2", @@ -376,30 +484,12 @@ pub fn install_client_patches( if crate::client::is_running(client_path) { return Err(Error::Invalid("Close the game before installing custom races patches.".into())); } - let data_dir = client_path.join("Data"); + let _lock = patch_lock(client_path)?; + recover_patch_transaction(client_path)?; + let staging = tempfile::Builder::new().prefix(".coa-races-stage-").tempdir_in(client_path)?; + let data_dir = staging.path().join("Data"); fs::create_dir_all(&data_dir)?; - // 1. Back up original Ascension.exe and patch-T.MPQ if they exist and haven't been backed up yet - let exe = client_path.join("Ascension.exe"); - let exe_bak = client_path.join("Ascension.exe.original"); - if exe.is_file() && !exe_bak.is_file() { - report("Backing up original Ascension.exe...", 0.02); - fs::copy(&exe, &exe_bak)?; - } - - let t_mpq = data_dir.join("patch-T.MPQ"); - let t_mpq_bak = data_dir.join("patch-T.MPQ.original"); - if t_mpq.is_file() && !t_mpq_bak.is_file() { - report("Backing up original patch-T.MPQ...", 0.04); - fs::copy(&t_mpq, &t_mpq_bak)?; - } - - let dll = client_path.join("dinput8.dll"); - let dll_backup = client_path.join("dinput8.dll.original"); - if dll.is_file() && !dll_backup.is_file() { - fs::copy(&dll, &dll_backup)?; - } - // 2. Resolve/download archives (0.05 .. 0.50) let http = reqwest::blocking::Client::builder() .user_agent(concat!("CoA-Server-Manager/", env!("CARGO_PKG_VERSION"))) @@ -470,10 +560,10 @@ pub fn install_client_patches( if disabled.is_file() { fs::remove_file(disabled)?; } installed_mpqs.push(base); } else if base.eq_ignore_ascii_case("dinput8.dll") { - let dest = client_path.join(&base); + let dest = staging.path().join(&base); let mut out = File::create(&dest)?; io::copy(&mut zfile, &mut out)?; - let disabled = client_path.join(format!("{base}.disabled")); + let disabled = staging.path().join(format!("{base}.disabled")); if disabled.is_file() { fs::remove_file(disabled)?; } installed_roots.push(base); } @@ -484,10 +574,10 @@ pub fn install_client_patches( .unwrap_or_default(); if !base.is_empty() { - let dest = client_path.join(&base); + let dest = staging.path().join(&base); let mut out = File::create(&dest)?; io::copy(&mut zfile, &mut out)?; - let disabled = client_path.join(format!("{base}.disabled")); + let disabled = staging.path().join(format!("{base}.disabled")); if disabled.is_file() { fs::remove_file(disabled)?; } installed_roots.push(base); } @@ -516,7 +606,35 @@ pub fn install_client_patches( manifest_body.push_str(&format!("ROOT:{r}\n")); } + let bundled: std::collections::BTreeMap = serde_json::from_str(include_str!("custom_races_hashes.json"))?; + let mut payload = Vec::new(); + for path in installed_mpqs.iter().map(|name| format!("Data/{name}")).chain(installed_roots.iter().cloned()) { + let staged = fsx::safe_join(staging.path(), &path)?; + let hash = fsx::sha256_file(&staged)?; + if bundled.get(&path) != Some(&hash) { return Err(Error::Invalid(format!("Custom races file {path} failed verification."))); } + manifest_body.push_str(&format!("SHA256:{hash}:{path}\n")); + payload.push(PatchFile { path, sha256: hash }); + } + if cancel.is_set() { return Err(Error::Invalid("Operation cancelled.".into())); } + if payload.len() != bundled.len() { return Err(Error::Invalid("Custom races archives are missing required runtime files.".into())); } + let previous = patch_files(client_path)?; + let mut tx = PatchTransaction::new(client_path)?; + for file in payload { + let dest = fsx::safe_join(client_path, &file.path)?; + if dest.is_file() && !previous.iter().any(|old| old.path == file.path && matches_patch(&dest, old)) && !matches_patch(&dest, &file) { + preserve_original(&mut tx, &dest)?; + } + tx.copy(&staging.path().join(&file.path), &dest)?; + let disabled = suffix(&dest, "disabled"); + if disabled.is_file() { + if !previous.iter().any(|old| old.path == file.path && matches_patch(&disabled, old)) && !matches_patch(&disabled, &file) { + tx.move_file(&disabled, &suffix(&disabled, &format!("replaced-{}", uuid::Uuid::new_v4().simple())))?; + } else { tx.save(&disabled)?; } + } + } + tx.save(&manifest_path)?; fsx::atomic_write(&manifest_path, manifest_body.as_bytes())?; + tx.finish()?; report("Custom races client patches installed successfully!", 1.0); tracing::info!( mpqs = installed_mpqs.len(), @@ -531,23 +649,138 @@ pub fn install_client_patches( mod tests { use super::*; - #[test] - fn disabling_restores_original_client_files_and_enabling_restores_custom_files() { - let temp = tempfile::tempdir().unwrap(); - let data = temp.path().join("Data"); - fs::create_dir_all(&data).unwrap(); - for path in [data.join("patch-T.MPQ"), temp.path().join("Ascension.exe"), temp.path().join("dinput8.dll")] { - fs::write(&path, b"custom").unwrap(); - let backup = path.with_extension(format!("{}.original", path.extension().unwrap().to_string_lossy())); - fs::write(&backup, b"original").unwrap(); + fn record_files(root: &Path, names: &[&str]) { + let mut manifest = format!("# CoA Custom {CLIENT_VERSION}\n"); + for name in names { + let (kind, base) = name.strip_prefix("Data/").map_or(("ROOT", *name), |base| ("MPQ", base)); + manifest.push_str(&format!("{kind}:{base}\nSHA256:{}:{name}\n", fsx::sha256_file(&root.join(name)).unwrap())); } - fs::write(temp.path().join(MANIFEST_FILENAME), "MPQ:patch-T.MPQ\n").unwrap(); - set_client_patch_enabled(temp.path(), false).unwrap(); - assert_eq!(fs::read(data.join("patch-T.MPQ")).unwrap(), b"original"); - assert_eq!(fs::read(temp.path().join("Ascension.exe")).unwrap(), b"original"); - set_client_patch_enabled(temp.path(), true).unwrap(); - assert_eq!(fs::read(data.join("patch-T.MPQ")).unwrap(), b"custom"); - assert_eq!(fs::read(temp.path().join("Ascension.exe")).unwrap(), b"custom"); + fs::write(root.join(MANIFEST_FILENAME), manifest).unwrap(); + } + + #[test] + #[ignore = "Requires the three downloaded release archives"] + fn bundled_hashes_match_verified_release_archives() { + use sha2::{Digest, Sha256}; + let folder = PathBuf::from(std::env::var_os("COA_RACES_ARCHIVE_DIR").expect("COA_RACES_ARCHIVE_DIR")); + let expected: std::collections::BTreeMap = serde_json::from_str(include_str!("custom_races_hashes.json")).unwrap(); + let mut actual = std::collections::BTreeMap::new(); + for (name, _) in ARCHIVES { + let path = folder.join(name); + verify_archive(&path, name).unwrap(); + let mut archive = zip::ZipArchive::new(File::open(path).unwrap()).unwrap(); + for index in 0..archive.len() { + let mut entry = archive.by_index(index).unwrap(); + let name = entry.name().replace('\\', "/"); + if entry.is_dir() { continue; } + let relative = if let Some(name) = name.split("files/client_root/").nth(1) { name.to_string() } + else if let Some(name) = name.split("files/client/").nth(1) { if name == "dinput8.dll" { name.to_string() } else { format!("Data/{name}") } } + else { continue }; + let mut hash = Sha256::new(); + let mut buffer = vec![0; 1024 * 1024]; + loop { let read = entry.read(&mut buffer).unwrap(); if read == 0 { break; } hash.update(&buffer[..read]); } + actual.insert(relative, hex::encode(hash.finalize())); + } + } + assert_eq!(actual, expected); + } + + #[test] + fn another_patch_operation_cannot_enter_the_same_client() { + let temp = tempfile::tempdir().unwrap(); + let first = patch_lock(temp.path()).unwrap(); + assert!(patch_lock(temp.path()).is_err()); + drop(first); + patch_lock(temp.path()).unwrap(); + } + + #[test] + fn switching_preserves_launcher_updates_for_every_owned_file() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + fs::create_dir_all(root.join("Data")).unwrap(); + let names = ["Data/patch-T.MPQ", "Data/patch-ZE1.MPQ", "Ascension.exe", "dinput8.dll", "EsteriaAppearance.dll"]; + for name in names { fs::write(root.join(name), b"custom").unwrap(); fs::write(suffix(&root.join(name), "original"), b"old official").unwrap(); } + record_files(root, &names); + set_client_patch_enabled(root, false).unwrap(); + for name in names { assert_eq!(fs::read(root.join(name)).unwrap(), b"old official"); fs::write(root.join(name), b"new official").unwrap(); } + set_client_patch_enabled(root, true).unwrap(); + for name in names { assert_eq!(fs::read(root.join(name)).unwrap(), b"custom"); assert_eq!(fs::read(suffix(&root.join(name), "original")).unwrap(), b"new official"); } + // An official launcher can also replace a currently enabled file. + fs::write(root.join("Ascension.exe"), b"newest official").unwrap(); + assert_eq!(client_patch_status(root).state, ClientPatchState::PartiallyInstalled); + set_client_patch_enabled(root, false).unwrap(); + assert_eq!(fs::read(root.join("Ascension.exe")).unwrap(), b"newest official"); + assert!(set_client_patch_enabled(root, true).unwrap_err().to_string().contains("Repair")); + uninstall_client_patches(root).unwrap(); + assert_eq!(fs::read(root.join("Ascension.exe")).unwrap(), b"newest official"); + assert_eq!(client_patch_status(root).state, ClientPatchState::NotInstalled); + } + + #[test] + fn failed_and_interrupted_transactions_restore_previous_bytes() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let file = root.join("Ascension.exe"); + fs::write(&file, b"previous").unwrap(); + { + let mut tx = PatchTransaction::new(root).unwrap(); + tx.save(&file).unwrap(); + fs::write(&file, b"replacement").unwrap(); + tx.save(&root.join("new.dll")).unwrap(); + fs::write(root.join("new.dll"), b"new").unwrap(); + } + assert_eq!(fs::read(&file).unwrap(), b"previous"); + assert!(!root.join("new.dll").exists()); + let mut tx = PatchTransaction::new(root).unwrap(); + tx.save(&file).unwrap(); + fs::write(&file, b"interrupted").unwrap(); + std::mem::forget(tx); + recover_patch_transaction(root).unwrap(); + assert_eq!(fs::read(&file).unwrap(), b"previous"); + } + + #[test] + fn existing_backups_and_foreign_disabled_files_are_retained() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let file = root.join("dinput8.dll"); + fs::write(&file, b"custom").unwrap(); + record_files(root, &["dinput8.dll"]); + fs::write(suffix(&file, "disabled"), b"third party").unwrap(); + fs::write(suffix(&file, "original"), b"original").unwrap(); + set_client_patch_enabled(root, false).unwrap(); + assert_eq!(fs::read(&file).unwrap(), b"original"); + assert!(fs::read_dir(root).unwrap().flatten().any(|entry| entry.file_name().to_string_lossy().contains("replaced-"))); + fs::write(&file, b"updated original").unwrap(); + set_client_patch_enabled(root, true).unwrap(); + assert!(fs::read_dir(root).unwrap().flatten().any(|entry| entry.file_name().to_string_lossy().contains("original.previous-"))); + } + + #[test] + fn uninstall_moves_corrupted_custom_only_assets_out_of_the_active_client() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + fs::create_dir_all(root.join("Data")).unwrap(); + let file = root.join("Data/patch-ZE1.MPQ"); + fs::write(&file, b"custom").unwrap(); + record_files(root, &["Data/patch-ZE1.MPQ"]); + fs::write(&file, b"corrupted custom").unwrap(); + uninstall_client_patches(root).unwrap(); + assert!(!file.exists()); + assert!(fs::read_dir(root.join("Data")).unwrap().flatten().any(|entry| entry.file_name().to_string_lossy().contains("replaced-"))); + assert_eq!(client_patch_status(root).state, ClientPatchState::NotInstalled); + } + + #[test] + fn unknown_or_corrupt_bytes_are_not_reported_as_enabled() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + fs::create_dir_all(root.join("Data")).unwrap(); + for name in RACE_MPQS { fs::write(root.join("Data").join(name), b"corrupt").unwrap(); } + fs::write(root.join("dinput8.dll"), b"corrupt").unwrap(); + fs::write(root.join(MANIFEST_FILENAME), format!("# CoA Custom {CLIENT_VERSION}\n")).unwrap(); + assert_eq!(client_patch_status(root).state, ClientPatchState::PartiallyInstalled); } #[test] @@ -595,6 +828,8 @@ mod tests { fs::write(client_path.join("dinput8.dll"), b"test dll").unwrap(); fs::write(client_path.join("EsteriaAppearance.dll"), b"test dll").unwrap(); + let names: Vec = RACE_MPQS.iter().map(|name| format!("Data/{name}")).chain(["dinput8.dll".into(), "EsteriaAppearance.dll".into()]).collect(); + record_files(client_path, &names.iter().map(String::as_str).collect::>()); let st = client_patch_status(client_path); assert_eq!(st.state, ClientPatchState::Enabled); assert_eq!(st.installed_mpqs, RACE_MPQS.len()); diff --git a/crates/coa-core/src/custom_races_hashes.json b/crates/coa-core/src/custom_races_hashes.json new file mode 100644 index 0000000..56f320e --- /dev/null +++ b/crates/coa-core/src/custom_races_hashes.json @@ -0,0 +1,50 @@ +{ + "Data/patch-ZE1.MPQ": "69d79dbbea54ec0312c37770debc79c7bc9ce2e21eedef99c03cd8d911955283", + "Data/patch-ZE2.MPQ": "1a41cae77ad730e9cf69e8d63fd12ae3a579418bb550d351fff469573a97ac3c", + "Data/patch-ZE3.MPQ": "b587202c21b6f580682b601b7c2e905fbfa766276e74733db55c1bddbffb7f87", + "Data/patch-ZEA.MPQ": "78fd10ec5262ae2417eba9700d2172553bbc1e5cfb0b597aada8f1753ca46d9d", + "Data/patch-ZHA.MPQ": "0fc6ebeafe1d14192dc64bafc90bde3c49230df1297f98bf65900a3bcf953617", + "Data/patch-ZZE.MPQ": "4b1e289e12c82644cb78f50170e6b5fd53918eb2de834f9e0cebddd81873a8bb", + "Data/patch-ZZR.MPQ": "5a7575e592dc0c8a6913e0d946bbe06fa0ca2e4d817ef1f8548d3d13142bcbd3", + "Ascension.exe": "e2c347019d554a59639e5978fd68e50e906973ab313c6573cc56ca82e8e3c547", + "EsteriaAppearance.bin": "b3fb7c69b7600c39f888ea1925701be5667097dcb02bb6ddd830e5489306bb50", + "EsteriaAppearance.dll": "392c5a2a25f9a69d705bdf841bb899aab768d4a3ee62734d576286c7198710d8", + "EsteriaAppearanceGeometry.bin": "c95c53ca19c53c3434e12b99c1966f9c015ec3944c46dff7784d5e4ffc472d5d", + "EsteriaAppearanceMaterials.bin": "f72f027f54a55c05aa0f76b457b97b484fe0e1aedc0e068cc0e0ca53e1878cba", + "EsteriaCosmeticWings.bin": "57352c28459d073a29b3014d33b70f3b3756afa37fa9583ef6b30d503422ac3f", + "EsteriaEarthen.bin": "8a0db93ba2d5c756b44ed84f2f7bc7c15eef13be6a7d87baf1383c6c0c624e02", + "EsteriaGnoll.bin": "cfacc8e681bb8e54a4f01ee9c12a91bf0bd23a23527bb548d4dc21ec1b33eea2", + "EsteriaGnollTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605", + "EsteriaHaranir.bin": "59f31d00e372fd2646e1cf0d9d3d5d671b31edd60ff7b862f8d1ce7e53ba1f2f", + "EsteriaHaranirTextures.bin": "99948ca570be2d376f0c67e3b8088739b6fa9f9d2c73985e19f68df77689a229", + "EsteriaHighmountain.bin": "e83349252ad2687ac23fcfc5623db7401cf32e5faf47a5a7be20c2f94b014938", + "EsteriaNaga.bin": "e87547cff5d09500c294f8b06935d134cdd0bbb962e294a72de09404505bd8b3", + "EsteriaNagaTextures.bin": "57ec560f99c1d48ac1e8f6a797c156513b99e7d349e3071afe405f812d159ae3", + "EsteriaSaberon.bin": "7735c9325c807c5125760d87f323d6b7d4162fb542c1d8b5f435ea2d6b9a4daa", + "EsteriaSaberonCoverage.bin": "9cdc227f08329e1245d2b864a996e7a772c96ecdbe5d81925f4d60cdbe4fa11e", + "EsteriaSaberonTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605", + "EsteriaSethrak.bin": "bd14857a8c11fdf8027128763293701586b2102f9a6539d33a727014789fd606", + "EsteriaSethrakTextures.bin": "d2b2cf58087a391f00198008fe871edd400ddf28d54a3a05674df74c5198528a", + "EsteriaThinHuman.bin": "75794d192ea0c228b3da7bf7d0bdff1119f79d3f9bfef6650cd5e1a2b5d106eb", + "EsteriaThinHumanTextures.bin": "02aeb4c05893d06125b8b58fdb7c2a56fe3d66a7d094706a44b43cb503d1731e", + "EsteriaTuskarr.bin": "ecbe7ef490ba67eca029a57639192db61238522618491549766fe38529914387", + "EsteriaTuskarrTextures.bin": "e2116c2c83adea079126fd5da765d20da24f044d4f161c4124e4368cb3d6bc71", + "EsteriaVrykul.bin": "39e2b7b4c67997dbbd34bae840bd606a59290f7f2fc82565a0ba00090335415c", + "EsteriaVrykulTextures.bin": "46098750a0e235724c6e9cc8d9d4e26550aa47bef95fd5f0f82efe5e945bf296", + "EsteriaVulpera.bin": "c5434993b9af4a17d881965e4735cd2ac379e39c7142aac9844adff9528562d4", + "EsteriaVulperaTextures.bin": "1ab844e74684aced2b069f1b91cd563aade45e05bd5e23f866a6c0c301cb098e", + "dinput8.dll": "f8de78606c241fb3911dce22faf68f5b9f9885b142b183c1e2c09ddc8f7e380c", + "Data/patch-T.MPQ": "9403e0680c74754eb0cb063718119aa6ace3616f7cecf488c8faf46f22505ae5", + "Data/patch-ZE5.MPQ": "476b5a1fcf69f64fdb281e3b445362b936d016066ccd12f897acfcf200d6c0cf", + "Data/patch-ZE6.MPQ": "762592c9ed950b4a7b6c7d85d45050b30fa3f35d53e348ee1e1516fdbb7e5484", + "Data/patch-ZE7.MPQ": "de54b0189d242763d2872223419a259b6b8a4b380c24b50a2ef9c6fa44466248", + "Data/patch-ZE8.MPQ": "1bc5691fb84a84f3e7a0a70baaa9192a97b4ec5906ee37c85838ffe08adef934", + "Data/patch-ZE9.MPQ": "d49c94463e9548e76378ce2c0454d8f204f0745c8c0de32b6dce7f0b0fd67c4a", + "Data/patch-ZES.MPQ": "a2d9348f917f745c7aa25b2eed96efa312f7b2d2f62cb86a4f2eaabc2dfa895f", + "Data/patch-ZG.MPQ": "48979e1fda775f8deb47bffa48e0a468595d4c71286fee518f51dc5801c5ca39", + "Data/patch-ZH.MPQ": "5e1d2fe287053abba703d7af381e10096c6525ba477ffed3971c2fc4f93e919c", + "Data/patch-ZHM.MPQ": "317d3ae00f0bd674b358af07544976acb7804aa6d8b19d1788ad96030e8c9bf0", + "Data/patch-ZJ.MPQ": "f821e99add86ef68d108c5ec466328c97677d3ed52f7944e200f3fe38c13b4de", + "Data/patch-ZM.MPQ": "424ab1978986e854551633983a94bd15beba6328aabea8c02f98eaa32f6f0b61", + "Data/patch-ZVU.MPQ": "c679b85a2e97344395756b54d2cf6abefe4e80e74944cb55b6e573ad17b7d020" +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fdc9149..c58aa68 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -699,6 +699,13 @@ async fn custom_races_install(app: AppHandle, state: State<'_, AppState>, id: St result } +#[tauri::command] +async fn custom_races_uninstall(state: State<'_, AppState>, id: String) -> std::result::Result<(), UiError> { + let ctx = client_context(&state, &id)?; + let _guard = BusyGuard::acquire(&state, &id)?; + blocking(move || coa_core::custom_races::uninstall_client_patches(&ctx.require()?)).await +} + #[tauri::command] async fn custom_races_toggle_client(state: State<'_, AppState>, id: String, enabled: bool) -> std::result::Result<(), UiError> { let root = path_of(&state, &id)?; @@ -1764,6 +1771,7 @@ pub fn run() { console_command, custom_races_status, custom_races_install, + custom_races_uninstall, custom_races_toggle_client ]) .run(tauri::generate_context!()) diff --git a/src/lib/api.ts b/src/lib/api.ts index e3dcb13..aafe0df 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -547,6 +547,7 @@ export const api = { clientDownload: (id: string, parent: string) => invoke("client_download", { id, parent }), clientCancel: () => invoke("client_cancel"), customRacesStatus: (id: string) => invoke("custom_races_status", { id }), + customRacesUninstall: (id: string) => invoke("custom_races_uninstall", { id }), customRacesInstall: (id: string) => invoke("custom_races_install", { id }), customRacesToggleClient: (id: string, enabled: boolean) => invoke("custom_races_toggle_client", { id, enabled }), play: (id: string) => invoke("play", { id }), diff --git a/src/screens/ModulesPage.tsx b/src/screens/ModulesPage.tsx index 5843dbd..9894687 100644 --- a/src/screens/ModulesPage.tsx +++ b/src/screens/ModulesPage.tsx @@ -144,6 +144,17 @@ export function ModulesPage({ serverId, onChanged, onRegisterGuard }: { serverId } } + async function uninstallClientPatch() { + setRacesInstalling(true); + setError(null); + try { + await api.customRacesUninstall(serverId); + await load(); + onChanged?.(); + } catch (e) { setError(asUiError(e)); } + finally { setRacesInstalling(false); } + } + async function toggle(m: ModuleView) { setError(null); try { @@ -252,7 +263,8 @@ export function ModulesPage({ serverId, onChanged, onRegisterGuard }: { serverId )} - {(!racesPatchStatus || racesPatchStatus.state === "not-installed" || racesPatchStatus.state === "partially-installed") && ( + {racesPatchStatus?.state === "partially-installed" &&

{locale === "ru" ? "Файлы отсутствуют или изменены. Восстановите патч клиента." : "Files are missing or changed. Repair the client patch."}

} + {( )} + {racesPatchStatus && racesPatchStatus.state !== "not-installed" && } +

{locale === "ru" ? (m.enabled ? "После установки патч будет включён." : "После установки патч останется отключённым: модуль сервера выключен или отсутствует.") : (m.enabled ? "The patch will be enabled after installation." : "The patch will stay disabled after installation: the server module is off or missing.")}

)} From 0c0d2db368ea38521e96f27acaa25ec8c34080fe 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: Tue, 13 Oct 2026 06:17:17 +0500 Subject: [PATCH 39/62] fix(startup): wait for checks and report operation lock owners --- .../20261013-update-check-start-contention.md | 7 +++ crates/coa-core/src/driver.rs | 2 +- crates/coa-core/src/update.rs | 45 +++++++++++++++++-- 3 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 changelog.d/20261013-update-check-start-contention.md diff --git a/changelog.d/20261013-update-check-start-contention.md b/changelog.d/20261013-update-check-start-contention.md new file mode 100644 index 0000000..24d7cf7 --- /dev/null +++ b/changelog.d/20261013-update-check-start-contention.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Starting the server waits for update checks and identifies ongoing maintenance +--- +Downloading an update manifest no longer blocks startup. A start waits up to 30 seconds for a check to finish; other active maintenance still blocks it safely and shows its operation, process and start time. diff --git a/crates/coa-core/src/driver.rs b/crates/coa-core/src/driver.rs index 954f4ef..40c0964 100644 --- a/crates/coa-core/src/driver.rs +++ b/crates/coa-core/src/driver.rs @@ -100,7 +100,7 @@ pub(crate) fn validate_update(root: &Path) -> Result { fn run_inner(root: &Path, verb: Verb, validating_update: bool) -> Result { let root = fsx::canonicalize_lenient(root)?; let _update_lock = if !validating_update && matches!(verb, Verb::StartAll | Verb::StartWorld) { - Some(crate::update::operation_lock(&crate::registry::metadata_dir_for(&root)?)?) + Some(crate::update::operation_lock_for(&crate::registry::metadata_dir_for(&root)?, "starting server", true)?) } else { None }; if !validating_update && matches!(verb, Verb::StartAll | Verb::StartWorld) { crate::update::ensure_recovered(&crate::registry::metadata_dir_for(&root)?)?; diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index 677546e..7117396 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -308,9 +308,12 @@ pub fn preview(root: &Path, meta: &InstallMeta, source: &Source, trusted_key: &s pub fn preview_with(root: &Path, meta: &InstallMeta, source: &Source, trusted_key: &str, resolutions: &BTreeMap, access: DatabaseAccess) -> Result { refuse_for_docker(root)?; let meta_dir = crate::registry::metadata_dir_for(root)?; - let _lock = operation_lock(&meta_dir)?; - ensure_recovered(&meta_dir)?; + let observed_version = MetaDir::open(&meta_dir)?.1.core.version; let (m, _) = fetch_manifest(source, trusted_key)?; + let _lock = operation_lock_for(&meta_dir, "checking updates", false)?; + ensure_recovered(&meta_dir)?; + let (_, current_meta) = MetaDir::open(&meta_dir)?; + let meta = if current_meta.core.version != observed_version { ¤t_meta } else { meta }; check_manifest(&m)?; reject_downgrade(meta, &m)?; let mut items = plan(root, meta, &m, resolutions, None)?; @@ -780,11 +783,26 @@ pub fn rollback(root: &Path, meta_dir: &Path, id: &str, env: &dyn Env) -> Result } pub(crate) fn operation_lock(meta_dir: &Path) -> Result { + operation_lock_for(meta_dir, "server maintenance", false) +} + +pub(crate) fn operation_lock_for(meta_dir: &Path, operation: &str, wait_for_check: bool) -> Result { fs::create_dir_all(meta_dir)?; let lock = fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(meta_dir.join("update.lock"))?; - if !fs4::fs_std::FileExt::try_lock_exclusive(&lock)? { - return Err(Error::Invalid("Another update, repair or recovery is in progress.".into())); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + if fs4::fs_std::FileExt::try_lock_exclusive(&lock)? { break; } + let owner = fsx::read_json::(&meta_dir.join("update-lock-owner.json")).ok(); + let checking = owner.as_ref().and_then(|owner| owner.get("operation")).and_then(|value| value.as_str()) == Some("checking updates"); + if wait_for_check && checking && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(100)); + continue; + } + let details = owner.as_ref().and_then(|owner| Some(format!(" Operation: {}; Manager process: {}; started: {}.", owner.get("operation")?.as_str()?, owner.get("pid")?.as_u64()?, owner.get("started")?.as_str()?))).unwrap_or_default(); + return Err(Error::Invalid(format!("Another update, repair or recovery is in progress.{details} Wait for that operation to finish, then retry."))); } + let record = serde_json::json!({"operation": operation, "pid": std::process::id(), "started": chrono::Utc::now().to_rfc3339()}); + fsx::atomic_write_json(&meta_dir.join("update-lock-owner.json"), &record)?; Ok(lock) } @@ -1358,6 +1376,25 @@ mod tests { w } + #[test] + fn startup_waits_for_an_update_check_but_not_for_active_maintenance() { + let temp = tempfile::tempdir().unwrap(); + let checker = operation_lock_for(temp.path(), "checking updates", false).unwrap(); + let root = temp.path().to_path_buf(); + let release = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(150)); + drop(checker); + }); + let start = operation_lock_for(&root, "starting server", true).unwrap(); + release.join().unwrap(); + let error = operation_lock(&root).unwrap_err().to_string(); + assert!(error.contains("starting server")); + assert!(error.contains(&std::process::id().to_string())); + drop(start); + // Stale owner metadata is harmless once the OS lock is released. + operation_lock(&root).unwrap(); + } + #[test] fn legacy_manager_realm_launcher_is_replaced_during_update() { let w = legacy_launcher_world(false); From 358f9f4f2bd40540ff8bb3ea85b9cf286fe09a1d 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: Tue, 13 Oct 2026 06:10:56 +0500 Subject: [PATCH 40/62] fix(bots): exclude every nonstandard race in the stock preset --- changelog.d/20261013-stock-bot-races.md | 7 +++++++ crates/coa-core/src/custom_races.rs | 22 ++++++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 changelog.d/20261013-stock-bot-races.md diff --git a/changelog.d/20261013-stock-bot-races.md b/changelog.d/20261013-stock-bot-races.md new file mode 100644 index 0000000..032c1ac --- /dev/null +++ b/changelog.d/20261013-stock-bot-races.md @@ -0,0 +1,7 @@ +--- +area: bots +type: fixed +audience: admins +title: Disabling custom races for SQUID bots now excludes all nonstandard races +--- +The standard-races preset keeps only the ten original playable races. High elves, worgen and other custom race IDs are excluded, and the displayed setting reads complete race IDs rather than matching number substrings. diff --git a/crates/coa-core/src/custom_races.rs b/crates/coa-core/src/custom_races.rs index 915c5f3..aa9abb4 100644 --- a/crates/coa-core/src/custom_races.rs +++ b/crates/coa-core/src/custom_races.rs @@ -55,7 +55,7 @@ pub const RACE_MPQS: &[&str] = &[ ]; pub const EXCLUDED_BOT_RACES_RECOMMENDED: &str = - "19,27,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128"; + "9,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128"; pub const EXCLUDED_BOT_RACES_EXPERIMENTAL: &str = "16,19,27,32,50,52,53,65,66,67,68,69,70,71,72,74,77,80,81"; @@ -99,7 +99,8 @@ pub fn is_bot_custom_races_allowed(root: &Path) -> Option { if let Ok(conf) = crate::config::parser::ConfFile::parse_bytes(&bytes) { if let Some(val) = conf.get("AiPlayerbot.ExcludedBotRaces") { let clean = val.trim().trim_matches(['"', '\'']); - return Some(!clean.contains("33") && !clean.contains("128")); + let excluded: std::collections::BTreeSet = clean.split(',').filter_map(|race| race.trim().parse().ok()).collect(); + return Some((1..=128).filter(|race| ![1, 2, 3, 4, 5, 6, 7, 8, 10, 11].contains(race)).any(|race| !excluded.contains(&race))); } } } @@ -617,4 +618,21 @@ mod tests { assert!(client_path.join("dinput8.dll").is_file()); assert!(!client_path.join("dinput8.dll.disabled").is_file()); } + #[test] + fn disabling_bot_custom_races_excludes_every_nonstandard_race() { + let temp = tempfile::tempdir().unwrap(); + let conf = temp.path().join("Core/configs/modules/playerbots.conf"); + fs::create_dir_all(conf.parent().unwrap()).unwrap(); + fs::write(&conf, "AiPlayerbot.ExcludedBotRaces = \"\"\n").unwrap(); + sync_bot_races_config(temp.path(), false).unwrap(); + assert_eq!(is_bot_custom_races_allowed(temp.path()), Some(false)); + let parsed = crate::config::parser::ConfFile::parse_bytes(&fs::read(conf).unwrap()).unwrap(); + let value = parsed.get("AiPlayerbot.ExcludedBotRaces").unwrap().trim_matches('"'); + let excluded: Vec = value.split(',').map(|race| race.parse().unwrap()).collect(); + for race in 1..=128 { + assert_eq!(excluded.contains(&race), ![1, 2, 3, 4, 5, 6, 7, 8, 10, 11].contains(&race), "race {race}"); + } + } + + } From 1a71f18defd695bcbb692c0ef39b13ffad455d34 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: Tue, 13 Oct 2026 06:20:23 +0500 Subject: [PATCH 41/62] fix(client): create missing patch directories before publishing --- crates/coa-core/src/custom_races.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/coa-core/src/custom_races.rs b/crates/coa-core/src/custom_races.rs index 7710523..63db5d6 100644 --- a/crates/coa-core/src/custom_races.rs +++ b/crates/coa-core/src/custom_races.rs @@ -236,6 +236,7 @@ impl<'a> PatchTransaction<'a> { } fn copy(&mut self, source: &Path, target: &Path) -> Result<()> { self.save(target)?; + fs::create_dir_all(target.parent().ok_or_else(|| Error::Invalid("Patch file has no parent directory.".into()))?)?; fs::copy(source, target)?; Ok(()) } @@ -727,11 +728,13 @@ mod tests { let mut tx = PatchTransaction::new(root).unwrap(); tx.save(&file).unwrap(); fs::write(&file, b"replacement").unwrap(); - tx.save(&root.join("new.dll")).unwrap(); - fs::write(root.join("new.dll"), b"new").unwrap(); + let staged = root.join("staged.dll"); + fs::write(&staged, b"new").unwrap(); + tx.copy(&staged, &root.join("Data/new.dll")).unwrap(); + assert_eq!(fs::read(root.join("Data/new.dll")).unwrap(), b"new"); } assert_eq!(fs::read(&file).unwrap(), b"previous"); - assert!(!root.join("new.dll").exists()); + assert!(!root.join("Data/new.dll").exists()); let mut tx = PatchTransaction::new(root).unwrap(); tx.save(&file).unwrap(); fs::write(&file, b"interrupted").unwrap(); From b97f86fc00aed5e91fdc8971493d6a2e76c6a157 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: Tue, 13 Oct 2026 06:14:53 +0500 Subject: [PATCH 42/62] feat(modules): show installed revisions and client patch versions --- .../20261013-installed-module-versions.md | 7 ++++ crates/coa-core/src/custom_races.rs | 4 +- crates/coa-core/src/modules.rs | 37 +++++++++++++++++++ src/lib/api.ts | 1 + src/screens/ModulesPage.tsx | 2 + 5 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 changelog.d/20261013-installed-module-versions.md diff --git a/changelog.d/20261013-installed-module-versions.md b/changelog.d/20261013-installed-module-versions.md new file mode 100644 index 0000000..7916c12 --- /dev/null +++ b/changelog.d/20261013-installed-module-versions.md @@ -0,0 +1,7 @@ +--- +area: manager +type: added +audience: admins +title: Module cards show installed server revisions and the client race patch version +--- +Module revisions come from the currently installed release metadata. The race card shows the client patch version separately; missing version metadata is reported honestly instead of showing a download version as installed. diff --git a/crates/coa-core/src/custom_races.rs b/crates/coa-core/src/custom_races.rs index 63db5d6..4acda94 100644 --- a/crates/coa-core/src/custom_races.rs +++ b/crates/coa-core/src/custom_races.rs @@ -146,6 +146,7 @@ pub enum ClientPatchState { #[serde(rename_all = "camelCase")] pub struct ClientPatchStatus { pub state: ClientPatchState, + pub version: Option, pub installed_mpqs: usize, pub total_mpqs: usize, pub has_root_files: bool, @@ -210,7 +211,8 @@ pub fn client_patch_status(client_path: &Path) -> ClientPatchStatus { } else if !manifest.is_empty() || patch_files(client_path).is_ok_and(|files| files.iter().filter(|file| file.path.starts_with("Data/") && file.path != "Data/patch-T.MPQ").any(|file| matches_patch(&client_path.join(&file.path), file) || matches_patch(&suffix(&client_path.join(&file.path), "disabled"), file))) { ClientPatchState::PartiallyInstalled } else { ClientPatchState::NotInstalled }; - ClientPatchStatus { state, installed_mpqs, total_mpqs: RACE_MPQS.len(), has_root_files, + let version = manifest.lines().find_map(|line| line.strip_prefix("# CoA Custom ").map(str::to_string)); + ClientPatchStatus { state, version, installed_mpqs, total_mpqs: RACE_MPQS.len(), has_root_files, client_path: Some(client_path.to_string_lossy().into_owned()) } } diff --git a/crates/coa-core/src/modules.rs b/crates/coa-core/src/modules.rs index b6d0bdd..f5b8606 100644 --- a/crates/coa-core/src/modules.rs +++ b/crates/coa-core/src/modules.rs @@ -119,7 +119,26 @@ fn conf_name(root: &Path, e: &Entry) -> String { } } +fn installed_module_commits(root: &Path) -> BTreeMap { + let Ok(directory) = crate::registry::metadata_dir_for(root) else { return Default::default() }; + let Ok((_, meta)) = crate::registry::MetaDir::open(&directory) else { return Default::default() }; + let Some(version) = meta.core.version else { return Default::default() }; + let candidates = [directory.join("manifests").join(format!("update-{version}.json")), directory.join("manifests/base.json")]; + for path in candidates { + let Ok(manifest) = crate::fsx::read_json::(&path) else { continue }; + if manifest.get("version").and_then(|value| value.as_str()) != Some(&version) { continue; } + if let Some(commits) = manifest.get("compatibility").and_then(|value| value.get("moduleCommits")).and_then(|value| value.as_object()) { + return commits.iter().filter_map(|(name, commit)| { + let commit = commit.as_str()?; + (commit.len() == 40 && commit.bytes().all(|byte| byte.is_ascii_hexdigit())).then(|| (name.clone(), commit.chars().take(8).collect())) + }).collect(); + } + } + Default::default() +} + fn list_in(cat: &[Entry], root: &Path) -> Vec { + let installed_commits = installed_module_commits(root); let wildcard = crate::realms::state(root).map(|s| s.active == crate::realms::Mode::Wildcard).unwrap_or(true); cat.iter() .cloned() @@ -141,6 +160,8 @@ fn list_in(cat: &[Entry], root: &Path) -> Vec { let version = if e.id == "playerbots" { crate::squid::release(root).map(|release| { [release.tag, release.commit.map(|commit| commit.chars().take(8).collect())].into_iter().flatten().collect::>().join(" · ") }) } else { None }; + let key = match e.id.as_str() { "companions" => "bots", "playerbots" => "squid", "content-scaling" => "scaling", "custom-races" => "races", id => id }; + let version = if installed { version.or_else(|| installed_commits.get(key).cloned()) } else { None }; ModuleView { version, compatibility: if blocked { "unsupported" } else if wildcard && e.id != "client-compat" { "experimental" } else { "compatible" }.into(), @@ -425,6 +446,22 @@ mod tests { (root, meta) } + #[test] + fn module_revisions_come_from_the_installed_release_only() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("server"); + fs::create_dir_all(&root).unwrap(); + let mut meta = crate::registry::InstallMeta::new(crate::registry::InstallKind::Imported, &root); + meta.core.version = Some("2.0.0".into()); + let directory = crate::registry::MetaDir::create(&root, &meta).unwrap(); + let value = serde_json::json!({"version": "2.0.0", "compatibility": {"moduleCommits": {"races": "a".repeat(40), "bots": "b".repeat(40)}}}); + fsx::atomic_write_json(&directory.root.join("manifests/update-2.0.0.json"), &value).unwrap(); + assert_eq!(installed_module_commits(&root).get("races").map(String::as_str), Some("aaaaaaaa")); + let stale = serde_json::json!({"version": "1.0.0", "compatibility": {"moduleCommits": {"races": "c".repeat(40)}}}); + fsx::atomic_write_json(&directory.root.join("manifests/update-2.0.0.json"), &stale).unwrap(); + assert!(installed_module_commits(&root).is_empty()); + } + #[test] fn squid_missing_keys_use_code_defaults_and_saves_enforce_json_ranges() { let d = tempfile::tempdir().unwrap(); diff --git a/src/lib/api.ts b/src/lib/api.ts index aafe0df..b0fb34a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -393,6 +393,7 @@ export interface ClientDownloadCheck { export type ClientPatchState = "not-installed" | "enabled" | "disabled" | "partially-installed"; export interface CustomRacesClientStatus { + version: string | null; state: ClientPatchState; installedMpqs: number; totalMpqs: number; diff --git a/src/screens/ModulesPage.tsx b/src/screens/ModulesPage.tsx index 9894687..5cba9dd 100644 --- a/src/screens/ModulesPage.tsx +++ b/src/screens/ModulesPage.tsx @@ -216,7 +216,9 @@ export function ModulesPage({ serverId, onChanged, onRegisterGuard }: { serverId {!m.installed && m.status !== "soon" && {t("mod.notHere")}} {m.id === "custom-races" && ( +
+

{locale === "ru" ? "Установленная версия патча клиента: " : "Installed client patch version: "}{racesPatchStatus?.version ?? (locale === "ru" ? "не записана" : "not recorded")}

{racesInstalling && racesProgress ? (
From 8269a53db7622ec60566dc780fa4f8b2eb0dcd23 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: Tue, 13 Oct 2026 06:26:46 +0500 Subject: [PATCH 43/62] fix(diagnostics): record refused starts and export maintenance ownership --- .../20261013-maintenance-diagnostic-export.md | 7 ++++ crates/coa-core/src/diag.rs | 15 +++++++++ crates/coa-core/src/driver.rs | 32 ++++++++++++++++++- 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 changelog.d/20261013-maintenance-diagnostic-export.md diff --git a/changelog.d/20261013-maintenance-diagnostic-export.md b/changelog.d/20261013-maintenance-diagnostic-export.md new file mode 100644 index 0000000..635b5d7 --- /dev/null +++ b/changelog.d/20261013-maintenance-diagnostic-export.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Diagnostics record blocked startup requests and maintenance owner information +--- +A start refused before the launcher runs is now logged with its reason. Exported diagnostics include the last recorded maintenance owner, while clearly distinguishing old metadata from a currently held operating-system lock. diff --git a/crates/coa-core/src/diag.rs b/crates/coa-core/src/diag.rs index a5ad4e8..c53614d 100644 --- a/crates/coa-core/src/diag.rs +++ b/crates/coa-core/src/diag.rs @@ -347,6 +347,15 @@ pub fn export_package(root: &Path, meta_dir: &Path, manager_log: &Path, meta: &I "wow_client_set": meta.client_path.is_some(), }); add("summary.json", serde_json::to_string_pretty(&summary)?.as_bytes())?; + let owner = fs::read_to_string(meta_dir.join("update-lock-owner.json")).ok() + .and_then(|text| serde_json::from_str::(&redact_json(&text)).ok()); + let maintenance = serde_json::json!({ + "lockFilePresent": meta_dir.join("update.lock").is_file(), + "lastRecordedOwner": owner, + "note": "The lock file and owner record may remain after completion. Their presence does not establish whether the OS lock is currently held." + }); + add("maintenance.json", serde_json::to_string_pretty(&maintenance)?.as_bytes())?; + // Logs. The newest update logs of the repack's own updater are added under their own names. let mut logs: Vec<(String, PathBuf)> = vec![ @@ -599,6 +608,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::write(meta_dir.join("update.lock"), b"").unwrap(); + fs::write(meta_dir.join("update-lock-owner.json"), r#"{"operation":"checking updates","pid":1234,"started":"2026-10-13T01:00:00Z","token":"owner-secret"}"#).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(); @@ -620,6 +631,10 @@ real error assert!(!all.contains("schema-secret")); assert!(all.contains("user_character_trigger") && all.contains("acore_characters")); assert!(!all.contains("recovery-secret")); + assert!(all.contains("checking updates") && all.contains("1234")); + assert!(all.contains("lockFilePresent") && all.contains("does not establish")); + assert!(!all.contains("owner-secret")); + assert_eq!(fs::read(meta_dir.join("update.lock")).unwrap(), b""); assert!(!all.contains("hunter2") && !all.contains("SECRETPW") && !all.contains("upstream-secret")); } } diff --git a/crates/coa-core/src/driver.rs b/crates/coa-core/src/driver.rs index 954f4ef..e741f69 100644 --- a/crates/coa-core/src/driver.rs +++ b/crates/coa-core/src/driver.rs @@ -90,7 +90,12 @@ fn launcher(root: &Path) -> Result<(PathBuf, PathBuf)> { /// Run a launcher verb to completion (blocking; call from a worker thread). pub fn run(root: &Path, verb: Verb) -> Result { - run_inner(root, verb, false) + tracing::info!(?verb, root = %root.display(), "driver: launcher requested"); + let result = run_inner(root, verb, false); + if let Err(error) = &result { + tracing::warn!(?verb, root = %root.display(), %error, "driver: launcher request failed before completion"); + } + result } pub(crate) fn validate_update(root: &Path) -> Result { @@ -287,6 +292,31 @@ pub(crate) fn startup_failure(root: &Path, started: &DriverOutcome) -> String { mod tests { use super::*; + #[test] + fn a_blocked_start_is_logged_before_any_launcher_is_spawned() { + use std::io::Write; + use std::sync::{Arc, Mutex}; + struct Capture(Arc>>); + impl Write for Capture { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { self.0.lock().unwrap().extend_from_slice(bytes); Ok(bytes.len()) } + fn flush(&mut self) -> std::io::Result<()> { Ok(()) } + } + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("server"); + std::fs::create_dir(&root).unwrap(); + let metadata = crate::registry::metadata_dir_for(&root).unwrap(); + let _maintenance = crate::update::operation_lock(&metadata).unwrap(); + let buffer = Arc::new(Mutex::new(Vec::new())); + let writer = Arc::clone(&buffer); + let subscriber = tracing_subscriber::fmt().without_time().with_ansi(false).with_writer(move || Capture(Arc::clone(&writer))).finish(); + let error = tracing::subscriber::with_default(subscriber, || run(&root, Verb::StartAll)).unwrap_err(); + assert!(error.to_string().contains("in progress")); + let text = String::from_utf8(buffer.lock().unwrap().clone()).unwrap(); + assert!(text.contains("launcher requested") && text.contains("StartAll")); + assert!(text.contains("launcher request failed before completion") && text.contains("in progress")); + assert!(!text.contains("starting launcher verb")); + } + #[test] fn background_start_disables_cli_in_template_and_rendered_config() { let folder = tempfile::tempdir().unwrap(); From ca2a555d7cdeb3f77386c3c62c9aab3b2d7df303 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: Tue, 13 Oct 2026 06:41:16 +0500 Subject: [PATCH 44/62] fix(docker): request restart when remote client protocol changes --- changelog.d/20261013-docker-remote-client-restart.md | 7 +++++++ crates/coa-core/src/docker/sharing.rs | 12 +++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 changelog.d/20261013-docker-remote-client-restart.md diff --git a/changelog.d/20261013-docker-remote-client-restart.md b/changelog.d/20261013-docker-remote-client-restart.md new file mode 100644 index 0000000..01bafc6 --- /dev/null +++ b/changelog.d/20261013-docker-remote-client-restart.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Docker friend mode requests a restart when remote client support changes +--- +Enabling remote CoA clients now requests a world restart even when the game ports were already open. Repeating the same unchanged setting does not request another restart. diff --git a/crates/coa-core/src/docker/sharing.rs b/crates/coa-core/src/docker/sharing.rs index b233d4d..1c0d120 100644 --- a/crates/coa-core/src/docker/sharing.rs +++ b/crates/coa-core/src/docker/sharing.rs @@ -59,10 +59,11 @@ pub fn set_open(root: &Path, meta: &Path, open: bool) -> Result { if changed { fsx::atomic_write_json(&root.join(MARKER), &cfg)?; } + let remote_changed = coa_edit.is_some(); if let Some(text) = coa_edit { fsx::atomic_write(&coa_conf, text.as_bytes())?; } - Ok(changed) + Ok(changed || remote_changed) } /// What each service can be reached from, computed from how the containers publish their ports: the database is never published, @@ -110,6 +111,15 @@ mod tests { assert_eq!(Config::load(&root).unwrap().bind_address, "127.0.0.1"); } + #[test] + fn enabling_remote_client_protocol_requires_restart_even_when_ports_are_open() { + let (_d, root, meta) = server(Some("0.0.0.0")); + fs::write(root.join("Core/configs/modules/coa.conf"), "CoA.Enable = 1\nCoA.AllowRemoteClients = 0\n").unwrap(); + assert!(set_open(&root, &meta, true).unwrap()); + assert_eq!(Config::load(&root).unwrap().bind_address, "0.0.0.0"); + assert!(!set_open(&root, &meta, true).unwrap()); + } + #[test] fn an_address_chosen_by_hand_is_kept_when_opening_and_other_settings_survive() { let (_d, root, meta) = server(Some("192.168.1.20")); From a6471d79d00bc64af1c5c4526e7c65d6ae7e53e2 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: Tue, 13 Oct 2026 06:52:12 +0500 Subject: [PATCH 45/62] feat(client): update verified custom race payloads to 1.6 --- .../20261013-custom-races-client-update.md | 7 +++ crates/coa-core/src/custom_races.rs | 38 ++++++++++---- crates/coa-core/src/custom_races_hashes.json | 18 ++++--- .../coa-core/src/custom_races_hashes_1_5.json | 50 +++++++++++++++++++ 4 files changed, 94 insertions(+), 19 deletions(-) create mode 100644 changelog.d/20261013-custom-races-client-update.md create mode 100644 crates/coa-core/src/custom_races_hashes_1_5.json diff --git a/changelog.d/20261013-custom-races-client-update.md b/changelog.d/20261013-custom-races-client-update.md new file mode 100644 index 0000000..b663df9 --- /dev/null +++ b/changelog.d/20261013-custom-races-client-update.md @@ -0,0 +1,7 @@ +--- +area: client +type: changed +audience: admins +title: Custom race client installation follows the current upstream release +--- +The installer verifies the current race archives and includes the two additional MPQs. Legacy patch hashes remain available so upgrading never mistakes the old custom executable for an original launcher file. diff --git a/crates/coa-core/src/custom_races.rs b/crates/coa-core/src/custom_races.rs index 6077c0e..1c3ae84 100644 --- a/crates/coa-core/src/custom_races.rs +++ b/crates/coa-core/src/custom_races.rs @@ -14,20 +14,20 @@ use crate::download::Cancel; use crate::error::{Error, Result}; use crate::fsx; -pub const CLIENT_VERSION: &str = "1.5"; +pub const CLIENT_VERSION: &str = "1.6"; pub const MANIFEST_FILENAME: &str = "custom_races_patch_manifest.txt"; pub const ARCHIVES: &[(&str, &str)] = &[ ( - "CoA-Custom-1.5-main-20261009-cb2a0ca4.zip", - "https://github.com/ilusixn/azerothcore-wotlk-coa/releases/download/coa-custom-1.5/CoA-Custom-1.5-main-20261009-cb2a0ca4.zip", + "CoA-Custom-1.6-main-20261012-4dd3a4ec.zip", + "https://github.com/ilusixn/azerothcore-wotlk-coa/releases/download/coa-custom-1.6/CoA-Custom-1.6-main-20261012-4dd3a4ec.zip", ), ( - "CoA-Custom-1.5-client-part1.zip", - "https://github.com/ilusixn/azerothcore-wotlk-coa/releases/download/coa-custom-1.5/CoA-Custom-1.5-client-part1.zip", + "CoA-Custom-1.6-client-part1.zip", + "https://github.com/ilusixn/azerothcore-wotlk-coa/releases/download/coa-custom-1.6/CoA-Custom-1.6-client-part1.zip", ), ( - "CoA-Custom-1.5-client-part2.zip", - "https://github.com/ilusixn/azerothcore-wotlk-coa/releases/download/coa-custom-1.5/CoA-Custom-1.5-client-part2.zip", + "CoA-Custom-1.6-client-part2.zip", + "https://github.com/ilusixn/azerothcore-wotlk-coa/releases/download/coa-custom-1.6/CoA-Custom-1.6-client-part2.zip", ), ]; @@ -52,6 +52,8 @@ pub const RACE_MPQS: &[&str] = &[ "patch-ZJ.MPQ", "patch-ZZE.MPQ", "patch-ZZR.MPQ", + "patch-ZZS.MPQ", + "patch-ZZT.MPQ", ]; pub const EXCLUDED_BOT_RACES_RECOMMENDED: &str = @@ -161,8 +163,9 @@ struct PatchFile { } fn patch_files(client: &Path) -> Result> { - let bundled: std::collections::BTreeMap = serde_json::from_str(include_str!("custom_races_hashes.json"))?; let manifest = fs::read_to_string(client.join(MANIFEST_FILENAME)).unwrap_or_default(); + let hashes = if manifest.lines().any(|line| line == "# CoA Custom 1.5") { include_str!("custom_races_hashes_1_5.json") } else { include_str!("custom_races_hashes.json") }; + let bundled: std::collections::BTreeMap = serde_json::from_str(hashes)?; let mut files = std::collections::BTreeMap::new(); for line in manifest.lines() { let path = if let Some(name) = line.strip_prefix("MPQ:") { @@ -361,9 +364,9 @@ pub fn uninstall_client_patches(client: &Path) -> Result<()> { fn verify_archive(path: &Path, filename: &str) -> Result<()> { let expected = match filename { - "CoA-Custom-1.5-main-20261009-cb2a0ca4.zip" => "117f5d99add18191676be6648fb0f2e99019a06c18705c71454e371e7df2afd2", - "CoA-Custom-1.5-client-part1.zip" => "19eb372805b3268653914da03377d67f056e5b50d818a8a06a5f260aad03211e", - "CoA-Custom-1.5-client-part2.zip" => "122f2882da255913e01610e33c6446ded897fecbe60b6d5ee026149becf8f278", + "CoA-Custom-1.6-main-20261012-4dd3a4ec.zip" => "dfe802ea70047c129ac2e0aaa557bab3110e3cc6926d36403b8c880a0e310ee4", + "CoA-Custom-1.6-client-part1.zip" => "1dbf1901ad5be61eba66973609df90712ffc78ef9e9b2c8549a5f3e36332bf12", + "CoA-Custom-1.6-client-part2.zip" => "689f7f946b4180fe977dabedc7cfe642e3e7f27431600c64c4dd5b7960b1bc66", _ => return Err(Error::Invalid("Unknown custom races archive.".into())), }; if fsx::sha256_file(path)? != expected { @@ -689,6 +692,19 @@ mod tests { assert_eq!(actual, expected); } + #[test] + fn legacy_installation_uses_its_own_hashes_when_preserving_originals() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join(MANIFEST_FILENAME), "# CoA Custom 1.5\n").unwrap(); + let legacy: std::collections::BTreeMap = serde_json::from_str(include_str!("custom_races_hashes_1_5.json")).unwrap(); + let current: std::collections::BTreeMap = serde_json::from_str(include_str!("custom_races_hashes.json")).unwrap(); + let files = patch_files(temp.path()).unwrap(); + assert_eq!(files.iter().find(|file| file.path == "Ascension.exe").unwrap().sha256, legacy["Ascension.exe"]); + assert_eq!(files.len(), legacy.len()); + assert!(current.len() > legacy.len()); + assert!(!files.iter().any(|file| file.path == "Data/patch-ZZS.MPQ")); + } + #[test] fn another_patch_operation_cannot_enter_the_same_client() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/coa-core/src/custom_races_hashes.json b/crates/coa-core/src/custom_races_hashes.json index 56f320e..9baa919 100644 --- a/crates/coa-core/src/custom_races_hashes.json +++ b/crates/coa-core/src/custom_races_hashes.json @@ -1,11 +1,4 @@ { - "Data/patch-ZE1.MPQ": "69d79dbbea54ec0312c37770debc79c7bc9ce2e21eedef99c03cd8d911955283", - "Data/patch-ZE2.MPQ": "1a41cae77ad730e9cf69e8d63fd12ae3a579418bb550d351fff469573a97ac3c", - "Data/patch-ZE3.MPQ": "b587202c21b6f580682b601b7c2e905fbfa766276e74733db55c1bddbffb7f87", - "Data/patch-ZEA.MPQ": "78fd10ec5262ae2417eba9700d2172553bbc1e5cfb0b597aada8f1753ca46d9d", - "Data/patch-ZHA.MPQ": "0fc6ebeafe1d14192dc64bafc90bde3c49230df1297f98bf65900a3bcf953617", - "Data/patch-ZZE.MPQ": "4b1e289e12c82644cb78f50170e6b5fd53918eb2de834f9e0cebddd81873a8bb", - "Data/patch-ZZR.MPQ": "5a7575e592dc0c8a6913e0d946bbe06fa0ca2e4d817ef1f8548d3d13142bcbd3", "Ascension.exe": "e2c347019d554a59639e5978fd68e50e906973ab313c6573cc56ca82e8e3c547", "EsteriaAppearance.bin": "b3fb7c69b7600c39f888ea1925701be5667097dcb02bb6ddd830e5489306bb50", "EsteriaAppearance.dll": "392c5a2a25f9a69d705bdf841bb899aab768d4a3ee62734d576286c7198710d8", @@ -46,5 +39,14 @@ "Data/patch-ZHM.MPQ": "317d3ae00f0bd674b358af07544976acb7804aa6d8b19d1788ad96030e8c9bf0", "Data/patch-ZJ.MPQ": "f821e99add86ef68d108c5ec466328c97677d3ed52f7944e200f3fe38c13b4de", "Data/patch-ZM.MPQ": "424ab1978986e854551633983a94bd15beba6328aabea8c02f98eaa32f6f0b61", - "Data/patch-ZVU.MPQ": "c679b85a2e97344395756b54d2cf6abefe4e80e74944cb55b6e573ad17b7d020" + "Data/patch-ZVU.MPQ": "c679b85a2e97344395756b54d2cf6abefe4e80e74944cb55b6e573ad17b7d020", + "Data/patch-ZE1.MPQ": "69d79dbbea54ec0312c37770debc79c7bc9ce2e21eedef99c03cd8d911955283", + "Data/patch-ZE2.MPQ": "1a41cae77ad730e9cf69e8d63fd12ae3a579418bb550d351fff469573a97ac3c", + "Data/patch-ZE3.MPQ": "b587202c21b6f580682b601b7c2e905fbfa766276e74733db55c1bddbffb7f87", + "Data/patch-ZEA.MPQ": "78fd10ec5262ae2417eba9700d2172553bbc1e5cfb0b597aada8f1753ca46d9d", + "Data/patch-ZHA.MPQ": "0fc6ebeafe1d14192dc64bafc90bde3c49230df1297f98bf65900a3bcf953617", + "Data/patch-ZZE.MPQ": "4b1e289e12c82644cb78f50170e6b5fd53918eb2de834f9e0cebddd81873a8bb", + "Data/patch-ZZR.MPQ": "5a7575e592dc0c8a6913e0d946bbe06fa0ca2e4d817ef1f8548d3d13142bcbd3", + "Data/patch-ZZS.MPQ": "533fd69d5784d83e872a5a28d27301dc9ab5d0cb925f8b2116926854e4a0a5b8", + "Data/patch-ZZT.MPQ": "aef3bbe4951333d62fb55224484724fbaa9c35773a19944ffb9935e250cfb59c" } diff --git a/crates/coa-core/src/custom_races_hashes_1_5.json b/crates/coa-core/src/custom_races_hashes_1_5.json new file mode 100644 index 0000000..56f320e --- /dev/null +++ b/crates/coa-core/src/custom_races_hashes_1_5.json @@ -0,0 +1,50 @@ +{ + "Data/patch-ZE1.MPQ": "69d79dbbea54ec0312c37770debc79c7bc9ce2e21eedef99c03cd8d911955283", + "Data/patch-ZE2.MPQ": "1a41cae77ad730e9cf69e8d63fd12ae3a579418bb550d351fff469573a97ac3c", + "Data/patch-ZE3.MPQ": "b587202c21b6f580682b601b7c2e905fbfa766276e74733db55c1bddbffb7f87", + "Data/patch-ZEA.MPQ": "78fd10ec5262ae2417eba9700d2172553bbc1e5cfb0b597aada8f1753ca46d9d", + "Data/patch-ZHA.MPQ": "0fc6ebeafe1d14192dc64bafc90bde3c49230df1297f98bf65900a3bcf953617", + "Data/patch-ZZE.MPQ": "4b1e289e12c82644cb78f50170e6b5fd53918eb2de834f9e0cebddd81873a8bb", + "Data/patch-ZZR.MPQ": "5a7575e592dc0c8a6913e0d946bbe06fa0ca2e4d817ef1f8548d3d13142bcbd3", + "Ascension.exe": "e2c347019d554a59639e5978fd68e50e906973ab313c6573cc56ca82e8e3c547", + "EsteriaAppearance.bin": "b3fb7c69b7600c39f888ea1925701be5667097dcb02bb6ddd830e5489306bb50", + "EsteriaAppearance.dll": "392c5a2a25f9a69d705bdf841bb899aab768d4a3ee62734d576286c7198710d8", + "EsteriaAppearanceGeometry.bin": "c95c53ca19c53c3434e12b99c1966f9c015ec3944c46dff7784d5e4ffc472d5d", + "EsteriaAppearanceMaterials.bin": "f72f027f54a55c05aa0f76b457b97b484fe0e1aedc0e068cc0e0ca53e1878cba", + "EsteriaCosmeticWings.bin": "57352c28459d073a29b3014d33b70f3b3756afa37fa9583ef6b30d503422ac3f", + "EsteriaEarthen.bin": "8a0db93ba2d5c756b44ed84f2f7bc7c15eef13be6a7d87baf1383c6c0c624e02", + "EsteriaGnoll.bin": "cfacc8e681bb8e54a4f01ee9c12a91bf0bd23a23527bb548d4dc21ec1b33eea2", + "EsteriaGnollTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605", + "EsteriaHaranir.bin": "59f31d00e372fd2646e1cf0d9d3d5d671b31edd60ff7b862f8d1ce7e53ba1f2f", + "EsteriaHaranirTextures.bin": "99948ca570be2d376f0c67e3b8088739b6fa9f9d2c73985e19f68df77689a229", + "EsteriaHighmountain.bin": "e83349252ad2687ac23fcfc5623db7401cf32e5faf47a5a7be20c2f94b014938", + "EsteriaNaga.bin": "e87547cff5d09500c294f8b06935d134cdd0bbb962e294a72de09404505bd8b3", + "EsteriaNagaTextures.bin": "57ec560f99c1d48ac1e8f6a797c156513b99e7d349e3071afe405f812d159ae3", + "EsteriaSaberon.bin": "7735c9325c807c5125760d87f323d6b7d4162fb542c1d8b5f435ea2d6b9a4daa", + "EsteriaSaberonCoverage.bin": "9cdc227f08329e1245d2b864a996e7a772c96ecdbe5d81925f4d60cdbe4fa11e", + "EsteriaSaberonTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605", + "EsteriaSethrak.bin": "bd14857a8c11fdf8027128763293701586b2102f9a6539d33a727014789fd606", + "EsteriaSethrakTextures.bin": "d2b2cf58087a391f00198008fe871edd400ddf28d54a3a05674df74c5198528a", + "EsteriaThinHuman.bin": "75794d192ea0c228b3da7bf7d0bdff1119f79d3f9bfef6650cd5e1a2b5d106eb", + "EsteriaThinHumanTextures.bin": "02aeb4c05893d06125b8b58fdb7c2a56fe3d66a7d094706a44b43cb503d1731e", + "EsteriaTuskarr.bin": "ecbe7ef490ba67eca029a57639192db61238522618491549766fe38529914387", + "EsteriaTuskarrTextures.bin": "e2116c2c83adea079126fd5da765d20da24f044d4f161c4124e4368cb3d6bc71", + "EsteriaVrykul.bin": "39e2b7b4c67997dbbd34bae840bd606a59290f7f2fc82565a0ba00090335415c", + "EsteriaVrykulTextures.bin": "46098750a0e235724c6e9cc8d9d4e26550aa47bef95fd5f0f82efe5e945bf296", + "EsteriaVulpera.bin": "c5434993b9af4a17d881965e4735cd2ac379e39c7142aac9844adff9528562d4", + "EsteriaVulperaTextures.bin": "1ab844e74684aced2b069f1b91cd563aade45e05bd5e23f866a6c0c301cb098e", + "dinput8.dll": "f8de78606c241fb3911dce22faf68f5b9f9885b142b183c1e2c09ddc8f7e380c", + "Data/patch-T.MPQ": "9403e0680c74754eb0cb063718119aa6ace3616f7cecf488c8faf46f22505ae5", + "Data/patch-ZE5.MPQ": "476b5a1fcf69f64fdb281e3b445362b936d016066ccd12f897acfcf200d6c0cf", + "Data/patch-ZE6.MPQ": "762592c9ed950b4a7b6c7d85d45050b30fa3f35d53e348ee1e1516fdbb7e5484", + "Data/patch-ZE7.MPQ": "de54b0189d242763d2872223419a259b6b8a4b380c24b50a2ef9c6fa44466248", + "Data/patch-ZE8.MPQ": "1bc5691fb84a84f3e7a0a70baaa9192a97b4ec5906ee37c85838ffe08adef934", + "Data/patch-ZE9.MPQ": "d49c94463e9548e76378ce2c0454d8f204f0745c8c0de32b6dce7f0b0fd67c4a", + "Data/patch-ZES.MPQ": "a2d9348f917f745c7aa25b2eed96efa312f7b2d2f62cb86a4f2eaabc2dfa895f", + "Data/patch-ZG.MPQ": "48979e1fda775f8deb47bffa48e0a468595d4c71286fee518f51dc5801c5ca39", + "Data/patch-ZH.MPQ": "5e1d2fe287053abba703d7af381e10096c6525ba477ffed3971c2fc4f93e919c", + "Data/patch-ZHM.MPQ": "317d3ae00f0bd674b358af07544976acb7804aa6d8b19d1788ad96030e8c9bf0", + "Data/patch-ZJ.MPQ": "f821e99add86ef68d108c5ec466328c97677d3ed52f7944e200f3fe38c13b4de", + "Data/patch-ZM.MPQ": "424ab1978986e854551633983a94bd15beba6328aabea8c02f98eaa32f6f0b61", + "Data/patch-ZVU.MPQ": "c679b85a2e97344395756b54d2cf6abefe4e80e74944cb55b6e573ad17b7d020" +} From 89c62abf80c1cf2c20c27d1cb78f55f55ec2c559 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: Tue, 13 Oct 2026 07:01:15 +0500 Subject: [PATCH 46/62] fix(bots): align experimental race exclusions with upstream 1.6 --- crates/coa-core/src/custom_races.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/coa-core/src/custom_races.rs b/crates/coa-core/src/custom_races.rs index 1c3ae84..32f2e85 100644 --- a/crates/coa-core/src/custom_races.rs +++ b/crates/coa-core/src/custom_races.rs @@ -60,7 +60,7 @@ pub const EXCLUDED_BOT_RACES_RECOMMENDED: &str = "9,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128"; pub const EXCLUDED_BOT_RACES_EXPERIMENTAL: &str = - "16,19,27,32,50,52,53,65,66,67,68,69,70,71,72,74,77,80,81"; + "65,72,77,80,81"; /// Finds all active playerbots.conf files on this server (root, coa-bots folder, and secondary realm). pub fn playerbots_conf_paths(root: &Path) -> Vec { From c18daac228ae8a7062c4b41d705648504d3ebf7a 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: Tue, 13 Oct 2026 07:02:07 +0500 Subject: [PATCH 47/62] docs(changelog): record supported experimental bot race preset --- changelog.d/20261013-upstream-bot-race-preset.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog.d/20261013-upstream-bot-race-preset.md diff --git a/changelog.d/20261013-upstream-bot-race-preset.md b/changelog.d/20261013-upstream-bot-race-preset.md new file mode 100644 index 0000000..a51228d --- /dev/null +++ b/changelog.d/20261013-upstream-bot-race-preset.md @@ -0,0 +1,7 @@ +--- +area: bots +type: changed +audience: admins +title: The experimental SQUID race preset follows the current race release +--- +Custom-race bots now use the upstream supported-race preset, excluding Test Race, Skyborne and Darkfallen. The standard-races preset continues to exclude all custom races. From 4efc02e768ae8d8cbf5eb5f7ec654ed898c38943 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: Tue, 13 Oct 2026 07:18:03 +0500 Subject: [PATCH 48/62] fix(paths): recognize long Windows rehearsal paths inside server --- changelog.d/20261013-long-rehearsal-paths.md | 7 ++++ crates/coa-core/src/fsx.rs | 42 ++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 changelog.d/20261013-long-rehearsal-paths.md diff --git a/changelog.d/20261013-long-rehearsal-paths.md b/changelog.d/20261013-long-rehearsal-paths.md new file mode 100644 index 0000000..aaa5593 --- /dev/null +++ b/changelog.d/20261013-long-rehearsal-paths.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Server updates accept long Windows paths inside the private test server +--- +Long SQL file paths no longer fail the folder safety check during update rehearsals. Files outside the server folder remain blocked. diff --git a/crates/coa-core/src/fsx.rs b/crates/coa-core/src/fsx.rs index 47925a1..57fd070 100644 --- a/crates/coa-core/src/fsx.rs +++ b/crates/coa-core/src/fsx.rs @@ -28,6 +28,19 @@ pub fn normalize_lexical(path: &Path) -> PathBuf { fn key(path: &Path) -> Vec { path.components() .map(|c| { + #[cfg(windows)] + if let Component::Prefix(prefix) = c { + use std::path::Prefix; + match prefix.kind() { + Prefix::Disk(drive) | Prefix::VerbatimDisk(drive) => { + return format!("{}:", char::from(drive).to_ascii_lowercase()); + } + Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => { + return format!("\\\\{}\\{}", server.to_string_lossy(), share.to_string_lossy()).to_lowercase(); + } + _ => {} + } + } let c = c.as_os_str().to_string_lossy(); if cfg!(windows) { c.to_lowercase() } else { c.into_owned() } }) @@ -262,6 +275,35 @@ mod tests { } } + #[cfg(windows)] + #[test] + fn containment_matches_verbatim_drive_and_unc_prefixes() { + assert!(starts_with_ci(Path::new(r"\\?\C:\server\Extras\file.sql"), Path::new(r"C:\server"))); + assert!(starts_with_ci(Path::new(r"C:\server\Extras\file.sql"), Path::new(r"\\?\c:\server"))); + assert!(starts_with_ci(Path::new(r"\\?\UNC\host\share\server\file.sql"), Path::new(r"\\host\share\server"))); + assert!(!starts_with_ci(Path::new(r"\\?\D:\server\file.sql"), Path::new(r"C:\server"))); + assert!(!starts_with_ci(Path::new(r"\\?\C:\server-other\file.sql"), Path::new(r"C:\server"))); + assert!(!starts_with_ci(Path::new(r"\\?\UNC\host\other\server\file.sql"), Path::new(r"\\host\share\server"))); + } + + #[cfg(windows)] + #[test] + fn ensure_within_accepts_long_rehearsal_sql_path() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("server"); + fs::create_dir_all(&root).unwrap(); + let mut candidate = root.clone(); + for _ in 0..5 { + candidate.push("long-private-rehearsal-directory-name-20261013"); + } + fs::create_dir_all(&candidate).unwrap(); + candidate.push("2026_06_13_00_ai_playerbot_missing_group_buff_reagent.sql"); + fs::write(&candidate, b"SELECT 1;").unwrap(); + assert!(candidate.as_os_str().len() > 260); + assert!(ensure_within(&root, &candidate).is_ok()); + assert!(ensure_within(&root, &std::fs::canonicalize(candidate).unwrap()).is_ok()); + } + #[test] fn ensure_within_blocks_junction_escape() { if !cfg!(windows) { From 6a955bd670c13cf318e7c1e655dc59323cc0a9b1 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: Tue, 13 Oct 2026 17:12:18 +0500 Subject: [PATCH 49/62] Release CoA Server Manager 0.6.16 --- Cargo.lock | 6 +++--- Cargo.toml | 2 +- package-lock.json | 4 ++-- package.json | 2 +- src-tauri/tauri.conf.json | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a464eb1..c1e2041 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -491,7 +491,7 @@ dependencies = [ [[package]] name = "coa-core" -version = "0.6.15" +version = "0.6.16" dependencies = [ "base64 0.22.1", "chrono", @@ -520,7 +520,7 @@ dependencies = [ [[package]] name = "coa-release" -version = "0.6.15" +version = "0.6.16" dependencies = [ "chrono", "coa-core", @@ -529,7 +529,7 @@ dependencies = [ [[package]] name = "coa-server-manager" -version = "0.6.15" +version = "0.6.16" dependencies = [ "coa-core", "serde", diff --git a/Cargo.toml b/Cargo.toml index a31fa76..e323c0d 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.15" +version = "0.6.16" edition = "2021" license = "AGPL-3.0-only" diff --git a/package-lock.json b/package-lock.json index b6f48b0..3ff139c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coa-server-manager", - "version": "0.6.15", + "version": "0.6.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coa-server-manager", - "version": "0.6.15", + "version": "0.6.16", "dependencies": { "@tauri-apps/api": "^2.12.0", "@tauri-apps/plugin-dialog": "^2.8.0", diff --git a/package.json b/package.json index 6402cd0..c0f763e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coa-server-manager", "private": true, - "version": "0.6.15", + "version": "0.6.16", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 52de8b2..7b3cf2a 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.15", + "version": "0.6.16", "identifier": "com.coa.servermanager", "build": { "beforeDevCommand": "npm run dev", From 482bbfcdfd9e5cf24ebeb8a23fa9b1a02b434a51 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: Wed, 14 Oct 2026 06:52:24 +0500 Subject: [PATCH 50/62] Fix repair completion and expose SQUID and rehearsal failures --- ...pdate-checks-show-preparation-stages-an.md | 7 ++ ...hecks-report-interrupted-squid-database.md | 7 ++ ...ul-server-repairs-no-longer-block-later.md | 7 ++ crates/coa-core/src/driver.rs | 10 +- crates/coa-core/src/mysql_snapshot.rs | 67 ++++++++++-- crates/coa-core/src/release_schema.rs | 1 + crates/coa-core/src/repair.rs | 4 +- crates/coa-core/src/squid.rs | 56 ++++++++++ crates/coa-core/src/update.rs | 101 +++++++++++++++++- docs/STABILIZATION-20261014.md | 53 +++++++++ tools/server-update-refresh.test.mjs | 18 +++- 11 files changed, 318 insertions(+), 13 deletions(-) create mode 100644 changelog.d/20261014-private-update-checks-show-preparation-stages-an.md create mode 100644 changelog.d/20261014-server-checks-report-interrupted-squid-database.md create mode 100644 changelog.d/20261014-successful-server-repairs-no-longer-block-later.md create mode 100644 docs/STABILIZATION-20261014.md diff --git a/changelog.d/20261014-private-update-checks-show-preparation-stages-an.md b/changelog.d/20261014-private-update-checks-show-preparation-stages-an.md new file mode 100644 index 0000000..71cef60 --- /dev/null +++ b/changelog.d/20261014-private-update-checks-show-preparation-stages-an.md @@ -0,0 +1,7 @@ +--- +area: manager +type: changed +audience: admins +title: Private update checks show preparation stages and stop failure details +--- +Private-copy preparation reports checked and copied file counts, database tests and startup stages. Failed shutdown reports the server folder, remaining processes and launcher reason. Port conflicts identify the owning process and executable. diff --git a/changelog.d/20261014-server-checks-report-interrupted-squid-database.md b/changelog.d/20261014-server-checks-report-interrupted-squid-database.md new file mode 100644 index 0000000..77e0e07 --- /dev/null +++ b/changelog.d/20261014-server-checks-report-interrupted-squid-database.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Server checks report interrupted SQUID database imports +--- +Check and Repair now report SQUID pending imports and missing bot character tables separately from package migrations. Interrupted imports retain their records and require verified database recovery. diff --git a/changelog.d/20261014-successful-server-repairs-no-longer-block-later.md b/changelog.d/20261014-successful-server-repairs-no-longer-block-later.md new file mode 100644 index 0000000..24bca4c --- /dev/null +++ b/changelog.d/20261014-successful-server-repairs-no-longer-block-later.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Successful server repairs no longer block later server actions +--- +Repair verifies restored files before completing its recovery record. Older completed repair records are accepted only when every repaired file still matches its expected content. diff --git a/crates/coa-core/src/driver.rs b/crates/coa-core/src/driver.rs index 636ecfa..c5540c4 100644 --- a/crates/coa-core/src/driver.rs +++ b/crates/coa-core/src/driver.rs @@ -180,7 +180,7 @@ fn run_inner(root: &Path, verb: Verb, validating_update: bool) -> Result (st.success(), st.code()), None => (false, None), @@ -195,6 +195,14 @@ fn run_inner(root: &Path, verb: Verb, validating_update: bool) -> Result Some(other), } }; + if code == Some(ErrorCode::PortInUse) { + let observed = crate::process::observe(&root, &crate::layout::read_ports(&root)); + for service in [&observed.mysql, &observed.auth, &observed.world].into_iter().chain(observed.secondary_world.iter()) { + if let Some(conflict) = &service.conflict { + output.push_str(&format!("\nPort {} is used by process {} ({}).", conflict.port, conflict.pid, conflict.exe.as_deref().unwrap_or("executable unavailable"))); + } + } + } tracing::info!(?verb, ok, ?code, "driver: launcher verb finished"); if !ok && !output.is_empty() { let redacted = crate::diag::redact(&output); diff --git a/crates/coa-core/src/mysql_snapshot.rs b/crates/coa-core/src/mysql_snapshot.rs index ea14db9..14b8cae 100644 --- a/crates/coa-core/src/mysql_snapshot.rs +++ b/crates/coa-core/src/mysql_snapshot.rs @@ -129,6 +129,10 @@ pub(crate) fn inventory(root: &Path) -> Result> { } pub(crate) fn copy_verified(source: &Path, target: &Path, files: &BTreeMap) -> Result<()> { + copy_verified_with_progress(source, target, files, &|_| {}) +} + +fn copy_verified_with_progress(source: &Path, target: &Path, files: &BTreeMap, report: &dyn Fn(&str)) -> 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)?)?; @@ -138,13 +142,15 @@ pub(crate) fn copy_verified(source: &Path, target: &Path, files: &BTreeMap 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 outcome = crate::driver::run(root, crate::driver::Verb::StopAll) + .map_err(|error| Error::Invalid(format!("Could not stop server {} before copying or restoring MySQL: {error}", root.display())))?; 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())); + return Err(Error::Invalid(stop_failure(root, &status, &outcome))); } Ok(()) } +fn stop_failure(root: &Path, status: &crate::process::Observed, outcome: &crate::driver::DriverOutcome) -> String { + let mut details = vec![format!("The complete server must be stopped before copying or restoring MySQL. Server: {}.", root.display())]; + for service in [&status.mysql, &status.auth, &status.world].into_iter().chain(status.secondary_world.iter()) { + if service.state != crate::process::ServiceState::Stopped { + details.push(format!("{}: {:?}; PID: {}; port: {}.", service.name, service.state, service.pid.map(|pid| pid.to_string()).unwrap_or_else(|| "unknown".into()), service.port)); + } + if let Some(conflict) = &service.conflict { + details.push(format!("Port {} is used by process {} ({}).", conflict.port, conflict.pid, conflict.exe.as_deref().unwrap_or("executable unavailable"))); + } + } + let output = crate::diag::redact(&outcome.output); + let lines: Vec<_> = output.lines().rev().take(12).collect(); + if !lines.is_empty() { details.push(lines.into_iter().rev().collect::>().join("\n")); } + else if !outcome.ok { details.push(format!("The stop command failed (exit code: {:?}).", outcome.exit_code)); } + details.join("\n") +} + pub(crate) fn stop_previous_rehearsals(meta: &Path) -> Result<()> { let location = meta.join("rehearsals"); if !location.exists() { return Ok(()); } @@ -226,7 +250,8 @@ pub(crate) fn verify(point: &Path, snapshot: &Snapshot) -> Result<()> { Ok(()) } -pub(crate) fn rehearsal_copy(root: &Path, meta: &Path, point: &Path, snapshot: &Snapshot) -> Result { +pub(crate) fn rehearsal_copy_with_progress(root: &Path, meta: &Path, point: &Path, snapshot: &Snapshot, report: &dyn Fn(&str)) -> Result { + report("Verifying the database recovery copy before rehearsal"); verify(point, snapshot)?; let canonical = fsx::canonicalize_lenient(root)?; let root = canonical.as_path(); @@ -249,11 +274,15 @@ pub(crate) fn rehearsal_copy(root: &Path, meta: &Path, point: &Path, snapshot: & 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 if metadata.is_file() { + files.insert(relative, fsx::sha256_file(&path)?); + if files.len() % 250 == 0 { report(&format!("Preparing the private copy: {} files checked", files.len())); } + } else { return Err(Error::Invalid("Unsupported rehearsal file.".into())); } } } - copy_verified(root, &destination, &files)?; + copy_verified_with_progress(root, &destination, &files, report)?; + report("Restoring MySQL into the private server copy"); copy_snapshot(point, snapshot, &destination.join("mysql/data"))?; let (_, mut installed) = crate::registry::MetaDir::open(meta)?; installed.id = uuid::Uuid::new_v4().to_string(); @@ -338,6 +367,17 @@ pub(crate) fn swap(root: &Path, meta: &Path, point: &Path, snapshot: &Snapshot, #[cfg(test)] mod tests { use super::*; + #[test] + fn failed_stop_identifies_the_server_process_and_retains_redacted_launcher_reason() { + use crate::process::{Observed, ServiceStatus, ServiceState}; + let stopped = |name| ServiceStatus { name, state: ServiceState::Stopped, pid: None, port: 0, port_ready: false, conflict: None, uptime_secs: None }; + let status = Observed { mysql: ServiceStatus { state: ServiceState::Stopping, pid: Some(42), port: 3307, ..stopped("mysql") }, auth: stopped("auth"), world: stopped("world"), secondary_world: None }; + let outcome = crate::driver::DriverOutcome { ok: false, exit_code: Some(1), code: None, human: None, output: "MySQL is still stopping; wait before copying the repack.\nrootPassword=hunter2".into() }; + let message = stop_failure(Path::new("private-rehearsal/server"), &status, &outcome); + assert!(message.contains("private-rehearsal") && message.contains("PID: 42") && message.contains("3307")); + assert!(message.contains("MySQL is still stopping")); + assert!(!message.contains("hunter2")); + } #[cfg(windows)] #[test] #[ignore = "requires COA_MYSQL_TEST_BIN pointing to the bundled MySQL tools"] @@ -373,6 +413,14 @@ mod tests { 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); + db.query("CREATE TABLE acore_characters.coa_squid_pending(path VARCHAR(255), sha256 CHAR(64)); INSERT INTO acore_characters.coa_squid_pending VALUES('playerbots/base/ai_playerbot_texts.sql', REPEAT('a',64));").unwrap(); + let squid_problems = crate::squid::database_problems(&root, crate::realms::Mode::Coa, &|sql| { + assert!(sql.starts_with("SELECT ")); + db.query(sql) + }).unwrap(); + assert_eq!(squid_problems.len(), 1); + assert!(squid_problems[0].detail.contains("interrupted")); + assert_eq!(db.query("SELECT COUNT(*) FROM acore_characters.coa_squid_pending;").unwrap(), "1"); 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(); @@ -486,8 +534,13 @@ mod tests { fsx::atomic_write(&root.join("Core/worldserver.exe"), b"exe").unwrap(); fsx::atomic_write(&root.join("Core/configs/worldserver.conf"), b"conf").unwrap(); fsx::atomic_write(&root.join("Settings/repack.json"), b"{}").unwrap(); - let dest = rehearsal_copy(&root, &meta, &point, &snapshot).unwrap(); + let phases = std::cell::RefCell::new(Vec::new()); + let dest = rehearsal_copy_with_progress(&root, &meta, &point, &snapshot, &|phase| phases.borrow_mut().push(phase.to_string())).unwrap(); assert_eq!(fs::read(dest.join("Core/configs/worldserver.conf")).unwrap(), b"conf"); assert_eq!(fs::read(dest.join("mysql/data/characters.ibd")).unwrap(), b"saved"); + let phases = phases.into_inner(); + assert!(phases[0].contains("Verifying the database")); + assert!(phases.iter().any(|phase| phase.contains("Copying private server files"))); + assert!(phases.last().unwrap().contains("Restoring MySQL")); } } diff --git a/crates/coa-core/src/release_schema.rs b/crates/coa-core/src/release_schema.rs index 28f1994..a9bde6d 100644 --- a/crates/coa-core/src/release_schema.rs +++ b/crates/coa-core/src/release_schema.rs @@ -14,6 +14,7 @@ impl crate::update::Env for QualificationEnv<'_> { 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 rehearse_with_progress(&self, manifest: &crate::manifest::Manifest, tree: &Path, items: &[crate::update::PlanItem], point: &str, report: &dyn Fn(&str, u8)) -> Result<()> { self.0.rehearse_with_progress(manifest, tree, items, point, report) } 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() } diff --git a/crates/coa-core/src/repair.rs b/crates/coa-core/src/repair.rs index d97672e..9ca4a0e 100644 --- a/crates/coa-core/src/repair.rs +++ b/crates/coa-core/src/repair.rs @@ -30,7 +30,9 @@ pub fn check(root: &Path, dir: &Path) -> Result> { for mode in modes { let db = db.clone().for_realm(mode); let migrations: Vec<_> = m.migrations.iter().filter(|m| mode == crate::realms::Mode::Coa || m.db != "auth").cloned().collect(); - checks.push(DatabaseCheck { realm: mode, migrations: crate::migrations::status(&db, &migrations)?, problems: crate::schema_check::check(&db, root)?, full_schema: root.join(crate::schema_check::CONTRACT).exists() }); + let mut problems = crate::schema_check::check(&db, root)?; + problems.extend(crate::squid::database_problems(root, mode, &|sql| db.query(sql))?); + checks.push(DatabaseCheck { realm: mode, migrations: crate::migrations::status(&db, &migrations)?, problems, full_schema: root.join(crate::schema_check::CONTRACT).exists() }); } Ok(checks) })?; diff --git a/crates/coa-core/src/squid.rs b/crates/coa-core/src/squid.rs index 67222b0..0c3f09d 100644 --- a/crates/coa-core/src/squid.rs +++ b/crates/coa-core/src/squid.rs @@ -1,6 +1,35 @@ use std::path::Path; use serde::{Deserialize, Serialize}; +/// SQUID has its own import ledger, outside the signed package migration ledger. +/// Keep this read-only: a pending base import can contain player data and must not +/// be replayed or cleared merely because the normal schema contract passes. +pub(crate) fn database_problems(root: &Path, mode: crate::realms::Mode, query: &impl Fn(&str) -> crate::Result) -> crate::Result> { + let active = root.join("Core/configs/modules/playerbots.conf"); + let config = if active.is_file() { active } else { root.join("Core/configs/modules/playerbots.conf.dist") }; + let enabled = if config.is_file() { + let conf = crate::config::parser::ConfFile::parse_bytes(&std::fs::read(config)?)?; + !matches!(conf.get("AiPlayerbot.Enabled").unwrap_or("1").trim().trim_matches('"').to_ascii_lowercase().as_str(), "0" | "false" | "no" | "off") + } else { false }; + let mut problems = Vec::new(); + for schema in [mode.schema("characters")?, mode.schema("world")?, "acore_playerbots"] { + let tables: std::collections::BTreeSet<_> = query(&format!("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA='{schema}';"))?.lines().map(str::to_owned).collect(); + if tables.contains("coa_squid_pending") { + for pending in query(&format!("SELECT path FROM `{schema}`.coa_squid_pending;"))?.lines().filter(|path| !path.is_empty()) { + problems.push(crate::schema_check::Problem { database: schema.into(), table: "coa_squid_pending".into(), column: String::new(), detail: format!("SQUID base import was interrupted: {pending}. File repair and package SQL updates cannot complete this import. Restore a verified database backup from before the import; do not delete the pending record or replay base SQL.") }); + } + } + if enabled && schema == mode.schema("characters")? { + for table in ["playerbots_names", "playerbots_guild_names", "playerbots_arena_team_names"] { + if !tables.contains(table) { + problems.push(crate::schema_check::Problem { database: schema.into(), table: table.into(), column: String::new(), detail: "Required SQUID character table is missing. A normal server start provisions it when no interrupted import is present; an interrupted import requires database recovery first.".into() }); + } + } + } + } + Ok(problems) +} + #[derive(Debug, Serialize, PartialEq, Eq)] pub struct Release { pub tag: Option, @@ -129,6 +158,33 @@ pub fn validate(field: &Field, value: &str) -> crate::Result<()> { mod tests { use super::*; #[test] + fn database_check_reports_pending_squid_imports_in_each_realm_without_writes() { + let root = tempfile::tempdir().unwrap(); + for mode in [crate::realms::Mode::Coa, crate::realms::Mode::Wildcard] { + let problems = database_problems(root.path(), mode, &|sql| { + assert!(sql.starts_with("SELECT ")); + if sql.contains("information_schema.TABLES") { Ok("coa_squid_pending".into()) } + else { Ok("playerbots/base/ai_playerbot_texts.sql".into()) } + }).unwrap(); + assert_eq!(problems.len(), 3); + assert_eq!(problems[0].database, mode.schema("characters").unwrap()); + assert!(problems.iter().all(|p| p.detail.contains("interrupted"))); + } + } + + #[test] + fn enabled_squid_requires_character_tables_but_disabled_squid_does_not() { + let root = tempfile::tempdir().unwrap(); + let config = root.path().join("Core/configs/modules/playerbots.conf"); + crate::fsx::atomic_write(&config, b"AiPlayerbot.Enabled = 1\n").unwrap(); + let query = |_: &str| Ok(String::new()); + assert_eq!(database_problems(root.path(), crate::realms::Mode::Wildcard, &query).unwrap().len(), 3); + crate::fsx::atomic_write(&config, b"AiPlayerbot.Enabled = 0\n").unwrap(); + assert!(database_problems(root.path(), crate::realms::Mode::Wildcard, &query).unwrap().is_empty()); + crate::fsx::atomic_write(&config, b"AiPlayerbot.Enabled = 1\n").unwrap(); + assert!(database_problems(root.path(), crate::realms::Mode::Coa, &|_| Ok("playerbots_names\nplayerbots_guild_names\nplayerbots_arena_team_names".into())).unwrap().is_empty()); + } + #[test] fn a_setting_with_choices_only_accepts_one_of_them() { let root = tempfile::tempdir().unwrap(); crate::fsx::atomic_write(&root.path().join("Core/configs/modules/playerbots.conf.settings.json"), br#"{"format":1,"settings":[ diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index a523540..f6d224b 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -93,6 +93,7 @@ pub trait Env { 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(()) } + fn rehearse_with_progress(&self, manifest: &Manifest, tree: &Path, items: &[PlanItem], point: &str, _report: &dyn Fn(&str, u8)) -> Result<()> { self.rehearse(manifest, tree, items, point) } /// Create a recovery point for the databases and configuration; returns its id. fn snapshot(&self) -> Result; fn restore_snapshot(&self, id: &str) -> Result<()>; @@ -121,13 +122,32 @@ pub fn load(meta: &Path, id: &str) -> Result { } fn read_journal(path: &Path) -> Result { - let txn: Txn = fsx::read_json(path).map_err(|e| Error::Invalid(format!("Update journal {} cannot be read: {e}. Available recovery copies were preserved.", path.display())))?; + let mut txn: Txn = fsx::read_json(path).map_err(|e| Error::Invalid(format!("Update journal {} cannot be read: {e}. Available recovery copies were preserved.", path.display())))?; if path.parent().and_then(Path::file_name).and_then(|id| id.to_str()) != Some(txn.id.as_str()) { return Err(Error::Invalid("Update journal identity is inconsistent.".into())); } txn_dir(path.parent().unwrap(), &txn.id)?; if let Some(id) = &txn.recovery_point { if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) || id.contains("..") { return Err(Error::Invalid("Update journal contains an invalid recovery point identity.".into())); } } let valid_hash = |hash: &str| hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()); + let mut completed_legacy_repair = false; + // Older successful repairs saved Committed without setting done. Recover only that + // specific shape, and only if every repaired file still has its expected bytes. + if txn.state == State::Committed && txn.id.contains("-repair-") + && txn.message.as_deref() == Some("Repair is in progress.") + && txn.from_version.as_deref() == Some(txn.to_version.as_str()) + && txn.databases_started && txn.recovery_point.is_some() && txn.recovery_hashes.contains_key("@install") + && txn.ops.iter().any(|op| !op.done) + && txn.ops.iter().all(|op| op.started && matches!((op.action, op.had_previous), (Action::Create, false) | (Action::Replace, true)) && valid_hash(&op.new_sha256)) { + let meta_dir = path.parent().and_then(Path::parent).and_then(Path::parent) + .ok_or_else(|| Error::Invalid("Invalid repair journal location.".into()))?; + let (_, installed) = MetaDir::open(meta_dir)?; + let root = Path::new(&installed.server_path); + if fsx::canonicalize_lenient(&crate::registry::metadata_dir_for(root)?)? != fsx::canonicalize_lenient(meta_dir)? { + return Err(Error::Invalid("The repair journal does not belong to its installed server.".into())); + } + complete_repair_ops(root, &mut txn)?; + completed_legacy_repair = true; + } let mut seen = std::collections::BTreeSet::new(); for op in &txn.ops { fsx::safe_join(path.parent().unwrap(), &op.path)?; @@ -140,6 +160,13 @@ fn read_journal(path: &Path) -> Result { } if txn.recovery_hashes.values().any(|hash| !valid_hash(hash)) { return Err(Error::Invalid("Update journal contains invalid recovery hashes.".into())); } for key in txn.recovery_hashes.keys().filter(|key| !matches!(key.as_str(), "@install" | "@point")) { fsx::safe_join(path.parent().unwrap(), key)?; } + if completed_legacy_repair { + // Persist the verified completion once: later legitimate updates can change + // these files. Retain the original record for diagnostics and crash recovery. + let original = path.with_file_name("txn-before-repair-completion.json"); + if !original.exists() { fsx::atomic_write(&original, &fs::read(path)?)?; } + fsx::atomic_write_json(path, &txn)?; + } Ok(txn) } @@ -499,7 +526,7 @@ pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result { 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()) { + if let Err(error) = p.env.rehearse_with_progress(&m, &tree, &items, txn.recovery_point.as_deref().unwrap(), report) { txn.state = State::Failed; txn.message = Some(format!("The private update rehearsal failed: {error}. The installed server was not updated.")); save(meta_dir, &txn)?; @@ -834,10 +861,22 @@ pub(crate) fn repair_migrating(meta_dir: &Path, txn: &mut Txn) -> Result<()> { pub(crate) fn finish_repair(root: &Path, meta_dir: &Path, txn: &mut Txn, success: bool, env: &dyn Env) -> Result<()> { if !success { restore_transaction(root, meta_dir, &txn_dir(meta_dir, &txn.id)?.join("before"), txn, env)?; } + if success { complete_repair_ops(root, txn)?; } txn.state = if success { State::Committed } else { State::RolledBack }; save(meta_dir, txn) } +fn complete_repair_ops(root: &Path, txn: &mut Txn) -> Result<()> { + for op in &txn.ops { + let target = fsx::ensure_within(root, &fsx::safe_join(root, &op.path)?)?; + if fsx::sha256_file(&target)? != op.new_sha256 { + return Err(Error::Invalid(format!("Repaired file {} failed verification. Its recovery record was preserved.", op.path))); + } + } + for op in &mut txn.ops { op.done = true; } + Ok(()) +} + fn prune(meta_dir: &Path, keep: usize) { let Ok(rd) = fs::read_dir(updates_dir(meta_dir)) else { return }; let mut dirs: Vec<_> = rd.flatten().filter(|e| read_journal(&e.path().join("txn.json")).is_ok_and(|t| matches!(t.state, State::Committed | State::RolledBack))).map(|e| e.path()).collect(); @@ -939,6 +978,10 @@ impl Env for RepackEnv<'_> { fn automatic_rollback(&self) -> bool { true } fn rehearse(&self, manifest: &Manifest, tree: &Path, items: &[PlanItem], id: &str) -> Result<()> { + self.rehearse_with_progress(manifest, tree, items, id, &|_, _| {}) + } + + fn rehearse_with_progress(&self, manifest: &Manifest, tree: &Path, items: &[PlanItem], id: &str, report: &dyn Fn(&str, u8)) -> 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())); } @@ -951,7 +994,7 @@ impl Env for RepackEnv<'_> { 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 fixture = crate::mysql_snapshot::rehearsal_copy_with_progress(self.root, self.meta_dir, &point_dir, snapshot, &|phase| step(report, phase, 54))?; let metadata = crate::registry::metadata_dir_for(&fixture)?; let env = RepackEnv { root: &fixture, meta_dir: &metadata }; let result = crate::install::with_scratch_ports(&fixture, || { @@ -964,9 +1007,11 @@ impl Env for RepackEnv<'_> { }).collect(), message:None, recovery_hashes:BTreeMap::new() }; let before = txn_dir(&metadata, &txn.id)?.join("before"); fs::create_dir_all(&before)?; + step(report, "Applying update files to the private server copy", 55); 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)?; + step(report, "Testing database updates on the private server copy", 56); let identities = crate::backup::with_database(&fixture, |db| { let identities = database_identities(db)?; let realms = crate::realms::state(&fixture)?; @@ -983,10 +1028,12 @@ impl Env for RepackEnv<'_> { verify_database_identities(db, &identities)?; Ok(identities) })?; + step(report, "Testing startup of the private server copy", 57); env.validate()?; crate::backup::with_database(&fixture, |db| verify_database_identities(db, &identities))?; env.ensure_stopped() }); + step(report, "Stopping the private server copy and verifying the installed database", 57); 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() @@ -1876,6 +1923,54 @@ mod tests { assert_eq!(read(&w, "Core/worldserver.exe"), b"world-v1"); } + #[test] + fn legacy_completed_repair_is_accepted_only_with_verified_repaired_files() { + let w = world(&[], false); + let files = vec![FileEntry { path: "Core/worldserver.exe".into(), sha256: fsx::sha256_bytes(b"repaired"), size: 8, owner: crate::manifest::Owner::Core, policy: ReplacePolicy::Replace }]; + let mut txn = begin_repair(&w.root, &w.meta, "1.0.0", "full-backup", &files).unwrap(); + write(&w.root, "Core/worldserver.exe", b"repaired"); + repair_migrating(&w.meta, &mut txn).unwrap(); + txn.state = State::Committed; + save(&w.meta, &txn).unwrap(); + let original = fs::read(txn_dir(&w.meta, &txn.id).unwrap().join("txn.json")).unwrap(); + write(&w.root, "Core/worldserver.exe", b"changed"); + assert!(ensure_recovered(&w.meta).is_err()); + write(&w.root, "Core/worldserver.exe", b"repaired"); + ensure_recovered(&w.meta).unwrap(); + assert!(load(&w.meta, &txn.id).unwrap().ops[0].done); + assert_eq!(fs::read(txn_dir(&w.meta, &txn.id).unwrap().join("txn-before-repair-completion.json")).unwrap(), original); + write(&w.root, "Core/worldserver.exe", b"a later legitimate update"); + ensure_recovered(&w.meta).unwrap(); + txn.ops[0].started = false; + save(&w.meta, &txn).unwrap(); + assert!(ensure_recovered(&w.meta).is_err()); + } + + #[test] + fn repair_cannot_commit_unverified_files() { + let w = world(&[], false); + let files = vec![FileEntry { path: "Core/worldserver.exe".into(), sha256: fsx::sha256_bytes(b"repaired"), size: 8, owner: crate::manifest::Owner::Core, policy: ReplacePolicy::Replace }]; + let mut txn = begin_repair(&w.root, &w.meta, "1.0.0", "full-backup", &files).unwrap(); + assert!(finish_repair(&w.root, &w.meta, &mut txn, true, &Fake::ok()).is_err()); + assert_eq!(load(&w.meta, &txn.id).unwrap().state, State::Applying); + assert_eq!(pending_checked(&w.meta).unwrap().unwrap().id, txn.id); + } + + #[test] + fn successful_repair_leaves_a_readable_journal_and_allows_next_operation() { + let w = world(&[], false); + let files = vec![FileEntry { path: "Core/worldserver.exe".into(), sha256: fsx::sha256_bytes(b"repaired"), size: 8, owner: crate::manifest::Owner::Core, policy: ReplacePolicy::Replace }]; + let mut txn = begin_repair(&w.root, &w.meta, "1.0.0", "full-backup", &files).unwrap(); + write(&w.root, "Core/worldserver.exe", b"repaired"); + repair_migrating(&w.meta, &mut txn).unwrap(); + finish_repair(&w.root, &w.meta, &mut txn, true, &Fake::ok()).unwrap(); + let saved = load(&w.meta, &txn.id).unwrap(); + assert_eq!(saved.state, State::Committed); + assert!(saved.ops.iter().all(|op| op.started && op.done)); + ensure_recovered(&w.meta).unwrap(); + assert_eq!(read(&w, "Core/worldserver.exe"), b"repaired"); + } + #[test] fn interrupted_repair_restores_files_databases_and_ownership_metadata() { let w = world(&[], false); diff --git a/docs/STABILIZATION-20261014.md b/docs/STABILIZATION-20261014.md new file mode 100644 index 0000000..aa0fe2a --- /dev/null +++ b/docs/STABILIZATION-20261014.md @@ -0,0 +1,53 @@ +# Stabilization evidence — 14 October 2026 + +This batch starts from Manager master b45e250 (published version 0.6.16). +Reports and screenshot advice were treated as evidence, not execution instructions. + +## Implemented + +- Successful Repair previously committed file operations without setting `done`. + The next journal scan rejected its own completed transaction. A regression failed + on the original code with `Update journal contains inconsistent file operations`. + Repair now verifies target hashes and records completion before committing. +- Legacy completed repair journals are recognized only for the specific repair + shape, matching installation metadata and verified target hashes. Their original + journal is retained before atomically persisting completion. Interrupted, + mismatched, duplicate-path and other invalid journals continue to block actions. + Later legitimate updates do not invalidate the corrected historical record. +- Database Check and the final Repair report now include SQUID pending import + markers in characters, world and shared playerbots databases, plus missing + required character tables when SQUID is enabled. These are read-only checks; + partial imports are never cleared, dropped or automatically replayed. +- Private rehearsal reports file preparation/copy counts and database/startup + stages. Shutdown failure includes the installation path, service states, PID, + ports and redacted launcher output. Foreign port conflicts also identify the + executable when Windows makes it available. + +## Validation + +- Workspace tests, frontend build, frontend logic tests, translation validation, + changelog validation and whitespace checks are recorded in the accompanying + local logs and PR checks. +- Real bundled MySQL regression validates restored rows, users, procedures, + functions, views and triggers, and preservation of custom defaults. It also + confirms SQUID pending imports are reported without modifying their records. +- Published Content Scaling revision e5d8c675 passes the production-budget + regression for 40/100 HP, core multipliers, rank rates, groups and repeated + calculation. This is a code regression, not an in-game Wildcard/PTR comparison. + +## Outstanding evidence and release gates + +| Report | Current evidence | Next verification | +| --- | --- | --- | +| Manager #98 | Repair journal defect reproduced; report itself has no diagnostic ZIP | Reproduce the complete Custom Races workflow and verify existing affected journal recovery | +| Screenshot: SQUID Start fails but Repair reports zero | Separate SQUID ledger was absent from Repair checks | Retest with a copy of the affected database; recover from a verified pre-import backup | +| Manager #82 / screenshot lock messages | #90 and #91 are already in published 0.6.16; lock regressions pass | Distinguish an active slow check from abandoned maintenance using diagnostics | +| Manager #100 / 54% | Attached archive ends immediately after entering private rehearsal; no failure is captured | Measure the full private-copy run on the affected hardware; new progress identifies its stage | +| Screenshot: stopped-MySQL error | Generic error hid launcher/process details | Reproduce with new shutdown diagnostics; do not assume a timeout or kill another installation | +| Manager #25 | Missing defaults have targeted repair; start-data incompatibilities remain separate | Retest imported schema and affected race/class data without suppressing incompatibility checks | +| Manager #54 | Targeted schedule SQL fix is published; recovery placeholders are a separate report | Reproduce placeholder recovery from the exact transaction/configuration | +| Scaling screenshot | 2.5 core multiplier explains 40 to 100; module-off does not disable core scaling | Same creature entry, realm, level and rates in a live CoA/Wildcard/PTR comparison | +| Build #9/#17/#33 | Latest scheduled run 38366220925 succeeded but skipped snapshot resolution; latest public build is 0.261013.54 | Qualify a new integrated candidate; old issue state is not current release evidence | + +This batch does not publish a Manager installer, promote a server package, +close user reports or claim the complete fresh-install/update/gameplay matrix. diff --git a/tools/server-update-refresh.test.mjs b/tools/server-update-refresh.test.mjs index dde2c9d..1eaea60 100644 --- a/tools/server-update-refresh.test.mjs +++ b/tools/server-update-refresh.test.mjs @@ -5,17 +5,21 @@ import { registerHooks } from "node:module"; let applyFailure = false; let stateFailure = false; let pending = null; +let serverRunning = true; const calls = []; globalThis.__hotfixApi = { + status: async () => ({ observed: Object.fromEntries(["mysql", "auth", "world"].map(key => [key, { state: serverRunning ? "running" : "stopped" }])) }), + stop: async () => { calls.push("stop"); }, applyUpdate: async () => { calls.push("apply"); if (applyFailure) throw new Error("SQL failure"); return { txn: { state: "committed", to_version: "2" } }; }, pendingUpdate: async () => { calls.push("disk"); if (stateFailure) throw new Error("damaged journal"); return pending; }, checkUpdate: async () => ({ from_version: "2", to_version: "2", items: [], conflicts: [], migrations: 1, pending_migrations: 0 }), }; registerHooks({ resolve(specifier, context, next) { - if (specifier === "react") return { shortCircuit: true, url: "data:text/javascript,export const useSyncExternalStore=(subscribe,getSnapshot)=>getSnapshot();" }; + if (specifier === "react") return { shortCircuit: true, url: "data:text/javascript,export const useSyncExternalStore=(subscribe,getSnapshot)=>getSnapshot();export const useState=()=>{throw new Error('React state is not used in this test');};" }; if (specifier === "@/lib/api") return { shortCircuit: true, url: "data:text/javascript,export const api=globalThis.__hotfixApi;export const asUiError=e=>({human:{code:'unknown'},technical:e.message});" }; if (specifier === "@tauri-apps/api/event") return { shortCircuit: true, url: "data:text/javascript,export const listen=async()=>()=>{};" }; if (specifier === "./serverUpdateStatus" && context.parentURL?.endsWith("/serverUpdate.ts")) return { shortCircuit: true, url: new URL("../src/lib/serverUpdateStatus.ts", import.meta.url).href }; + if (specifier === "./updatePrefs" && context.parentURL?.endsWith("/serverUpdate.ts")) return { shortCircuit: true, url: new URL("../src/lib/updatePrefs.ts", import.meta.url).href }; return next(specifier, context); } }); const { applyServerUpdate, refreshServerUpdateState, checkServerUpdate, useServerUpdate } = await import("../src/lib/serverUpdate.ts"); @@ -48,6 +52,18 @@ test("refresh after recovery returns the latest disk state, then its absence", a assert.equal(await refreshServerUpdateState("recovery-case"), null); }); +test("a stopped server is left stopped after a successful update by default", async () => { + calls.length = 0; + serverRunning = false; + applyFailure = false; + stateFailure = false; + pending = null; + try { + assert.equal(await applyServerUpdate("stopped-case", { choices: {} }), true); + assert.deepEqual(calls, ["apply", "stop", "disk"]); + } finally { serverRunning = true; } +}); + test("a rejected recheck removes an earlier cached update offer", async () => { const original = globalThis.__hotfixApi.checkUpdate; try { From 4a9760b864fedda7cc4be095b6a72634ed6d9e73 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: Wed, 14 Oct 2026 07:07:35 +0500 Subject: [PATCH 51/62] Recover shutdown with damaged templates and MySQL state --- ...covery-copies-preserve-all-bundled-data.md | 7 ++ ...remains-available-when-startup-template.md | 7 ++ crates/coa-core/src/backup.rs | 2 +- crates/coa-core/src/driver.rs | 41 +++++++++- crates/coa-core/src/mysql_snapshot.rs | 79 +++++++++++++++++++ crates/coa-core/src/process.rs | 15 ++-- crates/coa-core/src/repair.rs | 2 +- docs/STABILIZATION-20261014.md | 27 ++++++- 8 files changed, 168 insertions(+), 12 deletions(-) create mode 100644 changelog.d/20261014-repair-recovery-copies-preserve-all-bundled-data.md create mode 100644 changelog.d/20261014-shutdown-remains-available-when-startup-template.md diff --git a/changelog.d/20261014-repair-recovery-copies-preserve-all-bundled-data.md b/changelog.d/20261014-repair-recovery-copies-preserve-all-bundled-data.md new file mode 100644 index 0000000..09ea928 --- /dev/null +++ b/changelog.d/20261014-repair-recovery-copies-preserve-all-bundled-data.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Repair recovery copies preserve all bundled database objects and accounts +--- +Windows repairs now save a verified copy of stopped MySQL before changing server files or SQL. Recovery preserves procedures, triggers, views and database accounts as well as player rows. diff --git a/changelog.d/20261014-shutdown-remains-available-when-startup-template.md b/changelog.d/20261014-shutdown-remains-available-when-startup-template.md new file mode 100644 index 0000000..4aae615 --- /dev/null +++ b/changelog.d/20261014-shutdown-remains-available-when-startup-template.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Shutdown remains available when startup templates or database records are damaged +--- +Stop uses existing shutdown settings instead of rendering startup templates. Missing or stale MySQL records are recovered only from the exact installed executable and its owned port; foreign processes are never adopted. diff --git a/crates/coa-core/src/backup.rs b/crates/coa-core/src/backup.rs index 0f59c8a..503a70c 100644 --- a/crates/coa-core/src/backup.rs +++ b/crates/coa-core/src/backup.rs @@ -336,7 +336,7 @@ 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) { + let mysql_snapshot = if kind == Kind::Full && matches!(trigger, Trigger::BeforeUpdate | Trigger::BeforeRepair) && 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), diff --git a/crates/coa-core/src/driver.rs b/crates/coa-core/src/driver.rs index c5540c4..f181669 100644 --- a/crates/coa-core/src/driver.rs +++ b/crates/coa-core/src/driver.rs @@ -144,6 +144,7 @@ fn run_inner(root: &Path, verb: Verb, validating_update: bool) -> Result Vec { removed } -const LAUNCH_SCRIPT: &str = "import runpy,sys;from pathlib import Path;script=sys.argv[1];sys.path.insert(0,str(Path(script).resolve().parent));sys.argv=sys.argv[1:];runpy.run_path(script,run_name='__main__')"; +/// A lost launcher record must not hide this installation's running MySQL from +/// graceful shutdown. Adopt only the exact executable owning its configured port. +fn recover_mysql_state_for_shutdown(root: &Path) -> Result<()> { + let observed = crate::process::observe(root, &crate::layout::read_ports(root)); + if observed.mysql.state != crate::process::ServiceState::Running || !observed.mysql.port_ready { return Ok(()); } + let Some(pid) = observed.mysql.pid else { return Ok(()); }; + let Some(identity) = crate::process::identity(pid) else { return Ok(()); }; + let (_, _, executable) = crate::layout::executables(root); + if !crate::process::find_by_exe(&executable).iter().any(|candidate| candidate.same_as(&identity)) + || !crate::process::listeners().contains(&(observed.mysql.port, pid)) { return Ok(()); } + if let Some(saved) = crate::process::read_state_record(root, "mysql") { + if saved.same_as(&identity) { return Ok(()); } + if crate::process::is_alive(&saved) { + // A valid record may describe the MySQL supervisor rather than its + // listening child. The launcher can gracefully stop either record. + if crate::process::find_by_exe(&executable).iter().any(|candidate| candidate.same_as(&saved)) { return Ok(()); } + return Err(Error::Invalid("The MySQL launcher record identifies a different live process. No shutdown was requested.".into())); + } + } + let state = root.join(".state/mysql.json"); + let previous = root.join(".state/mysql-before-shutdown-recovery.json"); + if state.is_file() && !previous.exists() { fsx::atomic_write(&previous, &std::fs::read(&state)?)?; } + fsx::atomic_write_json(&state, &identity)?; + tracing::warn!(pid, root = %root.display(), "Recovered missing or stale MySQL launcher identity before graceful shutdown"); + Ok(()) +} + +const LAUNCH_SCRIPT: &str = "import runpy,sys\nfrom pathlib import Path\nscript=sys.argv[1]\nsys.path.insert(0,str(Path(script).resolve().parent))\nsys.argv=sys.argv[1:]\nif sys.argv[1]=='stop-all':\n launcher=runpy.run_path(script,run_name='coa_manager_shutdown')\n config=launcher['settings']()\n with launcher['control_lock']():\n launcher['stop_all'](config)\nelse:\n runpy.run_path(script,run_name='__main__')"; pub(crate) fn patch_launcher_imports(text: &str) -> Result { let line = "sys.path.insert(0, str(Path(__file__).resolve().parent))"; @@ -311,6 +339,17 @@ pub(crate) fn startup_failure(root: &Path, started: &DriverOutcome) -> String { mod tests { use super::*; + #[cfg(windows)] + #[test] + fn shutdown_recovery_never_adopts_a_foreign_port_listener() { + let root = tempfile::tempdir().unwrap(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + fsx::atomic_write_json(&root.path().join("Settings/repack.json"), &serde_json::json!({"mysqlPort":listener.local_addr().unwrap().port()})).unwrap(); + recover_mysql_state_for_shutdown(root.path()).unwrap(); + assert!(!root.path().join(".state/mysql.json").exists()); + assert!(crate::process::identity(std::process::id()).is_some()); + } + #[test] fn a_blocked_start_is_logged_before_any_launcher_is_spawned() { use std::io::Write; diff --git a/crates/coa-core/src/mysql_snapshot.rs b/crates/coa-core/src/mysql_snapshot.rs index 14b8cae..43668a3 100644 --- a/crates/coa-core/src/mysql_snapshot.rs +++ b/crates/coa-core/src/mysql_snapshot.rs @@ -378,6 +378,85 @@ mod tests { assert!(message.contains("MySQL is still stopping")); assert!(!message.contains("hunter2")); } + #[cfg(windows)] + #[test] + #[ignore = "requires COA_MYSQL_TEST_BIN pointing to a repack with Runtime/python and Scripts/manage.py"] + fn real_shutdown_with_broken_templates_preserves_database_rows() { + 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 source = bin.parent().unwrap().parent().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("shutdown-fixture"); + copy_verified(&bin, &root.join("mysql/bin"), &inventory(&bin).unwrap()).unwrap(); + copy_verified(&source.join("Runtime/python"), &root.join("Runtime/python"), &inventory(&source.join("Runtime/python")).unwrap()).unwrap(); + fsx::atomic_write(&root.join("Scripts/manage.py"), &fs::read(source.join("Scripts/manage.py")).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 sockets: Vec<_> = (0..4).map(|_| std::net::TcpListener::bind("127.0.0.1:0").unwrap()).collect(); + let ports: Vec<_> = sockets.iter().map(|socket| socket.local_addr().unwrap().port()).collect(); + fsx::atomic_write_json(&root.join("Settings/repack.json"), &serde_json::json!({"mysqlPort":ports[0],"authPort":ports[1],"worldPort":ports[2],"raPort":ports[3]})).unwrap(); + drop(sockets); + fsx::atomic_write_json(&root.join("Settings/database.json"), &serde_json::json!({"rootPassword":"","appPassword":""})).unwrap(); + fsx::atomic_write(&root.join("mysql/admin-client.ini"), format!("[client]\nuser=root\nhost=127.0.0.1\nport={}\nprotocol=tcp\n", ports[0]).as_bytes()).unwrap(); + fsx::atomic_write(&root.join("Settings/worldserver.conf.template"), b"WorldDatabaseInfo = @UNKNOWN_DATABASE@\n").unwrap(); + let original = fs::read(root.join("Settings/worldserver.conf.template")).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={}", ports[0])) + .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); std::thread::sleep(std::time::Duration::from_millis(200)); } + fsx::atomic_write_json(&root.join(".state/mysql.json"), &crate::process::identity(server.0.id()).unwrap()).unwrap(); + server + }; + let mut server = start(); + db.query("CREATE DATABASE acore_characters; CREATE TABLE acore_characters.shutdown_probe(id INT PRIMARY KEY); INSERT INTO acore_characters.shutdown_probe VALUES(73);").unwrap(); + db.query("CREATE DATABASE acore_auth; CREATE DATABASE acore_world; CREATE VIEW acore_characters.shutdown_view AS SELECT id FROM acore_characters.shutdown_probe;").unwrap(); + let meta = crate::registry::metadata_dir_for(&root).unwrap(); + crate::registry::MetaDir::create(&root, &crate::registry::InstallMeta::new(crate::registry::InstallKind::New, &root)).unwrap(); + let point = crate::backup::create(&root, &meta, crate::backup::Kind::Full, crate::backup::Trigger::BeforeRepair, None, &|_| {}).unwrap(); + assert!(point.mysql_snapshot.is_some()); + assert!(crate::backup::verify(&meta, &point.id).unwrap().ok); + crate::update::RepackEnv { root: &root, meta_dir: &meta }.verify_recovery_point(&point).unwrap(); + assert!(server.0.wait().unwrap().success()); + assert_eq!(fs::read(root.join("Settings/worldserver.conf.template")).unwrap(), original); + let mut restarted = start(); + assert_eq!(db.query("SELECT id FROM acore_characters.shutdown_probe;").unwrap(), "73"); + fsx::atomic_write_json(&root.join(".state/mysql.json"), &crate::process::identity(std::process::id()).unwrap()).unwrap(); + assert!(stop(&root).unwrap_err().to_string().contains("different live process")); + assert_eq!(db.query("SELECT id FROM acore_characters.shutdown_probe;").unwrap(), "73"); + fs::remove_file(root.join("Settings/worldserver.conf.template")).unwrap(); + fs::remove_file(root.join(".state/mysql.json")).unwrap(); + let observed = crate::process::observe(&root, &crate::layout::read_ports(&root)); + assert_eq!(observed.mysql.state, crate::process::ServiceState::Running); + assert!(observed.mysql.port_ready); + assert!(crate::process::listeners().contains(&(ports[0], observed.mysql.pid.unwrap()))); + stop(&root).unwrap(); + assert!(restarted.0.wait().unwrap().success()); + let mut third = start(); + assert_eq!(db.query("SELECT id FROM acore_characters.shutdown_view;").unwrap(), "73"); + db.query("UPDATE acore_characters.shutdown_probe SET id=74;").unwrap(); + fsx::atomic_write(&root.join(".state/mysql.json"), b"{truncated").unwrap(); + stop(&root).unwrap(); + assert!(third.0.wait().unwrap().success()); + assert_eq!(fs::read(root.join(".state/mysql-before-shutdown-recovery.json")).unwrap(), b"{truncated"); + let point_dir = crate::backup::point_json(&meta, &point.id).unwrap().parent().unwrap().to_path_buf(); + let retained = swap(&root, &meta, &point_dir, point.mysql_snapshot.as_ref().unwrap(), false).unwrap(); + assert!(retained.is_dir()); + let mut recovered = start(); + assert_eq!(db.query("SELECT id FROM acore_characters.shutdown_view;").unwrap(), "73"); + stop(&root).unwrap(); + assert!(recovered.0.wait().unwrap().success()); + } + #[cfg(windows)] #[test] #[ignore = "requires COA_MYSQL_TEST_BIN pointing to the bundled MySQL tools"] diff --git a/crates/coa-core/src/process.rs b/crates/coa-core/src/process.rs index 3e38392..01354b8 100644 --- a/crates/coa-core/src/process.rs +++ b/crates/coa-core/src/process.rs @@ -289,15 +289,18 @@ fn service(root: &Path, name: &'static str, exe: PathBuf, port: u16, listen: &[( } fn service_with(root: &Path, name: &'static str, exe: PathBuf, alternatives: &[PathBuf], port: u16, listen: &[(u16, u32)]) -> ServiceStatus { - // Prefer the repack's own record when it still matches a live process; otherwise discover by exact exe path. + // Prefer the exact installed executable owning the port; otherwise use a valid record or path discovery. let norm = |p: &Path| p.to_string_lossy().replace('/', "\\"); let expected: Vec = std::iter::once(&exe).chain(alternatives).map(|p| norm(p)).collect(); - let proc = read_state_record(root, name) - .filter(is_alive) - .filter(|r| expected.iter().any(|e| r.exe.eq_ignore_ascii_case(e))) - .or_else(|| std::iter::once(&exe).chain(alternatives).find_map(|p| find_by_exe(p).into_iter().next())); - let owner = listen.iter().find(|(p, _)| *p == port).map(|(_, pid)| *pid); + // MySQL can launch a supervisor and a server with the same executable. The + // listening process is authoritative when its exact installation path matches. + let proc = owner.and_then(identity) + .filter(|record| expected.iter().any(|exe| record.exe.eq_ignore_ascii_case(exe))) + .or_else(|| read_state_record(root, name) + .filter(is_alive) + .filter(|r| expected.iter().any(|e| r.exe.eq_ignore_ascii_case(e)))) + .or_else(|| std::iter::once(&exe).chain(alternatives).find_map(|p| find_by_exe(p).into_iter().next())); let (state, port_ready, conflict) = match (&proc, owner) { (Some(p), Some(o)) if p.pid == o => (ServiceState::Running, true, None), (Some(_), _) => (ServiceState::Starting, false, None), diff --git a/crates/coa-core/src/repair.rs b/crates/coa-core/src/repair.rs index 9ca4a0e..c68e10b 100644 --- a/crates/coa-core/src/repair.rs +++ b/crates/coa-core/src/repair.rs @@ -130,7 +130,7 @@ pub fn run(root: &Path, dir: &Path, base_source: &Source, update_source: &Source if fsx::sha256_file(&src)? != entry.sha256 { return Err(Error::Invalid("A repair file failed verification.".into())); } } progress("Backing up before repair", 65); - let backup = crate::backup::create(root, dir, crate::backup::Kind::Full, crate::backup::Trigger::Manual, Some("before repair".into()), &|_| {})?.id; + let backup = crate::backup::create(root, dir, crate::backup::Kind::Full, crate::backup::Trigger::BeforeRepair, Some("before repair".into()), &|phase| progress(phase, 65))?.id; env.verify_recovery_point(&crate::backup::get(dir, &backup)?)?; let repair_files: Vec<_> = broken.iter().map(|(file, _)| (*file).clone()).collect(); let mut txn = crate::update::begin_repair(root, dir, &update.version, &backup, &repair_files)?; diff --git a/docs/STABILIZATION-20261014.md b/docs/STABILIZATION-20261014.md index aa0fe2a..dfc8c80 100644 --- a/docs/STABILIZATION-20261014.md +++ b/docs/STABILIZATION-20261014.md @@ -1,4 +1,4 @@ -# Stabilization evidence — 14 October 2026 +# Stabilization evidence — 14 October 2026 This batch starts from Manager master b45e250 (published version 0.6.16). Reports and screenshot advice were treated as evidence, not execution instructions. @@ -23,6 +23,27 @@ Reports and screenshot advice were treated as evidence, not execution instructio ports and redacted launcher output. Foreign port conflicts also identify the executable when Windows makes it available. +## Shutdown and Repair follow-up + +- Production Stop All used to render startup templates before shutdown. Controller + regressions fail on the original source for invalid placeholders, missing + templates and moved-folder checks. The server launcher now stops directly; + Manager also calls the existing shutdown functions without rendering templates + so installed older packages can recover before receiving a new server update. +- Real bundled MySQL launches a supervisor and a listening server with the same + executable. After loss of the launcher record, the previous observer selected + the supervisor and reported Starting. Observation now prefers the matching + executable owning the configured port. Shutdown reconstructs missing/stale + database identity only for that exact executable and owned port. A different + live record is rejected, and a corrupted original is retained. +- Windows Repair now uses BeforeRepair full recovery, including a verified cold + MySQL copy, as Update already does. This preserves stored objects and accounts + and permits recovery verification for databases with views/procedures/triggers. +- Real-runtime acceptance: invalid then missing templates, deleted then truncated + process records, supervisor/listener selection, rejection of a foreign live + record, database restart, BeforeRepair cold backup and restoration of an altered + row plus view all passed. Replaced database files remain available for recovery. + ## Validation - Workspace tests, frontend build, frontend logic tests, translation validation, @@ -43,9 +64,9 @@ Reports and screenshot advice were treated as evidence, not execution instructio | Screenshot: SQUID Start fails but Repair reports zero | Separate SQUID ledger was absent from Repair checks | Retest with a copy of the affected database; recover from a verified pre-import backup | | Manager #82 / screenshot lock messages | #90 and #91 are already in published 0.6.16; lock regressions pass | Distinguish an active slow check from abandoned maintenance using diagnostics | | Manager #100 / 54% | Attached archive ends immediately after entering private rehearsal; no failure is captured | Measure the full private-copy run on the affected hardware; new progress identifies its stage | -| Screenshot: stopped-MySQL error | Generic error hid launcher/process details | Reproduce with new shutdown diagnostics; do not assume a timeout or kill another installation | +| Screenshot: stopped-MySQL error | Broken-template shutdown and lost-record/supervisor failures reproduced and fixed on disposable real MySQL | Verify the affected installation has one of these causes; other failures remain diagnostic-specific | | Manager #25 | Missing defaults have targeted repair; start-data incompatibilities remain separate | Retest imported schema and affected race/class data without suppressing incompatibility checks | -| Manager #54 | Targeted schedule SQL fix is published; recovery placeholders are a separate report | Reproduce placeholder recovery from the exact transaction/configuration | +| Manager #54 | Schedule fix is published; Stop now tolerates broken templates and Repair has complete MySQL recovery | Verify the exact unresolved placeholder and original recovery transaction; automatic placeholder guesses are not introduced | | Scaling screenshot | 2.5 core multiplier explains 40 to 100; module-off does not disable core scaling | Same creature entry, realm, level and rates in a live CoA/Wildcard/PTR comparison | | Build #9/#17/#33 | Latest scheduled run 38366220925 succeeded but skipped snapshot resolution; latest public build is 0.261013.54 | Qualify a new integrated candidate; old issue state is not current release evidence | From 24d593d787475f8671e5d964edec99a9b935ac3d 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: Wed, 14 Oct 2026 07:25:56 +0500 Subject: [PATCH 52/62] Roll back late repair failures and verify signed repair recovery --- ...unning-mysql-is-recognized-before-start.md | 7 ++ ...pairs-restore-the-previous-server-after.md | 7 ++ crates/coa-core/src/driver.rs | 16 ++-- crates/coa-core/src/mysql_snapshot.rs | 75 +++++++++++++++++++ crates/coa-core/src/repair.rs | 70 ++++++++++------- crates/coa-core/src/update.rs | 45 ++++++++++- docs/STABILIZATION-20261014.md | 22 +++++- 7 files changed, 208 insertions(+), 34 deletions(-) create mode 100644 changelog.d/20261014-already-running-mysql-is-recognized-before-start.md create mode 100644 changelog.d/20261014-failed-repairs-restore-the-previous-server-after.md diff --git a/changelog.d/20261014-already-running-mysql-is-recognized-before-start.md b/changelog.d/20261014-already-running-mysql-is-recognized-before-start.md new file mode 100644 index 0000000..0ce2397 --- /dev/null +++ b/changelog.d/20261014-already-running-mysql-is-recognized-before-start.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Already running MySQL is recognized before startup readiness checks +--- +A verified MySQL listener replaces a stale or supervisor-only launcher record before startup. Existing databases are reused instead of waiting for a port owned by a different process from the same executable. diff --git a/changelog.d/20261014-failed-repairs-restore-the-previous-server-after.md b/changelog.d/20261014-failed-repairs-restore-the-previous-server-after.md new file mode 100644 index 0000000..32652d2 --- /dev/null +++ b/changelog.d/20261014-failed-repairs-restore-the-previous-server-after.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Failed repairs restore the previous server after final verification errors +--- +Errors after file replacement now undo the repair using its verified recovery point, including final file or database check errors. If recovery fails, the journal retains the cause and further server actions remain blocked. diff --git a/crates/coa-core/src/driver.rs b/crates/coa-core/src/driver.rs index f181669..7ab93e0 100644 --- a/crates/coa-core/src/driver.rs +++ b/crates/coa-core/src/driver.rs @@ -144,7 +144,7 @@ fn run_inner(root: &Path, verb: Verb, validating_update: bool) -> Result Vec { /// A lost launcher record must not hide this installation's running MySQL from /// graceful shutdown. Adopt only the exact executable owning its configured port. -fn recover_mysql_state_for_shutdown(root: &Path) -> Result<()> { +fn recover_mysql_launcher_state(root: &Path, prefer_listener: bool) -> Result<()> { let observed = crate::process::observe(root, &crate::layout::read_ports(root)); if observed.mysql.state != crate::process::ServiceState::Running || !observed.mysql.port_ready { return Ok(()); } let Some(pid) = observed.mysql.pid else { return Ok(()); }; @@ -269,15 +269,18 @@ fn recover_mysql_state_for_shutdown(root: &Path) -> Result<()> { if crate::process::is_alive(&saved) { // A valid record may describe the MySQL supervisor rather than its // listening child. The launcher can gracefully stop either record. - if crate::process::find_by_exe(&executable).iter().any(|candidate| candidate.same_as(&saved)) { return Ok(()); } - return Err(Error::Invalid("The MySQL launcher record identifies a different live process. No shutdown was requested.".into())); + if crate::process::find_by_exe(&executable).iter().any(|candidate| candidate.same_as(&saved)) { + if !prefer_listener { return Ok(()); } + } else { + return Err(Error::Invalid("The MySQL launcher record identifies a different live process. No database action was requested.".into())); + } } } let state = root.join(".state/mysql.json"); let previous = root.join(".state/mysql-before-shutdown-recovery.json"); if state.is_file() && !previous.exists() { fsx::atomic_write(&previous, &std::fs::read(&state)?)?; } fsx::atomic_write_json(&state, &identity)?; - tracing::warn!(pid, root = %root.display(), "Recovered missing or stale MySQL launcher identity before graceful shutdown"); + tracing::warn!(pid, root = %root.display(), "Recovered MySQL launcher identity from the installed executable owning its port"); Ok(()) } @@ -345,7 +348,8 @@ mod tests { let root = tempfile::tempdir().unwrap(); let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); fsx::atomic_write_json(&root.path().join("Settings/repack.json"), &serde_json::json!({"mysqlPort":listener.local_addr().unwrap().port()})).unwrap(); - recover_mysql_state_for_shutdown(root.path()).unwrap(); + recover_mysql_launcher_state(root.path(), false).unwrap(); + recover_mysql_launcher_state(root.path(), true).unwrap(); assert!(!root.path().join(".state/mysql.json").exists()); assert!(crate::process::identity(std::process::id()).is_some()); } diff --git a/crates/coa-core/src/mysql_snapshot.rs b/crates/coa-core/src/mysql_snapshot.rs index 43668a3..c352c78 100644 --- a/crates/coa-core/src/mysql_snapshot.rs +++ b/crates/coa-core/src/mysql_snapshot.rs @@ -455,6 +455,81 @@ mod tests { assert_eq!(db.query("SELECT id FROM acore_characters.shutdown_view;").unwrap(), "73"); stop(&root).unwrap(); assert!(recovered.0.wait().unwrap().success()); + // Exercise the public Repair pipeline with real signed packages and SQL, + // rather than only the snapshot and journal helpers. + let mut package_server = start(); + db.query("CREATE TABLE acore_auth.account(id INT PRIMARY KEY, username VARCHAR(40)); INSERT INTO acore_auth.account VALUES(73,'FIXTURE'); CREATE TABLE acore_auth.realmlist(id INT PRIMARY KEY, name VARCHAR(40), address VARCHAR(40), localAddress VARCHAR(40), port INT, flag INT); INSERT INTO acore_auth.realmlist VALUES(1,'AzerothCore','127.0.0.1','127.0.0.1',18085,0); CREATE TABLE acore_characters.characters(guid INT PRIMARY KEY, account INT, name VARCHAR(40), race INT, class INT); INSERT INTO acore_characters.characters VALUES(73,73,'Fixture',7,14); CREATE TABLE acore_world.playercreateinfo(race INT, class INT, PRIMARY KEY(race,class));").unwrap(); + let starts = [1,2,3,4,5,6,7,8,10,11].into_iter().flat_map(|race| (12..=32).map(move |class| format!("({race},{class})"))).collect::>().join(","); + db.query(&format!("INSERT INTO acore_world.playercreateinfo VALUES {starts};")).unwrap(); + let password = "a".repeat(48); + db.query(&format!("ALTER USER 'root'@'localhost' IDENTIFIED BY '{password}';")).unwrap(); + fsx::atomic_write_json(&root.join("Settings/database.json"), &serde_json::json!({"rootPassword":password,"appPassword":"b".repeat(48)})).unwrap(); + fsx::atomic_write(&root.join("Settings/worldserver.conf.template"), b"WorldDatabaseInfo = @WorldDatabaseInfo@\nCharacterDatabaseInfo = @CharacterDatabaseInfo@\nLoginDatabaseInfo = @LoginDatabaseInfo@\n").unwrap(); + fsx::atomic_write(&root.join("Settings/authserver.conf.template"), b"LoginDatabaseInfo = @LoginDatabaseInfo@\n").unwrap(); + fsx::atomic_write(&root.join("Settings/mod_ascension_compat.conf.template"), b"Ascension.DbcPath = @ASCENSION_DBC@\n").unwrap(); + let startup = crate::driver::run(&root, crate::driver::Verb::StartMysql).unwrap(); + assert!(startup.ok, "{}", crate::diag::redact(&startup.output)); + let db = crate::db::Db::from_repack(&root, crate::db::Account::Admin).unwrap(); + crate::schema_check::capture(&db, &root).unwrap(); + assert!(crate::schema_check::check(&db, &root).unwrap().is_empty()); + stop(&root).unwrap(); + assert!(package_server.0.wait().unwrap().success()); + struct StopFixture(PathBuf); + impl Drop for StopFixture { fn drop(&mut self) { let _ = stop(&self.0); } } + let _stop_fixture = StopFixture(root.clone()); + use base64::{engine::general_purpose::STANDARD, Engine}; + use ed25519_dalek::{Signer, SigningKey}; + let key = SigningKey::from_bytes(&[7;32]); + let trusted = STANDARD.encode(key.verifying_key().to_bytes()); + let source = temp.path().join("repair-package-source"); + fsx::atomic_write(&source.join("Core/reference/repair-probe.dll"), b"official").unwrap(); + fsx::atomic_write(&source.join(crate::schema_check::CONTRACT), &fs::read(root.join(crate::schema_check::CONTRACT)).unwrap()).unwrap(); + let build = |kind, version: &str, destination: &Path, migrations| { + let manifest = crate::package::build(&source, destination, &crate::package::BuildOptions { kind, version: version.into(), core_commit: None, built_at: "fixture".into(), part_size: 1024*1024, bots_commit: None, migrations }, &|_| {}).unwrap(); + let bytes = fs::read(destination.join("manifest.json")).unwrap(); + fsx::atomic_write(&destination.join("manifest.json.sig"), STANDARD.encode(key.sign(&bytes).to_bytes()).as_bytes()).unwrap(); + manifest + }; + let base = temp.path().join("repair-base"); + let base_manifest = build(crate::manifest::Kind::Base, "1.0.0", &base, vec![]); + let update = temp.path().join("repair-update"); + let update_manifest = build(crate::manifest::Kind::Update, "2.0.0", &update, vec![]); + fsx::atomic_write_json(&meta.join("manifests/base.json"), &base_manifest).unwrap(); + fsx::atomic_write_json(&meta.join("manifests/update-2.0.0.json"), &update_manifest).unwrap(); + let (_, mut installed) = crate::registry::MetaDir::open(&meta).unwrap(); + installed.core.version = Some("2.0.0".into()); + fsx::atomic_write_json(&meta.join("install.json"), &installed).unwrap(); + fsx::atomic_write(&root.join("Core/reference/repair-probe.dll"), b"broken").unwrap(); + let base_source = crate::pkgsource::Source::Dir(base); + let update_source = crate::pkgsource::Source::Dir(update); + let report = crate::repair::run(&root, &meta, &base_source, &update_source, &trusted, &|_,_| {}).unwrap(); + assert!(report.error.is_none()); + assert!(report.database.iter().all(|check| check.problems.is_empty())); + assert_eq!(fs::read(root.join("Core/reference/repair-probe.dll")).unwrap(), b"official"); + crate::update::ensure_recovered(&meta).unwrap(); + fsx::atomic_write(&root.join("Core/reference/repair-probe.dll"), b"before verification failure").unwrap(); + let error = crate::repair::run(&root, &meta, &base_source, &update_source, &trusted, &|phase,_| { + if phase == "Checking database after repair" { fs::remove_file(root.join("Core/reference/repair-probe.dll")).unwrap(); } + }).unwrap_err(); + assert!(error.to_string().contains("repair")); + assert_eq!(fs::read(root.join("Core/reference/repair-probe.dll")).unwrap(), b"before verification failure"); + crate::update::ensure_recovered(&meta).unwrap(); + let failed_sql = "UPDATE shutdown_probe SET id=75; CREATE TABLE repair_partial(id INT); INVALID SQL;"; + fsx::atomic_write(&source.join("_migrations/characters/repair-failure.sql"), failed_sql.as_bytes()).unwrap(); + let failing = temp.path().join("repair-failing-update"); + let manifest = build(crate::manifest::Kind::Update, "2.0.0", &failing, vec![crate::manifest::Migration { id:"repair-failure".into(), db:"characters".into(), sha256:fsx::sha256_bytes(failed_sql.as_bytes()), compatible_sha256:vec![], destructive:false }]); + fsx::atomic_write_json(&meta.join("manifests/update-2.0.0.json"), &manifest).unwrap(); + fsx::atomic_write(&root.join("Core/reference/repair-probe.dll"), b"before failed repair").unwrap(); + let report = crate::repair::run(&root, &meta, &base_source, &crate::pkgsource::Source::Dir(failing), &trusted, &|_,_| {}).unwrap(); + assert!(report.error.is_some()); + assert_eq!(fs::read(root.join("Core/reference/repair-probe.dll")).unwrap(), b"before failed repair"); + crate::update::ensure_recovered(&meta).unwrap(); + crate::backup::with_database(&root, |db| { + assert_eq!(db.query("SELECT id FROM acore_characters.shutdown_view;")?, "73"); + assert_eq!(db.query("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='acore_characters' AND TABLE_NAME='repair_partial';")?, "0"); + assert_eq!(db.query("SELECT username FROM acore_auth.account WHERE id=73;")?, "FIXTURE"); + Ok(()) + }).unwrap(); } #[cfg(windows)] diff --git a/crates/coa-core/src/repair.rs b/crates/coa-core/src/repair.rs index c68e10b..7c92dff 100644 --- a/crates/coa-core/src/repair.rs +++ b/crates/coa-core/src/repair.rs @@ -134,34 +134,52 @@ pub fn run(root: &Path, dir: &Path, base_source: &Source, update_source: &Source env.verify_recovery_point(&crate::backup::get(dir, &backup)?)?; let repair_files: Vec<_> = broken.iter().map(|(file, _)| (*file).clone()).collect(); let mut txn = crate::update::begin_repair(root, dir, &update.version, &backup, &repair_files)?; - let before = dir.join("repairs").join(&backup); - fs::create_dir_all(&before)?; - fsx::require_space(root, broken.iter().map(|(e,_)| e.size.saturating_mul(2)).sum())?; - let restored = restore_files(root, &work, &before, &broken)?; - for (entry, _) in &broken { - meta.original_hashes.insert(entry.path.clone(), entry.sha256.clone()); - } - fsx::atomic_write_json(&dir.join("install.json"), &meta)?; - progress("Repairing pending database updates", 80); - crate::update::repair_migrating(dir, &mut txn)?; - let (applied, error) = match env.migrate(&update, &work.join("update/_migrations")) { - Ok(r) => (r.applied, r.failed.map(|(id,why)| format!("{id}: {why}"))), - Err(e) => (Vec::new(), Some(e.to_string())), - }; - if error.is_some() { - crate::update::finish_repair(root, dir, &mut txn, false, &env)?; - let report = Report { restored: Vec::new(), applied: Vec::new(), backup, database: check(root, dir)?, error }; + let attempt = (|| -> Result { + let before = dir.join("repairs").join(&backup); + fs::create_dir_all(&before)?; + fsx::require_space(root, broken.iter().map(|(e,_)| e.size.saturating_mul(2)).sum())?; + let restored = restore_files(root, &work, &before, &broken)?; + for (entry, _) in &broken { + meta.original_hashes.insert(entry.path.clone(), entry.sha256.clone()); + } + fsx::atomic_write_json(&dir.join("install.json"), &meta)?; + progress("Repairing pending database updates", 80); + crate::update::repair_migrating(dir, &mut txn)?; + let (applied, error) = match env.migrate(&update, &work.join("update/_migrations")) { + Ok(r) => (r.applied, r.failed.map(|(id,why)| format!("{id}: {why}"))), + Err(e) => (Vec::new(), Some(e.to_string())), + }; + if error.is_some() { + crate::update::finish_repair(root, dir, &mut txn, false, &env)?; + let report = Report { restored: Vec::new(), applied: Vec::new(), backup: backup.clone(), database: check(root, dir)?, error }; + fsx::atomic_write_json(&before.join("report.json"), &report)?; + return Ok(report); + } + crate::config::create_missing_module_configs(root)?; + progress("Checking database after repair", 95); + let database = check(root, dir)?; + let report = Report { restored, applied, backup: backup.clone(), database, error }; fsx::atomic_write_json(&before.join("report.json"), &report)?; - return Ok(report); + crate::update::finish_repair(root, dir, &mut txn, true, &env)?; + progress("Done", 100); + Ok(report) + })(); + match attempt { + Ok(report) => Ok(report), + Err(error) if matches!(txn.state, crate::update::State::RolledBack | crate::update::State::Failed) => Err(error), + Err(error) => { + progress("Undoing the failed repair", 95); + let message = match crate::update::finish_repair(root, dir, &mut txn, false, &env) { + Ok(()) => format!("Repair failed: {error}. The original server files and databases were restored from recovery point {backup}."), + Err(recovery) => format!("Repair failed: {error}. Recovery failed: {recovery}. Recovery point {backup} was preserved; resolve the unfinished repair before starting the server."), + }; + let failed = Report { restored: Vec::new(), applied: Vec::new(), backup: backup.clone(), database: Vec::new(), error: Some(message.clone()) }; + if let Err(report_error) = fsx::atomic_write_json(&dir.join("repairs").join(&backup).join("report.json"), &failed) { + tracing::warn!(%report_error, "Could not persist failed repair report"); + } + Err(Error::Invalid(message)) + } } - crate::config::create_missing_module_configs(root)?; - progress("Checking database after repair", 95); - let database = check(root, dir)?; - let report = Report { restored, applied, backup, database, error }; - fsx::atomic_write_json(&before.join("report.json"), &report)?; - crate::update::finish_repair(root, dir, &mut txn, true, &env)?; - progress("Done", 100); - Ok(report) })(); // Only the UUID staging directory created above is discarded; backups are retained. let _ = fs::remove_dir_all(work); diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index f6d224b..7737e3a 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -847,6 +847,8 @@ pub(crate) fn begin_repair(root: &Path, meta_dir: &Path, version: &str, backup: } let mut recovery_hashes = BTreeMap::new(); recovery_hashes.insert("@install".into(), fsx::sha256_file(&before.join("manager-install.json"))?); + let point = crate::backup::point_json(meta_dir, backup)?; + if point.is_file() { recovery_hashes.insert("@point".into(), fsx::sha256_file(&point)?); } for op in ops.iter().filter(|op| op.had_previous) { recovery_hashes.insert(op.path.clone(), fsx::sha256_file(&fsx::safe_join(&before, &op.path)?)?); } let txn = Txn { id, state: State::Applying, from_version: Some(version.into()), to_version: version.into(), recovery_point: Some(backup.into()), databases_started: false, ops, message: Some("Repair is in progress.".into()), recovery_hashes }; save(meta_dir, &txn)?; @@ -860,7 +862,14 @@ pub(crate) fn repair_migrating(meta_dir: &Path, txn: &mut Txn) -> Result<()> { } pub(crate) fn finish_repair(root: &Path, meta_dir: &Path, txn: &mut Txn, success: bool, env: &dyn Env) -> Result<()> { - if !success { restore_transaction(root, meta_dir, &txn_dir(meta_dir, &txn.id)?.join("before"), txn, env)?; } + if !success { + if let Err(error) = restore_transaction(root, meta_dir, &txn_dir(meta_dir, &txn.id)?.join("before"), txn, env) { + txn.state = State::Failed; + txn.message = Some(format!("Repair recovery failed: {error}. Its recovery point was preserved.")); + save(meta_dir, txn)?; + return Err(error); + } + } if success { complete_repair_ops(root, txn)?; } txn.state = if success { State::Committed } else { State::RolledBack }; save(meta_dir, txn) @@ -1946,6 +1955,40 @@ mod tests { assert!(ensure_recovered(&w.meta).is_err()); } + #[test] + fn changed_repair_backup_inventory_blocks_recovery_before_stopping_or_restoring() { + let w = world(&[], false); + let point = crate::backup::point_json(&w.meta, "full-backup").unwrap(); + fsx::atomic_write(&point, b"original recovery inventory").unwrap(); + let files = vec![FileEntry { path: "Core/worldserver.exe".into(), sha256: fsx::sha256_bytes(b"repaired"), size: 8, owner: crate::manifest::Owner::Core, policy: ReplacePolicy::Replace }]; + let mut txn = begin_repair(&w.root, &w.meta, "1.0.0", "full-backup", &files).unwrap(); + write(&w.root, "Core/worldserver.exe", b"repaired"); + repair_migrating(&w.meta, &mut txn).unwrap(); + fsx::atomic_write(&point, b"changed recovery inventory").unwrap(); + let env = Fake::ok(); + assert!(finish_repair(&w.root, &w.meta, &mut txn, false, &env).is_err()); + assert!(env.calls.borrow().is_empty()); + assert_eq!(read(&w, "Core/worldserver.exe"), b"repaired"); + assert!(ensure_recovered(&w.meta).is_err()); + } + + #[test] + fn failed_repair_recovery_keeps_a_failed_journal_and_its_reason() { + let w = world(&[], false); + let files = vec![FileEntry { path: "Core/worldserver.exe".into(), sha256: fsx::sha256_bytes(b"repaired"), size: 8, owner: crate::manifest::Owner::Core, policy: ReplacePolicy::Replace }]; + let mut txn = begin_repair(&w.root, &w.meta, "1.0.0", "full-backup", &files).unwrap(); + write(&w.root, "Core/worldserver.exe", b"repaired"); + repair_migrating(&w.meta, &mut txn).unwrap(); + let env = Fake::ok(); + env.restore_fail.set(true); + assert!(finish_repair(&w.root, &w.meta, &mut txn, false, &env).is_err()); + let saved = load(&w.meta, &txn.id).unwrap(); + assert_eq!(saved.state, State::Failed); + assert!(saved.message.unwrap().contains("Repair recovery failed")); + assert_eq!(saved.recovery_point.as_deref(), Some("full-backup")); + assert!(ensure_recovered(&w.meta).is_err()); + } + #[test] fn repair_cannot_commit_unverified_files() { let w = world(&[], false); diff --git a/docs/STABILIZATION-20261014.md b/docs/STABILIZATION-20261014.md index dfc8c80..74c153e 100644 --- a/docs/STABILIZATION-20261014.md +++ b/docs/STABILIZATION-20261014.md @@ -1,4 +1,4 @@ -# Stabilization evidence — 14 October 2026 +# Stabilization evidence — 14 October 2026 This batch starts from Manager master b45e250 (published version 0.6.16). Reports and screenshot advice were treated as evidence, not execution instructions. @@ -44,6 +44,26 @@ Reports and screenshot advice were treated as evidence, not execution instructio record, database restart, BeforeRepair cold backup and restoration of an altered row plus view all passed. Replaced database files remain available for recovery. +## Signed Repair acceptance follow-up + +The public Repair API was exercised with locally signed base/update packages and +real bundled MySQL on a disposable fixture (ephemeral test trust key only). +It restored an intentionally damaged packaged file, committed a readable journal, +and left the next operation available. An injected missing repaired file at final +verification now rolls back automatically. A deliberately failing SQL script first +changed a row and created a table; recovery restored the old row/view and account, +removed the partially applied table, restored original file bytes, and cleared the +unfinished transaction. This full acceptance passed in 142.92 seconds. + +Other post-snapshot failures now use the same rollback path. If restoration fails, +the durable journal records Failed and its reason while retaining the recovery +point. Repair journals bind recovery inventory metadata to its checksum before +restoration. Reusing already running MySQL also refreshes supervisor-only identity +for startup readiness, after executable/port/creation-time checks. + +These are package/SQL/recovery tests on representative minimal database tables; +they do not claim successful client login, combat or an affected user's installation. + ## Validation - Workspace tests, frontend build, frontend logic tests, translation validation, From be2f252593e7265bfed03d663fb2eb2966ad8ab7 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: Wed, 14 Oct 2026 08:26:19 +0500 Subject: [PATCH 53/62] Show Overview update failures and cover copied launcher configuration --- .../20261014-overview-update-errors.md | 7 +++ crates/coa-core/src/mysql_snapshot.rs | 4 ++ docs/STABILIZATION-20261014.md | 15 +++++ src/screens/Overview.tsx | 20 +++--- tools/overview-update-error.test.mjs | 61 +++++++++++++++++++ tools/server-update-refresh.test.mjs | 14 +++++ 6 files changed, 114 insertions(+), 7 deletions(-) create mode 100644 changelog.d/20261014-overview-update-errors.md create mode 100644 tools/overview-update-error.test.mjs diff --git a/changelog.d/20261014-overview-update-errors.md b/changelog.d/20261014-overview-update-errors.md new file mode 100644 index 0000000..eeac22f --- /dev/null +++ b/changelog.d/20261014-overview-update-errors.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Failed server updates show an error on the Overview screen +--- +The error stays visible after the progress bar disappears, with technical details and a copy button. Recovery-state errors are shown there too. diff --git a/crates/coa-core/src/mysql_snapshot.rs b/crates/coa-core/src/mysql_snapshot.rs index c352c78..0e078a1 100644 --- a/crates/coa-core/src/mysql_snapshot.rs +++ b/crates/coa-core/src/mysql_snapshot.rs @@ -420,6 +420,8 @@ mod tests { let mut server = start(); db.query("CREATE DATABASE acore_characters; CREATE TABLE acore_characters.shutdown_probe(id INT PRIMARY KEY); INSERT INTO acore_characters.shutdown_probe VALUES(73);").unwrap(); db.query("CREATE DATABASE acore_auth; CREATE DATABASE acore_world; CREATE VIEW acore_characters.shutdown_view AS SELECT id FROM acore_characters.shutdown_probe;").unwrap(); + let copied_configuration = serde_json::json!({"root":temp.path().join("original-installation"),"ports":{"mysqlPort":ports[0],"authPort":ports[1],"worldPort":ports[2],"raPort":ports[3]}}); + fsx::atomic_write_json(&root.join(".state/configuration.json"), &copied_configuration).unwrap(); let meta = crate::registry::metadata_dir_for(&root).unwrap(); crate::registry::MetaDir::create(&root, &crate::registry::InstallMeta::new(crate::registry::InstallKind::New, &root)).unwrap(); let point = crate::backup::create(&root, &meta, crate::backup::Kind::Full, crate::backup::Trigger::BeforeRepair, None, &|_| {}).unwrap(); @@ -428,6 +430,8 @@ mod tests { crate::update::RepackEnv { root: &root, meta_dir: &meta }.verify_recovery_point(&point).unwrap(); assert!(server.0.wait().unwrap().success()); assert_eq!(fs::read(root.join("Settings/worldserver.conf.template")).unwrap(), original); + assert_eq!(fsx::read_json::(&root.join(".state/configuration.json")).unwrap(), copied_configuration); + fs::remove_file(root.join(".state/configuration.json")).unwrap(); let mut restarted = start(); assert_eq!(db.query("SELECT id FROM acore_characters.shutdown_probe;").unwrap(), "73"); fsx::atomic_write_json(&root.join(".state/mysql.json"), &crate::process::identity(std::process::id()).unwrap()).unwrap(); diff --git a/docs/STABILIZATION-20261014.md b/docs/STABILIZATION-20261014.md index 74c153e..903c739 100644 --- a/docs/STABILIZATION-20261014.md +++ b/docs/STABILIZATION-20261014.md @@ -64,6 +64,21 @@ for startup readiness, after executable/port/creation-time checks. These are package/SQL/recovery tests on representative minimal database tables; they do not claim successful client login, combat or an affected user's installation. +## Overview update failure at 2% + +The additional screenshot reports a failed StopAll inside a private rehearsal, +with the launcher's moved-folder/changed-port guard, followed by a disappearing +progress bar on Overview. The shutdown adapter already bypasses startup preparation. +The native recovery test now also supplies a copied configuration signature with +an old installation path, verifies shutdown preserves it, and retains its database +row/view recovery assertions. + +Overview previously rendered only its local start/stop failure, ignoring stored +update and recovery-state errors. It now displays those errors with the existing +technical-details and copy controls, even when the update offer and progress bar +are gone. A new update attempt clears an older local action error. Render tests +reproduced the missing alerts before the fix; all 12 frontend tests pass afterward. + ## Validation - Workspace tests, frontend build, frontend logic tests, translation validation, diff --git a/src/screens/Overview.tsx b/src/screens/Overview.tsx index b0f05e1..643bdab 100644 --- a/src/screens/Overview.tsx +++ b/src/screens/Overview.tsx @@ -99,7 +99,13 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o const [keepMine, setKeepMine] = useState(true); const [perf, setPerf] = useState(null); const upd = useServerUpdate(server.id); + const visibleFailure = failure ?? upd.stateError ?? upd.uiError; const needsDecision = (upd.preview?.conflicts.length ?? 0) > 0; + const runServerUpdate = async () => { + setFailure(null); + setShowDetails(false); + await applyServerUpdate(server.id); + }; const [startBots, setStartBots] = useState(undefined); const [autoRestart, setAutoRestart] = useAutoRestart(server.id); const autoRestartRef = useRef(autoRestart); @@ -401,7 +407,7 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o variant="primary" size="xl" disabled={transitioning || upd.applying} - onClick={() => (needsDecision ? onOpenUpdates() : void applyServerUpdate(server.id))} + onClick={() => (needsDecision ? onOpenUpdates() : void runServerUpdate())} className="min-w-56" > {upd.applying && } @@ -424,7 +430,7 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o )} {running && upd.available && ( - @@ -587,21 +593,21 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o )} - {failure && ( + {visibleFailure && ( -

{human(failure.human).title}

-

{human(failure.human).message}

+

{human(visibleFailure.human).title}

+

{human(visibleFailure.human).message}

-
{showDetails && (
-              {failure.technical || t("common.noOutput")}
+              {visibleFailure.technical || t("common.noOutput")}
             
)}
diff --git a/tools/overview-update-error.test.mjs b/tools/overview-update-error.test.mjs new file mode 100644 index 0000000..0028fa8 --- /dev/null +++ b/tools/overview-update-error.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { rolldown } from "rolldown"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +const stubs = { + react: `export const useEffect=()=>{}; export const useCallback=f=>f; export const useRef=v=>({current:v}); export const useState=v=>[globalThis.__overviewStateCalls++===0?globalThis.__overviewStatus:v,()=>{}];`, + "@/lib/api": `export const api={}; export const asUiError=e=>e;`, + "@/lib/utils": `export const cn=(...s)=>s.filter(Boolean).join(' '); export const formatBytes=String; export const formatUptime=String;`, + "@/i18n": `export const useT=()=>k=>k; export const useHuman=()=>h=>h;`, + "@/lib/serverUpdate": `export const applyServerUpdate=async()=>false; export const useServerUpdate=()=>globalThis.__overviewUpdate;`, + "@/lib/clientJob": `export const dismissClientJob=()=>{}; export const startClientUpdate=()=>{}; export const stopClientJob=()=>{}; export const syncClient=()=>{}; export const useClientJob=()=>({phase:'idle'});`, + "@/lib/clientUpdate": `export const checkClient=()=>{}; export const useClientStatus=()=>({status:null});`, + "@/lib/autoRestartPrefs": `export const useAutoRestart=()=>[false,()=>{}];`, + "@/components/ui/button": `import {createElement} from 'react'; export const Button=({children,disabled,onClick,className})=>createElement('button',{disabled,onClick,className},children);`, + "@/components/ui/card": `import {createElement} from 'react'; export const Card=({children,...props})=>createElement('div',props,children);`, +}; +const bundle = await rolldown({ + input: fileURLToPath(new URL("../src/screens/Overview.tsx", import.meta.url)), + transform: { jsx: "react-jsx" }, + external: (id, importer) => id === "react/jsx-runtime" || id === "lucide-react" || (id === "react" && !importer?.endsWith("Overview.tsx")), + plugins: [{ name: "overview-test", + resolveId(id, importer) { + if (id.startsWith("@/") || (id === "react" && importer?.endsWith("Overview.tsx"))) return "\0" + id; + }, + load(id) { + if (!id.startsWith("\0")) return; + const name = id.slice(1); + return stubs[name] ?? `export const ${name.split('/').at(-1)}=()=>null;`; + }, + }], +}); +const result = await bundle.generate({ format: "cjs" }); +await bundle.close(); +const module = { exports: {} }; +new Function("require", "module", "exports", result.output[0].code)(createRequire(import.meta.url), module, module.exports); + +function overview(update) { + globalThis.__overviewStateCalls = 0; + globalThis.__overviewStatus = { path_exists: true, busy: false, observed: Object.fromEntries(["mysql", "auth", "world"].map(k => [k, { state: "stopped" }])) }; + globalThis.__overviewUpdate = { available: false, applying: false, preview: null, uiError: null, stateError: null, ...update }; + return renderToStaticMarkup(createElement(module.exports.Overview, { server: { id: "fixture", path: "C:/CoA_Server" }, onForget() {}, onOpenUpdates() {}, onRealmChanged() {}, onReport() {} })); +} +const failure = { human: { code: "unknown", title: "Server update failed", message: "See the technical details." }, technical: "Stop this repack before moving its folder or changing its ports." }; + +test("Overview displays failed updates after the progress and cached offer disappear", () => { + const html = overview({ uiError: failure }); + assert.match(html, /role="alert"/); + assert.match(html, /Server update failed/); + assert.match(html, /common.viewDetails/); + assert.match(html, /common.copyError/); +}); +test("Overview displays update-state failures even without an update offer", () => { + assert.match(overview({ stateError: failure }), /Server update failed/); +}); +test("Overview does not display an update failure when there is none", () => { + assert.doesNotMatch(overview({}), /Server update failed/); +}); diff --git a/tools/server-update-refresh.test.mjs b/tools/server-update-refresh.test.mjs index 1eaea60..2132b30 100644 --- a/tools/server-update-refresh.test.mjs +++ b/tools/server-update-refresh.test.mjs @@ -76,3 +76,17 @@ test("a rejected recheck removes an earlier cached update offer", async () => { assert.equal(useServerUpdate("stale-offer").preview, null); } finally { globalThis.__hotfixApi.checkUpdate = original; } }); + +test("a successful recheck keeps the last failed update details visible", async () => { + applyFailure = true; + stateFailure = false; + pending = null; + try { + assert.equal(await applyServerUpdate("visible-error", { choices: {} }), false); + await checkServerUpdate("visible-error"); + const state = useServerUpdate("visible-error"); + assert.equal(state.applying, false); + assert.equal(state.available, false); + assert.equal(state.uiError.technical, "SQL failure"); + } finally { applyFailure = false; } +}); From 52b80232fa91f8954eeb3ad8e99712534a52ad0a 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: Wed, 14 Oct 2026 18:57:16 +0500 Subject: [PATCH 54/62] Install CoA Custom 1.7 overlays with complete legacy race assets --- .../20261014-custom-races-grummle-client.md | 7 +++ crates/coa-core/src/custom_races.rs | 28 ++++++++-- crates/coa-core/src/custom_races_hashes.json | 7 ++- .../coa-core/src/custom_races_hashes_1_6.json | 52 +++++++++++++++++++ 4 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 changelog.d/20261014-custom-races-grummle-client.md create mode 100644 crates/coa-core/src/custom_races_hashes_1_6.json diff --git a/changelog.d/20261014-custom-races-grummle-client.md b/changelog.d/20261014-custom-races-grummle-client.md new file mode 100644 index 0000000..b2c904b --- /dev/null +++ b/changelog.d/20261014-custom-races-grummle-client.md @@ -0,0 +1,7 @@ +--- +area: manager +type: changed +audience: players +title: Install the matching client patches for Grummle +--- +Custom race installation includes the new Grummle appearance and voices while retaining the existing race assets. Older installations can still be safely switched off or removed. diff --git a/crates/coa-core/src/custom_races.rs b/crates/coa-core/src/custom_races.rs index 32f2e85..ac0c8da 100644 --- a/crates/coa-core/src/custom_races.rs +++ b/crates/coa-core/src/custom_races.rs @@ -14,7 +14,7 @@ use crate::download::Cancel; use crate::error::{Error, Result}; use crate::fsx; -pub const CLIENT_VERSION: &str = "1.6"; +pub const CLIENT_VERSION: &str = "1.7"; pub const MANIFEST_FILENAME: &str = "custom_races_patch_manifest.txt"; pub const ARCHIVES: &[(&str, &str)] = &[ ( @@ -29,6 +29,10 @@ pub const ARCHIVES: &[(&str, &str)] = &[ "CoA-Custom-1.6-client-part2.zip", "https://github.com/ilusixn/azerothcore-wotlk-coa/releases/download/coa-custom-1.6/CoA-Custom-1.6-client-part2.zip", ), + ( + "CoA-Custom-1.7-client.zip", + "https://github.com/ilusixn/azerothcore-wotlk-coa/releases/download/coa-custom-1.7/CoA-Custom-1.7-client.zip", + ), ]; pub const RACE_MPQS: &[&str] = &[ @@ -54,6 +58,9 @@ pub const RACE_MPQS: &[&str] = &[ "patch-ZZR.MPQ", "patch-ZZS.MPQ", "patch-ZZT.MPQ", + "patch-ZZU.MPQ", + "patch-ZZV.MPQ", + "patch-ZZW.MPQ", ]; pub const EXCLUDED_BOT_RACES_RECOMMENDED: &str = @@ -164,7 +171,7 @@ struct PatchFile { fn patch_files(client: &Path) -> Result> { let manifest = fs::read_to_string(client.join(MANIFEST_FILENAME)).unwrap_or_default(); - let hashes = if manifest.lines().any(|line| line == "# CoA Custom 1.5") { include_str!("custom_races_hashes_1_5.json") } else { include_str!("custom_races_hashes.json") }; + let hashes = if manifest.lines().any(|line| line == "# CoA Custom 1.5") { include_str!("custom_races_hashes_1_5.json") } else if manifest.lines().any(|line| line == "# CoA Custom 1.6") { include_str!("custom_races_hashes_1_6.json") } else { include_str!("custom_races_hashes.json") }; let bundled: std::collections::BTreeMap = serde_json::from_str(hashes)?; let mut files = std::collections::BTreeMap::new(); for line in manifest.lines() { @@ -367,6 +374,7 @@ fn verify_archive(path: &Path, filename: &str) -> Result<()> { "CoA-Custom-1.6-main-20261012-4dd3a4ec.zip" => "dfe802ea70047c129ac2e0aaa557bab3110e3cc6926d36403b8c880a0e310ee4", "CoA-Custom-1.6-client-part1.zip" => "1dbf1901ad5be61eba66973609df90712ffc78ef9e9b2c8549a5f3e36332bf12", "CoA-Custom-1.6-client-part2.zip" => "689f7f946b4180fe977dabedc7cfe642e3e7f27431600c64c4dd5b7960b1bc66", + "CoA-Custom-1.7-client.zip" => "ad389ef486f44f269445f80807b25b214c39fc5d21272b5708bfeac8b1efefa0", _ => return Err(Error::Invalid("Unknown custom races archive.".into())), }; if fsx::sha256_file(path)? != expected { @@ -666,7 +674,7 @@ mod tests { } #[test] - #[ignore = "Requires the three downloaded release archives"] + #[ignore = "Requires the downloaded release archives"] fn bundled_hashes_match_verified_release_archives() { use sha2::{Digest, Sha256}; let folder = PathBuf::from(std::env::var_os("COA_RACES_ARCHIVE_DIR").expect("COA_RACES_ARCHIVE_DIR")); @@ -705,6 +713,20 @@ mod tests { assert!(!files.iter().any(|file| file.path == "Data/patch-ZZS.MPQ")); } + #[test] + fn previous_release_uses_its_own_hashes_for_upgrade_and_removal() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join(MANIFEST_FILENAME), "# CoA Custom 1.6\n").unwrap(); + let legacy: std::collections::BTreeMap = serde_json::from_str(include_str!("custom_races_hashes_1_6.json")).unwrap(); + let current: std::collections::BTreeMap = serde_json::from_str(include_str!("custom_races_hashes.json")).unwrap(); + let files = patch_files(temp.path()).unwrap(); + assert_eq!(files.len(), legacy.len()); + assert_eq!(files.iter().find(|file| file.path == "Data/patch-T.MPQ").unwrap().sha256, legacy["Data/patch-T.MPQ"]); + assert_ne!(legacy["Data/patch-T.MPQ"], current["Data/patch-T.MPQ"]); + assert!(!files.iter().any(|file| file.path == "Data/patch-ZZW.MPQ")); + assert!(legacy.keys().all(|key| current.contains_key(key))); + } + #[test] fn another_patch_operation_cannot_enter_the_same_client() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/coa-core/src/custom_races_hashes.json b/crates/coa-core/src/custom_races_hashes.json index 9baa919..dbd0dbf 100644 --- a/crates/coa-core/src/custom_races_hashes.json +++ b/crates/coa-core/src/custom_races_hashes.json @@ -27,7 +27,7 @@ "EsteriaVulpera.bin": "c5434993b9af4a17d881965e4735cd2ac379e39c7142aac9844adff9528562d4", "EsteriaVulperaTextures.bin": "1ab844e74684aced2b069f1b91cd563aade45e05bd5e23f866a6c0c301cb098e", "dinput8.dll": "f8de78606c241fb3911dce22faf68f5b9f9885b142b183c1e2c09ddc8f7e380c", - "Data/patch-T.MPQ": "9403e0680c74754eb0cb063718119aa6ace3616f7cecf488c8faf46f22505ae5", + "Data/patch-T.MPQ": "fe597138ab7f56881b5f4f27d6dc9d81bb278b483ca0d91b7207ffd4a5ae7bfd", "Data/patch-ZE5.MPQ": "476b5a1fcf69f64fdb281e3b445362b936d016066ccd12f897acfcf200d6c0cf", "Data/patch-ZE6.MPQ": "762592c9ed950b4a7b6c7d85d45050b30fa3f35d53e348ee1e1516fdbb7e5484", "Data/patch-ZE7.MPQ": "de54b0189d242763d2872223419a259b6b8a4b380c24b50a2ef9c6fa44466248", @@ -48,5 +48,8 @@ "Data/patch-ZZE.MPQ": "4b1e289e12c82644cb78f50170e6b5fd53918eb2de834f9e0cebddd81873a8bb", "Data/patch-ZZR.MPQ": "5a7575e592dc0c8a6913e0d946bbe06fa0ca2e4d817ef1f8548d3d13142bcbd3", "Data/patch-ZZS.MPQ": "533fd69d5784d83e872a5a28d27301dc9ab5d0cb925f8b2116926854e4a0a5b8", - "Data/patch-ZZT.MPQ": "aef3bbe4951333d62fb55224484724fbaa9c35773a19944ffb9935e250cfb59c" + "Data/patch-ZZT.MPQ": "aef3bbe4951333d62fb55224484724fbaa9c35773a19944ffb9935e250cfb59c", + "Data/patch-ZZU.MPQ": "8ee1839415b56d46c6eff7eb4dfbaf6fbafc4c6b29b39380d69f4d827d02a99e", + "Data/patch-ZZV.MPQ": "8d5e61d37008abf70478e5d5f46ec7afb0490b9a643323bd75a1c70cadd63290", + "Data/patch-ZZW.MPQ": "ed452a0a0118c9f4deaf02db753064ac95b6e4a84022bdabd39a7e70d775223f" } diff --git a/crates/coa-core/src/custom_races_hashes_1_6.json b/crates/coa-core/src/custom_races_hashes_1_6.json new file mode 100644 index 0000000..9baa919 --- /dev/null +++ b/crates/coa-core/src/custom_races_hashes_1_6.json @@ -0,0 +1,52 @@ +{ + "Ascension.exe": "e2c347019d554a59639e5978fd68e50e906973ab313c6573cc56ca82e8e3c547", + "EsteriaAppearance.bin": "b3fb7c69b7600c39f888ea1925701be5667097dcb02bb6ddd830e5489306bb50", + "EsteriaAppearance.dll": "392c5a2a25f9a69d705bdf841bb899aab768d4a3ee62734d576286c7198710d8", + "EsteriaAppearanceGeometry.bin": "c95c53ca19c53c3434e12b99c1966f9c015ec3944c46dff7784d5e4ffc472d5d", + "EsteriaAppearanceMaterials.bin": "f72f027f54a55c05aa0f76b457b97b484fe0e1aedc0e068cc0e0ca53e1878cba", + "EsteriaCosmeticWings.bin": "57352c28459d073a29b3014d33b70f3b3756afa37fa9583ef6b30d503422ac3f", + "EsteriaEarthen.bin": "8a0db93ba2d5c756b44ed84f2f7bc7c15eef13be6a7d87baf1383c6c0c624e02", + "EsteriaGnoll.bin": "cfacc8e681bb8e54a4f01ee9c12a91bf0bd23a23527bb548d4dc21ec1b33eea2", + "EsteriaGnollTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605", + "EsteriaHaranir.bin": "59f31d00e372fd2646e1cf0d9d3d5d671b31edd60ff7b862f8d1ce7e53ba1f2f", + "EsteriaHaranirTextures.bin": "99948ca570be2d376f0c67e3b8088739b6fa9f9d2c73985e19f68df77689a229", + "EsteriaHighmountain.bin": "e83349252ad2687ac23fcfc5623db7401cf32e5faf47a5a7be20c2f94b014938", + "EsteriaNaga.bin": "e87547cff5d09500c294f8b06935d134cdd0bbb962e294a72de09404505bd8b3", + "EsteriaNagaTextures.bin": "57ec560f99c1d48ac1e8f6a797c156513b99e7d349e3071afe405f812d159ae3", + "EsteriaSaberon.bin": "7735c9325c807c5125760d87f323d6b7d4162fb542c1d8b5f435ea2d6b9a4daa", + "EsteriaSaberonCoverage.bin": "9cdc227f08329e1245d2b864a996e7a772c96ecdbe5d81925f4d60cdbe4fa11e", + "EsteriaSaberonTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605", + "EsteriaSethrak.bin": "bd14857a8c11fdf8027128763293701586b2102f9a6539d33a727014789fd606", + "EsteriaSethrakTextures.bin": "d2b2cf58087a391f00198008fe871edd400ddf28d54a3a05674df74c5198528a", + "EsteriaThinHuman.bin": "75794d192ea0c228b3da7bf7d0bdff1119f79d3f9bfef6650cd5e1a2b5d106eb", + "EsteriaThinHumanTextures.bin": "02aeb4c05893d06125b8b58fdb7c2a56fe3d66a7d094706a44b43cb503d1731e", + "EsteriaTuskarr.bin": "ecbe7ef490ba67eca029a57639192db61238522618491549766fe38529914387", + "EsteriaTuskarrTextures.bin": "e2116c2c83adea079126fd5da765d20da24f044d4f161c4124e4368cb3d6bc71", + "EsteriaVrykul.bin": "39e2b7b4c67997dbbd34bae840bd606a59290f7f2fc82565a0ba00090335415c", + "EsteriaVrykulTextures.bin": "46098750a0e235724c6e9cc8d9d4e26550aa47bef95fd5f0f82efe5e945bf296", + "EsteriaVulpera.bin": "c5434993b9af4a17d881965e4735cd2ac379e39c7142aac9844adff9528562d4", + "EsteriaVulperaTextures.bin": "1ab844e74684aced2b069f1b91cd563aade45e05bd5e23f866a6c0c301cb098e", + "dinput8.dll": "f8de78606c241fb3911dce22faf68f5b9f9885b142b183c1e2c09ddc8f7e380c", + "Data/patch-T.MPQ": "9403e0680c74754eb0cb063718119aa6ace3616f7cecf488c8faf46f22505ae5", + "Data/patch-ZE5.MPQ": "476b5a1fcf69f64fdb281e3b445362b936d016066ccd12f897acfcf200d6c0cf", + "Data/patch-ZE6.MPQ": "762592c9ed950b4a7b6c7d85d45050b30fa3f35d53e348ee1e1516fdbb7e5484", + "Data/patch-ZE7.MPQ": "de54b0189d242763d2872223419a259b6b8a4b380c24b50a2ef9c6fa44466248", + "Data/patch-ZE8.MPQ": "1bc5691fb84a84f3e7a0a70baaa9192a97b4ec5906ee37c85838ffe08adef934", + "Data/patch-ZE9.MPQ": "d49c94463e9548e76378ce2c0454d8f204f0745c8c0de32b6dce7f0b0fd67c4a", + "Data/patch-ZES.MPQ": "a2d9348f917f745c7aa25b2eed96efa312f7b2d2f62cb86a4f2eaabc2dfa895f", + "Data/patch-ZG.MPQ": "48979e1fda775f8deb47bffa48e0a468595d4c71286fee518f51dc5801c5ca39", + "Data/patch-ZH.MPQ": "5e1d2fe287053abba703d7af381e10096c6525ba477ffed3971c2fc4f93e919c", + "Data/patch-ZHM.MPQ": "317d3ae00f0bd674b358af07544976acb7804aa6d8b19d1788ad96030e8c9bf0", + "Data/patch-ZJ.MPQ": "f821e99add86ef68d108c5ec466328c97677d3ed52f7944e200f3fe38c13b4de", + "Data/patch-ZM.MPQ": "424ab1978986e854551633983a94bd15beba6328aabea8c02f98eaa32f6f0b61", + "Data/patch-ZVU.MPQ": "c679b85a2e97344395756b54d2cf6abefe4e80e74944cb55b6e573ad17b7d020", + "Data/patch-ZE1.MPQ": "69d79dbbea54ec0312c37770debc79c7bc9ce2e21eedef99c03cd8d911955283", + "Data/patch-ZE2.MPQ": "1a41cae77ad730e9cf69e8d63fd12ae3a579418bb550d351fff469573a97ac3c", + "Data/patch-ZE3.MPQ": "b587202c21b6f580682b601b7c2e905fbfa766276e74733db55c1bddbffb7f87", + "Data/patch-ZEA.MPQ": "78fd10ec5262ae2417eba9700d2172553bbc1e5cfb0b597aada8f1753ca46d9d", + "Data/patch-ZHA.MPQ": "0fc6ebeafe1d14192dc64bafc90bde3c49230df1297f98bf65900a3bcf953617", + "Data/patch-ZZE.MPQ": "4b1e289e12c82644cb78f50170e6b5fd53918eb2de834f9e0cebddd81873a8bb", + "Data/patch-ZZR.MPQ": "5a7575e592dc0c8a6913e0d946bbe06fa0ca2e4d817ef1f8548d3d13142bcbd3", + "Data/patch-ZZS.MPQ": "533fd69d5784d83e872a5a28d27301dc9ab5d0cb925f8b2116926854e4a0a5b8", + "Data/patch-ZZT.MPQ": "aef3bbe4951333d62fb55224484724fbaa9c35773a19944ffb9935e250cfb59c" +} From 97c024d95c6d87fb58bb77cb1f60273afcdc21bb 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: Wed, 14 Oct 2026 19:22:45 +0500 Subject: [PATCH 55/62] Install current stable full images and qualify clean installation --- changelog.d/20261014-current-clean-install.md | 7 ++ crates/coa-core/src/cleanbase.rs | 2 +- crates/coa-core/src/install.rs | 4 + crates/coa-core/src/pkgsource.rs | 43 ++++++++ crates/coa-core/src/release_schema.rs | 99 ++++++++++++++++++- crates/coa-release/src/main.rs | 18 ++++ docs/STABILIZATION-20261014.md | 2 + src-tauri/src/lib.rs | 11 ++- src/dev/mock.ts | 2 +- 9 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 changelog.d/20261014-current-clean-install.md diff --git a/changelog.d/20261014-current-clean-install.md b/changelog.d/20261014-current-clean-install.md new file mode 100644 index 0000000..83f0d6e --- /dev/null +++ b/changelog.d/20261014-current-clean-install.md @@ -0,0 +1,7 @@ +--- +area: manager +type: changed +audience: admins +title: New servers install the current stable build directly +--- +A clean installation downloads the complete package of the selected stable release, including its prepared database. It no longer installs an old base and asks for a separate update. Missing or mismatched full packages stop installation before changing the destination. diff --git a/crates/coa-core/src/cleanbase.rs b/crates/coa-core/src/cleanbase.rs index 44cef0b..37470fb 100644 --- a/crates/coa-core/src/cleanbase.rs +++ b/crates/coa-core/src/cleanbase.rs @@ -24,7 +24,7 @@ pub struct Params<'a> { } /// Auth tables that hold player data. Everything else (permissions, build info, realmlist) is configuration. -const AUTH_PLAYER_TABLES: [&str; 11] = +pub(crate) const AUTH_PLAYER_TABLES: [&str; 11] = ["account", "account_access", "account_banned", "account_muted", "account_password_history", "ip_banned", "logs", "logs_ip_actions", "realmcharacters", "uptime", "rbac_account_permissions"]; fn copy_dir(src: &Path, dst: &Path, skip: &dyn Fn(&str) -> bool, base: &Path) -> Result { diff --git a/crates/coa-core/src/install.rs b/crates/coa-core/src/install.rs index 2d88ee2..e9e8915 100644 --- a/crates/coa-core/src/install.rs +++ b/crates/coa-core/src/install.rs @@ -201,6 +201,10 @@ fn bootstrap_database_inner(root: &Path) -> Result<()> { sql.push_str("FLUSH PRIVILEGES;\n"); db.query(&sql)?; fsx::atomic_write_json(&target, &serde_json::json!({ "rootPassword": root_pw, "appPassword": app_pw }))?; + fsx::atomic_write(&root.join("mysql/admin-client.ini"), format!( + "[client]\nuser=root\npassword={root_pw}\nhost=127.0.0.1\nport={}\nprotocol=tcp\ndefault-character-set=utf8mb4\n", + crate::layout::read_ports(root).mysql, + ).as_bytes())?; // The server console gets its own account with a random password instead of a shared default. let console_pw = Db::from_repack(root, Account::Admin)?.provision_service_account()?; // new root password db::write_console_credentials(root, &console_pw)?; diff --git a/crates/coa-core/src/pkgsource.rs b/crates/coa-core/src/pkgsource.rs index 85fac7a..8d3f669 100644 --- a/crates/coa-core/src/pkgsource.rs +++ b/crates/coa-core/src/pkgsource.rs @@ -14,6 +14,7 @@ use crate::signing; /// Where a package comes from. #[derive(Debug, Clone)] pub enum Source { + Installation(Box), /// `https://.../` base URL holding manifest.json, manifest.json.sig and the parts. Url(String), /// A folder produced by `package::build` plus its `manifest.json.sig` (offline / local installs). @@ -23,6 +24,7 @@ pub enum Source { /// Read a small file (manifest, signature) from the source. pub fn fetch_small(source: &Source, name: &str) -> Result> { match source { + Source::Installation(source) => fetch_small(source, &if matches!(name, "manifest.json" | "manifest.json.sig") { format!("install-{name}") } else { name.into() }), Source::Dir(d) => Ok(fs::read(fsx::safe_join(d, name)?).map_err(|_| Error::Invalid(format!("{name} was not found in the package folder.")))?), Source::Url(base) => { let url = format!("{}/{name}", base.trim_end_matches('/')); @@ -48,6 +50,14 @@ pub fn fetch_manifest(source: &Source, trusted_key: &str) -> Result<(Manifest, V let sig = String::from_utf8_lossy(&fetch_small(source, "manifest.json.sig")?).into_owned(); signing::verify(&bytes, &sig, trusted_key)?; let manifest = Manifest::parse(&bytes)?; + if let Source::Installation(release) = source { + let (update, _) = fetch_manifest(release, trusted_key)?; + if manifest.kind != crate::manifest::Kind::Base || update.kind != crate::manifest::Kind::Update || manifest.version != update.version + || manifest.core.commit != update.core.commit + || manifest.bots.as_ref().and_then(|value| value.commit.as_ref()) != update.bots.as_ref().and_then(|value| value.commit.as_ref()) { + return Err(Error::InvalidManifest("The full installation package does not match the selected server release.".into())); + } + } Ok((manifest, bytes)) } @@ -68,6 +78,7 @@ pub fn human_bytes(n: u64) -> String { pub fn fetch_parts(source: &Source, manifest: &Manifest, download_dir: &Path, cancel: &Cancel, report: &dyn Fn(f64, Option)) -> Result { let archive = manifest.archive.as_ref().ok_or_else(|| Error::InvalidManifest("manifest has no archive".into()))?; match source { + Source::Installation(source) => fetch_parts(source, manifest, download_dir, cancel, report), Source::Dir(d) => Ok(d.clone()), Source::Url(base) => { let all: u64 = archive.parts.iter().map(|p| p.size).sum::().max(1); @@ -90,6 +101,38 @@ pub fn fetch_parts(source: &Source, manifest: &Manifest, download_dir: &Path, ca mod tests { use super::*; + #[test] + fn clean_installation_requires_the_signed_full_image_of_the_selected_release() { + use base64::{engine::general_purpose::STANDARD, Engine}; + use ed25519_dalek::{Signer, SigningKey}; + let folder = tempfile::tempdir().unwrap(); + let signing_key = SigningKey::from_bytes(&[23; 32]); + let key = STANDARD.encode(signing_key.verifying_key().to_bytes()); + let source = Source::Installation(Box::new(Source::Dir(folder.path().into()))); + let mut manifest = serde_json::json!({"schema": 1, "kind": "update", "version": "0.261014.1", + "core": {"commit": "a".repeat(40)}, "builtAt": "test", "minManagerVersion": "0.6.16", + "files": [], "migrations": [], "archive": null}); + let write = |name: &str, value: &serde_json::Value| { + let bytes = serde_json::to_vec(value).unwrap(); + fs::write(folder.path().join(name), &bytes).unwrap(); + fs::write(folder.path().join(format!("{name}.sig")), STANDARD.encode(signing_key.sign(&bytes).to_bytes())).unwrap(); + }; + write("manifest.json", &manifest); + assert!(fetch_manifest(&source, &key).is_err()); + manifest["kind"] = "base".into(); + write("install-manifest.json", &manifest); + assert_eq!(fetch_manifest(&source, &key).unwrap().0.version, "0.261014.1"); + manifest["version"] = "0.2.0".into(); + write("install-manifest.json", &manifest); + assert!(fetch_manifest(&source, &key).is_err()); + manifest["version"] = "0.261014.1".into(); + manifest["core"]["commit"] = "b".repeat(40).into(); + write("install-manifest.json", &manifest); + assert!(fetch_manifest(&source, &key).is_err()); + fs::write(folder.path().join("install-manifest.json"), b"tampered").unwrap(); + assert!(fetch_manifest(&source, &key).unwrap_err().to_string().contains("signature")); + } + #[test] fn sizes_are_shown_in_binary_units() { assert_eq!(human_bytes(0), "0 B"); diff --git a/crates/coa-core/src/release_schema.rs b/crates/coa-core/src/release_schema.rs index a9bde6d..16696ce 100644 --- a/crates/coa-core/src/release_schema.rs +++ b/crates/coa-core/src/release_schema.rs @@ -1,5 +1,6 @@ //! Database acceptance for release packages, using a freshly extracted signed base fixture. use std::collections::HashSet; +use std::fs; use std::path::Path; use crate::{backup, fsx, install, manifest::{Kind, Migration}, migrations, package, pkgsource, release::{self, SqlFile}, schema_check, Error, Result}; @@ -90,6 +91,66 @@ pub fn qualify_upgrade(base: &Path, source: &Path, candidate: &Path, fixture: &P })) } +pub fn compose_install(base: &Path, candidate: &Path, fixture: &Path, trusted_key: &str) -> Result<()> { + qualify_upgrade(base, base, candidate, fixture, trusted_key)?; + let fixture = fsx::canonicalize_lenient(fixture)?; + let (_, bytes) = pkgsource::fetch_manifest(&pkgsource::Source::Dir(candidate.into()), trusted_key)?; + let manifest = crate::manifest::Manifest::parse(&bytes)?; + install::with_scratch_ports(&fixture, || backup::with_database(&fixture, |db| { + let players = db.query(&format!("SELECT COUNT(*) FROM acore_characters.characters WHERE account<>{};", crate::companions::TEMPLATE_ACCOUNT))?; + if players.trim() != "0" || crate::companions::template_count(db)? != 21 { + return Err(Error::Invalid("The installation image contains unexpected player characters.".into())); + } + for table in crate::cleanbase::AUTH_PLAYER_TABLES { + if db.tables("acore_auth")?.iter().any(|name| name == table) { + db.query(&format!("DELETE FROM acore_auth.`{table}`;"))?; + } + } + if let Some(problem) = schema_check::check(db, &fixture)?.first() { + return Err(Error::Invalid(format!("Installation image schema mismatch: {}.{}", problem.table, problem.column))); + } + Ok(()) + }))?; + fs::remove_file(fixture.join("Core/configs/coa-qualification-user.conf"))?; + let path = fixture.join("Settings/repack.json"); + let mut settings: serde_json::Value = fsx::read_json(&path)?; + settings["raPassword"] = "".into(); + fsx::atomic_write_json(&path, &settings)?; + fsx::atomic_write_json(&fixture.join("Settings/install-source.json"), &serde_json::json!({ + "version": manifest.version, "core": manifest.core, "bots": manifest.bots, + "updateManifestSha256": fsx::sha256_bytes(&bytes), + }))?; + fs::remove_file(fixture.join(MARKER))?; + Ok(()) +} + +pub fn qualify_install(package: &Path, fixture: &Path, trusted_key: &str) -> Result { + use crate::update::Env; + if !cfg!(windows) || fixture.exists() + || !fixture.file_name().is_some_and(|name| name.to_string_lossy().starts_with("coa-schema-fixture-")) { + return Err(Error::Invalid("Clean installation qualification requires a new disposable Windows fixture.".into())); + } + let fixture = std::path::absolute(fixture)?; + let package = fsx::canonicalize_lenient(package)?; + let (manifest, bytes) = pkgsource::fetch_manifest(&pkgsource::Source::Dir(package.clone()), trusted_key)?; + let registry_dir = tempfile::tempdir()?; + let registry = crate::registry::Registry::at(registry_dir.path().join("registry.json")); + let installed = install::install_base(&install::Params { + source: pkgsource::Source::Dir(package), dest: fixture.clone(), trusted_key, + registry: ®istry, cancel: crate::download::Cancel::default(), + }, &|_| {})?; + let metadata = crate::registry::metadata_dir_for(&fixture)?; + let env = QualificationEnv(crate::update::RepackEnv { root: &fixture, meta_dir: &metadata }); + env.validate()?; + env.ensure_stopped()?; + if installed.version != manifest.version || crate::update::pending_checked(&metadata)?.is_some() { + return Err(Error::Invalid("Clean installation did not finish at the packaged release.".into())); + } + Ok(serde_json::json!({ "schema": 1, "result": "passed", "version": installed.version, + "manifestSha256": fsx::sha256_bytes(&bytes), + "checks": ["clean-install", "signed-full-package", "credential-rotation", "schema-contract", "private-auth-world-startup"] })) +} + 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)?); } @@ -221,7 +282,15 @@ pub fn validate_startup(fixture: &Path, tree: &Path, base: &Path, trusted_key: & } pub fn verify_package(dir: &Path, trusted_key: &str) -> Result<()> { - let (manifest, _) = pkgsource::fetch_manifest(&pkgsource::Source::Dir(dir.into()), trusted_key)?; + verify_package_source(dir, &pkgsource::Source::Dir(dir.into()), trusted_key) +} + +pub fn verify_install_package(dir: &Path, trusted_key: &str) -> Result<()> { + verify_package_source(dir, &pkgsource::Source::Installation(Box::new(pkgsource::Source::Dir(dir.into()))), trusted_key) +} + +fn verify_package_source(dir: &Path, source: &pkgsource::Source, trusted_key: &str) -> Result<()> { + let (manifest, _) = pkgsource::fetch_manifest(source, trusted_key)?; if !manifest.files.iter().any(|f| f.path == schema_check::CONTRACT && f.size > 0) { return Err(Error::Invalid("The release is missing its database schema contract.".into())); } @@ -250,6 +319,34 @@ mod tests { use base64::{engine::general_purpose::STANDARD, Engine}; use ed25519_dalek::{Signer, SigningKey}; + #[test] + #[ignore = "Requires a composed disposable installation image"] + fn real_full_package_installs_the_current_version_without_an_update_transaction() { + let image = std::path::PathBuf::from(std::env::var_os("COA_INSTALL_IMAGE_DIR").expect("COA_INSTALL_IMAGE_DIR")); + let provenance: serde_json::Value = fsx::read_json(&image.join("Settings/install-source.json")).unwrap(); + let root = tempfile::tempdir().unwrap(); + let package = root.path().join("package"); + let options = package::BuildOptions { + kind: Kind::Base, version: provenance["version"].as_str().unwrap().into(), + core_commit: provenance["core"]["commit"].as_str().map(String::from), + bots_commit: provenance["bots"]["commit"].as_str().map(String::from), + built_at: "qualification".into(), part_size: package::DEFAULT_PART_SIZE, migrations: vec![], + }; + package::build(&image, &package, &options, &|_| {}).unwrap(); + let signing_key = SigningKey::generate(&mut rand_core::OsRng); + let bytes = fs::read(package.join("manifest.json")).unwrap(); + fs::write(package.join("manifest.json.sig"), STANDARD.encode(signing_key.sign(&bytes).to_bytes())).unwrap(); + let trusted_key = STANDARD.encode(signing_key.verifying_key().to_bytes()); + let fixture = root.path().join("coa-schema-fixture-clean-install"); + let report = qualify_install(&package, &fixture, &trusted_key).unwrap(); + assert_eq!(report["version"], provenance["version"]); + assert_eq!(report["result"], "passed"); + let metadata = crate::registry::metadata_dir_for(&fixture).unwrap(); + let (_, installed) = crate::registry::MetaDir::open(&metadata).unwrap(); + assert_eq!(installed.core.version.as_deref(), provenance["version"].as_str()); + assert!(!metadata.join("updates").read_dir().is_ok_and(|mut entries| entries.next().is_some())); + } + #[test] fn relative_startup_fixture_targets_replace_the_original_location() { let directory = tempfile::Builder::new().prefix("coa-schema-fixture-path-test-").tempdir_in(".").unwrap(); diff --git a/crates/coa-release/src/main.rs b/crates/coa-release/src/main.rs index 9dddaf2..0b08488 100644 --- a/crates/coa-release/src/main.rs +++ b/crates/coa-release/src/main.rs @@ -47,6 +47,24 @@ 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-install" => { + coa_core::release_schema::verify_install_package( + &PathBuf::from(need(&a, "dir")?), coa_core::signing::EMBEDDED_PUBLIC_KEY, + ).map_err(e)?; + } + "compose-install" => { + coa_core::release_schema::compose_install( + &PathBuf::from(need(&a, "base")?), &PathBuf::from(need(&a, "candidate")?), + &PathBuf::from(need(&a, "fixture")?), coa_core::signing::EMBEDDED_PUBLIC_KEY, + ).map_err(e)?; + } + "qualify-install" => { + let report = coa_core::release_schema::qualify_install( + &PathBuf::from(need(&a, "package")?), &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)?; + } "qualify-upgrade" => { let report = coa_core::release_schema::qualify_upgrade( &PathBuf::from(need(&a, "base")?), &PathBuf::from(need(&a, "source")?), diff --git a/docs/STABILIZATION-20261014.md b/docs/STABILIZATION-20261014.md index 903c739..3c07e44 100644 --- a/docs/STABILIZATION-20261014.md +++ b/docs/STABILIZATION-20261014.md @@ -107,3 +107,5 @@ reproduced the missing alerts before the fix; all 12 frontend tests pass afterwa This batch does not publish a Manager installer, promote a server package, close user reports or claim the complete fresh-install/update/gameplay matrix. + +Clean Windows installs now select the full image attached to the same signed stable release used by updates. They never fall back to the legacy base when the current full image is absent. Explicit offline/custom full packages remain supported, while Repair and release upgrade fixtures retain the legacy base source. The release worker composes and validates the complete current database image before publication; users do not run an intermediate base server or an update transaction. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index aa9337a..6656434 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -63,6 +63,13 @@ fn package_source(custom: Option) -> Source { } } +fn installation_source(custom: Option) -> Result { + let custom = custom.filter(|value| !value.trim().is_empty()) + .or_else(|| std::env::var("COA_PACKAGE_SOURCE").ok().filter(|value| !value.trim().is_empty())); + if custom.is_some() { return Ok(package_source(custom)); } + Ok(Source::Installation(Box::new(update_source(None)?))) +} + #[derive(Serialize)] struct ServerSummary { id: String, @@ -519,7 +526,7 @@ async fn install_requirements(package: Option) -> std::result::Result, dest: String, p } let cancel = Cancel::default(); *state.install_cancel.lock().map_err(|_| Error::Invalid("state poisoned".into()))? = Some(cancel.clone()); - let source = package_source(package); let registry = Registry::at(data_dir().join("installs.json")); let dest_path = PathBuf::from(dest); let result = tauri::async_runtime::spawn_blocking(move || { + let source = installation_source(package)?; install::install_base( &install::Params { source, dest: dest_path, trusted_key: coa_core::signing::EMBEDDED_PUBLIC_KEY, registry: ®istry, cancel }, &|step| { diff --git a/src/dev/mock.ts b/src/dev/mock.ts index 89b6b31..1b1159b 100644 --- a/src/dev/mock.ts +++ b/src/dev/mock.ts @@ -96,7 +96,7 @@ export {}; if(cmd==="set_game_data_folder") return args.path; if(cmd==="install_environment"){const docker=new URLSearchParams(location.search).get("flavor")==="docker";return docker?{flavor:"docker",default_dir:"/home/you/CoaServer",docker_problem:new URLSearchParams(location.search).has("nodocker")?"permission denied while trying to connect to the Docker daemon socket":null}:{flavor:"repack",default_dir:"C:\\Games\\CoA Server",docker_problem:null};} if(cmd==="install_preflight") return {ok:true,problems:[],free_bytes:210*2**30}; - if(cmd==="install_requirements") return {download_bytes:Math.round(5.9*2**30),unpacked_bytes:Math.round(11.4*2**30),version:"0.2.0"}; + if(cmd==="install_requirements") return {download_bytes:Math.round(5.9*2**30),unpacked_bytes:Math.round(11.4*2**30),version:"0.261014.1"}; if(cmd==="list_accounts"){const w=window as any;w.__acc=w.__acc??[{id:2,name:"ALICE",access:3,online:true,last_login:"2026-10-01 12:04:11",characters:4},{id:5,name:"BOB",access:0,online:false,last_login:null,characters:0},{id:6,name:"CAROL",access:2,online:false,last_login:"2026-09-30 21:40:02",characters:2}];return w.__acc;} if(cmd==="account_set_access"){const a=(window as any).__acc.find((x: any)=>x.name===args.name);if(a)a.access=args.level;return null;} if(cmd==="account_set_password") return null; From 46785f7978b546f6e6f7eb247b5ce50e61b51581 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: Wed, 14 Oct 2026 19:31:33 +0500 Subject: [PATCH 56/62] Stop the complete database before packaging or finishing qualification --- crates/coa-core/src/release_schema.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/coa-core/src/release_schema.rs b/crates/coa-core/src/release_schema.rs index 16696ce..bb6a44e 100644 --- a/crates/coa-core/src/release_schema.rs +++ b/crates/coa-core/src/release_schema.rs @@ -120,6 +120,7 @@ pub fn compose_install(base: &Path, candidate: &Path, fixture: &Path, trusted_ke "version": manifest.version, "core": manifest.core, "bots": manifest.bots, "updateManifestSha256": fsx::sha256_bytes(&bytes), }))?; + crate::mysql_snapshot::stop(&fixture)?; fs::remove_file(fixture.join(MARKER))?; Ok(()) } @@ -143,6 +144,7 @@ pub fn qualify_install(package: &Path, fixture: &Path, trusted_key: &str) -> Res let env = QualificationEnv(crate::update::RepackEnv { root: &fixture, meta_dir: &metadata }); env.validate()?; env.ensure_stopped()?; + crate::mysql_snapshot::stop(&fixture)?; if installed.version != manifest.version || crate::update::pending_checked(&metadata)?.is_some() { return Err(Error::Invalid("Clean installation did not finish at the packaged release.".into())); } From dc323df681ca77fb15ce4284da0feadfe83e0c7e 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: Thu, 15 Oct 2026 21:36:00 +0500 Subject: [PATCH 57/62] Release Manager 0.6.17 --- Cargo.lock | 6 +++--- Cargo.toml | 2 +- package-lock.json | 4 ++-- package.json | 2 +- src-tauri/tauri.conf.json | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c1e2041..285a37e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -491,7 +491,7 @@ dependencies = [ [[package]] name = "coa-core" -version = "0.6.16" +version = "0.6.17" dependencies = [ "base64 0.22.1", "chrono", @@ -520,7 +520,7 @@ dependencies = [ [[package]] name = "coa-release" -version = "0.6.16" +version = "0.6.17" dependencies = [ "chrono", "coa-core", @@ -529,7 +529,7 @@ dependencies = [ [[package]] name = "coa-server-manager" -version = "0.6.16" +version = "0.6.17" dependencies = [ "coa-core", "serde", diff --git a/Cargo.toml b/Cargo.toml index e323c0d..9ea449f 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.16" +version = "0.6.17" edition = "2021" license = "AGPL-3.0-only" diff --git a/package-lock.json b/package-lock.json index 3ff139c..1eb5436 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coa-server-manager", - "version": "0.6.16", + "version": "0.6.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coa-server-manager", - "version": "0.6.16", + "version": "0.6.17", "dependencies": { "@tauri-apps/api": "^2.12.0", "@tauri-apps/plugin-dialog": "^2.8.0", diff --git a/package.json b/package.json index c0f763e..0332207 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coa-server-manager", "private": true, - "version": "0.6.16", + "version": "0.6.17", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7b3cf2a..5dd98cc 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.16", + "version": "0.6.17", "identifier": "com.coa.servermanager", "build": { "beforeDevCommand": "npm run dev", From 16396407ad63c1b596dc4fd7e2672ef87cf23f56 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: Fri, 16 Oct 2026 05:20:12 +0500 Subject: [PATCH 58/62] Offer signed intermediate upgrades for older server installations --- Cargo.lock | 6 +- Cargo.toml | 2 +- changelog.d/20261016-intermediate-updates.md | 7 + crates/coa-core/src/lib.rs | 1 + crates/coa-core/src/update.rs | 5 +- crates/coa-core/src/update_route.rs | 180 +++++++++++++++++++ crates/coa-release/src/main.rs | 7 + package-lock.json | 4 +- package.json | 2 +- src-tauri/src/lib.rs | 8 +- src-tauri/tauri.conf.json | 2 +- src/i18n/locales/en.ts | 1 + src/i18n/locales/ru.ts | 1 + src/lib/api.ts | 1 + src/screens/Overview.tsx | 1 + src/screens/SettingsHome.tsx | 1 + 16 files changed, 217 insertions(+), 12 deletions(-) create mode 100644 changelog.d/20261016-intermediate-updates.md create mode 100644 crates/coa-core/src/update_route.rs diff --git a/Cargo.lock b/Cargo.lock index 285a37e..0fd30ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -491,7 +491,7 @@ dependencies = [ [[package]] name = "coa-core" -version = "0.6.17" +version = "0.6.18" dependencies = [ "base64 0.22.1", "chrono", @@ -520,7 +520,7 @@ dependencies = [ [[package]] name = "coa-release" -version = "0.6.17" +version = "0.6.18" dependencies = [ "chrono", "coa-core", @@ -529,7 +529,7 @@ dependencies = [ [[package]] name = "coa-server-manager" -version = "0.6.17" +version = "0.6.18" dependencies = [ "coa-core", "serde", diff --git a/Cargo.toml b/Cargo.toml index 9ea449f..4784707 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.17" +version = "0.6.18" edition = "2021" license = "AGPL-3.0-only" diff --git a/changelog.d/20261016-intermediate-updates.md b/changelog.d/20261016-intermediate-updates.md new file mode 100644 index 0000000..8e8cc68 --- /dev/null +++ b/changelog.d/20261016-intermediate-updates.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Older servers find a tested intermediate update instead of being blocked +--- +When the latest release cannot update an older server directly, Manager offers a signed intermediate release from its compatibility matrix and explains how to continue to the latest version. diff --git a/crates/coa-core/src/lib.rs b/crates/coa-core/src/lib.rs index 3476ebf..174917b 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; +pub mod update_route; mod update_isolation; mod mysql_snapshot; pub mod wine; diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index 7737e3a..1ececb9 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -296,6 +296,8 @@ pub fn plan(root: &Path, meta: &InstallMeta, manifest: &Manifest, resolutions: & pub struct Preview { pub from_version: Option, pub to_version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub latest_version: Option, pub items: Vec, pub conflicts: Vec, pub migrations: usize, @@ -364,6 +366,7 @@ pub fn preview_with(root: &Path, meta: &InstallMeta, source: &Source, trusted_ke Ok(Preview { from_version: meta.core.version.clone(), to_version: m.version.clone(), + latest_version: None, conflicts: items.iter().filter(|i| i.action == Action::Conflict).map(|i| i.path.clone()).collect(), items, migrations: m.migrations.len(), @@ -404,7 +407,7 @@ fn reject_downgrade(meta: &InstallMeta, manifest: &Manifest) -> Result<()> { return Err(Error::Invalid(format!("This package targets {} rather than {platform}.", c.platform))); } if meta.core.version.as_ref().is_none_or(|version| version != &manifest.version && !c.source_versions.contains(version)) { - return Err(Error::Invalid("This server version is outside the release's tested compatibility matrix.".into())); + return Err(Error::Invalid(format!("Installed server {} is outside the compatibility matrix for {}. Tested source versions: {}.", meta.core.version.as_deref().unwrap_or("unknown"), manifest.version, c.source_versions.join(", ")))); } } if let Some(installed) = meta.core.version.as_deref() { diff --git a/crates/coa-core/src/update_route.rs b/crates/coa-core/src/update_route.rs new file mode 100644 index 0000000..73cb9c7 --- /dev/null +++ b/crates/coa-core/src/update_route.rs @@ -0,0 +1,180 @@ +use std::collections::BTreeSet; + +use crate::{fsx, manifest::Manifest, pkgsource::{self, Source}, registry::InstallMeta, update, Error, Result}; + +const RELEASES: &str = "https://github.com/Corfirean/coa-server-build/releases/download/server-"; + +#[derive(Debug)] +pub struct Selection { + pub source: Source, + pub latest_version: Option, +} + +pub fn select(meta: &InstallMeta, source: Source, key: &str) -> Result { + select_with(meta, source, &mut |source| pkgsource::fetch_manifest(source, key)) +} + +fn select_with(meta: &InstallMeta, source: Source, fetch: &mut impl FnMut(&Source) -> Result<(Manifest, Vec)>) -> Result { + let (manifest, _) = fetch(&source)?; + if update::validate_candidate(meta, &manifest).is_ok() { + return Ok(Selection { source, latest_version: None }); + } + let original = update::validate_candidate(meta, &manifest).unwrap_err(); + if !matches!(&source, Source::Url(url) if url.trim_end_matches('/') == format!("{RELEASES}{}", manifest.version)) { + return Err(original); + } + let Some(installed) = meta.core.version.as_deref().and_then(crate::manifest::parse_version) else { return Err(original); }; + if manifest.kind != crate::manifest::Kind::Update + || manifest.compatibility.as_ref().is_none_or(|c| c.platform != if cfg!(windows) { "windows-x86_64" } else { "linux-x86_64" }) + || !manifest.compatible_with_manager(crate::MANAGER_VERSION) + || crate::manifest::parse_version(&manifest.version).is_none_or(|version| version <= installed) { + return Err(original); + } + let mut visited = BTreeSet::new(); + let Some(source) = find(meta, &manifest, fetch, &mut visited, 0)? else { return Err(original); }; + Ok(Selection { source, latest_version: Some(manifest.version) }) +} + +fn find(meta: &InstallMeta, manifest: &Manifest, fetch: &mut impl FnMut(&Source) -> Result<(Manifest, Vec)>, visited: &mut BTreeSet, depth: usize) -> Result> { + if depth >= 8 || visited.len() >= 32 { return Ok(None); } + let Some(compatibility) = &manifest.compatibility else { return Ok(None); }; + let Some(installed) = meta.core.version.as_deref().and_then(crate::manifest::parse_version) else { return Ok(None); }; + let target = crate::manifest::parse_version(&manifest.version).unwrap(); + let mut versions: Vec<_> = compatibility.source_versions.iter().filter_map(|version| { + crate::manifest::parse_version(version).filter(|number| *number > installed && *number < target).map(|number| (number, version)) + }).collect(); + versions.sort_by(|left, right| right.0.cmp(&left.0)); + for (_, version) in versions { + let Some(database) = compatibility.source_databases.get(version) else { continue; }; + if !visited.insert(version.clone()) || visited.len() > 32 { continue; } + let source = Source::Url(format!("{RELEASES}{version}")); + let (bridge, bytes) = fetch(&source)?; + if bridge.version != *version || fsx::sha256_bytes(&bytes) != database.manifest_sha256 { + return Err(Error::InvalidManifest("The intermediate update differs from the signed compatibility matrix.".into())); + } + if update::validate_candidate(meta, &bridge).is_ok() { return Ok(Some(source)); } + if bridge.kind != crate::manifest::Kind::Update || !bridge.compatible_with_manager(crate::MANAGER_VERSION) + || bridge.compatibility.as_ref().is_none_or(|c| c.platform != compatibility.platform) { continue; } + if let Some(source) = find(meta, &bridge, fetch, visited, depth + 1)? { return Ok(Some(source)); } + } + Ok(None) +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::{engine::general_purpose::STANDARD, Engine}; + use ed25519_dalek::{Signer, SigningKey}; + use std::{fs, path::Path}; + + fn package(version: &str, from: &str, digest: &str) -> serde_json::Value { + serde_json::json!({"schema":1,"kind":"update","version":version,"core":{"commit":"a".repeat(40)}, + "builtAt":"test","minManagerVersion":"0.6.16","files":[],"migrations":[],"archive":null, + "compatibility":{"schema":1,"platform":if cfg!(windows) {"windows-x86_64"} else {"linux-x86_64"}, + "coreCommit":"a".repeat(40),"moduleCommits":{"squid":"b".repeat(40)},"clientPatchVersion":"1.7", + "sourceVersions":[from],"sourceDatabases":{from:{"manifestSha256":digest,"schemaSha256":null}}}}) + } + + fn meta(version: &str) -> InstallMeta { + let mut meta = InstallMeta::new(crate::registry::InstallKind::New, Path::new("unused")); + meta.core.version = Some(version.into()); + meta + } + + fn signed_fixture(values: &[serde_json::Value], tamper: Option<&str>) -> (tempfile::TempDir, String) { + let folder = tempfile::tempdir().unwrap(); + let signing = SigningKey::from_bytes(&[37; 32]); + for value in values { + let version = value["version"].as_str().unwrap(); + let dir = folder.path().join(version); + fs::create_dir(&dir).unwrap(); + let bytes = serde_json::to_vec(value).unwrap(); + fs::write(dir.join("manifest.json.sig"), STANDARD.encode(signing.sign(&bytes).to_bytes())).unwrap(); + fs::write(dir.join("manifest.json"), &bytes).unwrap(); + if tamper == Some(version) { fs::write(dir.join("manifest.json"), b"tampered").unwrap(); } + } + (folder, STANDARD.encode(signing.verifying_key().to_bytes())) + } + + fn select_fixture(values: &[serde_json::Value], installed: &str, tamper: Option<&str>) -> Result { + let (folder, key) = signed_fixture(values, tamper); + let latest = values.last().unwrap()["version"].as_str().unwrap(); + select_with(&meta(installed), Source::Url(format!("{RELEASES}{latest}")), &mut |source| { + let Source::Url(url) = source else { unreachable!() }; + let version = url.strip_prefix(RELEASES).unwrap(); + pkgsource::fetch_manifest(&Source::Dir(folder.path().join(version)), &key) + }) + } + + fn pair() -> Vec { + let bridge = package("1.1.0", "1.0.0", &"c".repeat(64)); + let digest = fsx::sha256_bytes(&serde_json::to_vec(&bridge).unwrap()); + vec![bridge, package("1.2.0", "1.1.0", &digest)] + } + + #[test] + fn finds_a_signed_intermediate_without_relaxing_candidate_validation() { + let selection = select_fixture(&pair(), "1.0.0", None).unwrap(); + assert!(matches!(selection.source, Source::Url(url) if url.ends_with("1.1.0"))); + assert_eq!(selection.latest_version.as_deref(), Some("1.2.0")); + let latest = Manifest::parse(&serde_json::to_vec(pair().last().unwrap()).unwrap()).unwrap(); + assert!(update::validate_candidate(&meta("1.0.0"), &latest).is_err()); + } + + #[test] + fn direct_updates_and_current_versions_do_not_request_an_intermediate() { + for installed in ["1.1.0", "1.2.0"] { + let selection = select_fixture(&pair(), installed, None).unwrap(); + assert!(selection.latest_version.is_none()); + } + } + + #[test] + fn rejects_tampered_or_replaced_intermediate_manifests() { + assert!(select_fixture(&pair(), "1.0.0", Some("1.1.0")).is_err()); + let mut values = pair(); + values[0]["builtAt"] = "replaced".into(); + assert!(select_fixture(&values, "1.0.0", None).unwrap_err().to_string().contains("differs")); + } + + #[test] + fn follows_multiple_tested_edges_and_rejects_missing_paths() { + let mut values = pair(); + let digest = fsx::sha256_bytes(&serde_json::to_vec(values.last().unwrap()).unwrap()); + values.push(package("1.3.0", "1.2.0", &digest)); + let selection = select_fixture(&values, "1.0.0", None).unwrap(); + assert!(matches!(selection.source, Source::Url(url) if url.ends_with("1.1.0"))); + assert!(select_fixture(&values, "0.9.0", None).is_err()); + assert!(select_fixture(&values, "2.0.0", None).is_err()); + } + + #[test] + fn never_routes_custom_sources_or_unknown_installations() { + let values = pair(); + let (folder, key) = signed_fixture(&values, None); + let mut calls = 0; + let result = select_with(&meta("1.0.0"), Source::Dir(folder.path().join("1.2.0")), &mut |source| { + calls += 1; + pkgsource::fetch_manifest(source, &key) + }); + assert!(result.is_err()); + assert_eq!(calls, 1); + assert!(select_fixture(&values, "unknown", None).is_err()); + } + + #[test] + fn rejects_incompatible_platforms_and_manager_versions() { + for index in [0, 1] { + for (field, value) in [("minManagerVersion", "99.0.0"), ("platform", "other-platform")] { + let mut values = pair(); + if field == "platform" { values[index]["compatibility"][field] = value.into(); } + else { values[index][field] = value.into(); } + if index == 0 { + let digest = fsx::sha256_bytes(&serde_json::to_vec(&values[0]).unwrap()); + values[1]["compatibility"]["sourceDatabases"]["1.1.0"]["manifestSha256"] = digest.into(); + } + assert!(select_fixture(&values, "1.0.0", None).is_err()); + } + } + } +} diff --git a/crates/coa-release/src/main.rs b/crates/coa-release/src/main.rs index 0b08488..d3a4faf 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() { + "plan-update" => { + let mut meta = coa_core::registry::InstallMeta::new(coa_core::registry::InstallKind::New, std::path::Path::new("unused")); + meta.core.version = Some(need(&a, "from")?.clone()); + let selection = coa_core::update_route::select(&meta, coa_core::pkgsource::Source::Url(need(&a, "source")?.clone()), coa_core::signing::EMBEDDED_PUBLIC_KEY).map_err(e)?; + let (manifest, _) = coa_core::pkgsource::fetch_manifest(&selection.source, coa_core::signing::EMBEDDED_PUBLIC_KEY).map_err(e)?; + println!("{}", serde_json::json!({"from":meta.core.version,"next":manifest.version,"latest":selection.latest_version})); + } "verify-install" => { coa_core::release_schema::verify_install_package( &PathBuf::from(need(&a, "dir")?), coa_core::signing::EMBEDDED_PUBLIC_KEY, diff --git a/package-lock.json b/package-lock.json index 1eb5436..5b24704 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coa-server-manager", - "version": "0.6.17", + "version": "0.6.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coa-server-manager", - "version": "0.6.17", + "version": "0.6.18", "dependencies": { "@tauri-apps/api": "^2.12.0", "@tauri-apps/plugin-dialog": "^2.8.0", diff --git a/package.json b/package.json index 0332207..b20dd5f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coa-server-manager", "private": true, - "version": "0.6.17", + "version": "0.6.18", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6656434..7964346 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -847,8 +847,10 @@ async fn check_update(state: State<'_, AppState>, id: String, source: Option> = { "upd.folderHint": "Папка с пакетом обновления (оставьте пустым, чтобы использовать официальный источник)", "upd.available": "Доступна версия {to}", "upd.availableFrom": "Доступна версия {to} (у вас {from})", + "upd.intermediate": "Сначала обновитесь до {to}, затем проверьте обновления ещё раз для перехода на {latest}. Каждый этап использует проверенный путь обновления.", "upd.summary": "Загрузка {size} · изменится файлов: {files}", "upd.summaryDb": "Загрузка {size} · изменится файлов: {files} · обновлений базы данных: {db}", "upd.conflict": "Этот файл был изменён вне CoA Server Manager.", diff --git a/src/lib/api.ts b/src/lib/api.ts index 2b34c11..f1a4fc4 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -307,6 +307,7 @@ export interface UpdatePlanItem { export interface UpdatePreview { from_version: string | null; to_version: string; + latest_version?: string; items: UpdatePlanItem[]; conflicts: string[]; migrations: number; diff --git a/src/screens/Overview.tsx b/src/screens/Overview.tsx index 643bdab..d2e162f 100644 --- a/src/screens/Overview.tsx +++ b/src/screens/Overview.tsx @@ -545,6 +545,7 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o {upd.available && upd.preview && (

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

+ {upd.preview.latest_version &&

{t("upd.intermediate", { to: upd.preview.to_version, latest: upd.preview.latest_version })}

} {needsDecision &&

{t("overview.updateConflicts")}

} {upd.applying && (
diff --git a/src/screens/SettingsHome.tsx b/src/screens/SettingsHome.tsx index 525cdd9..ff2197f 100644 --- a/src/screens/SettingsHome.tsx +++ b/src/screens/SettingsHome.tsx @@ -234,6 +234,7 @@ export function SettingsHome({ serverId, path, onForget }: { serverId: string; p

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

+ {preview.latest_version &&

{t("upd.intermediate", { to: preview.to_version, latest: preview.latest_version })}

}

{!preview.database_check_error && (preview.pending_migrations ?? preview.migrations) > 0 ? t("upd.summaryDb", { size: mb(preview.download_bytes), files: preview.items.filter((i) => i.action !== "skip").length, db: preview.pending_migrations ?? preview.migrations }) From d6a6384f9c6c793081131d1beeebe24db3e09f68 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: Fri, 16 Oct 2026 05:54:25 +0500 Subject: [PATCH 59/62] Keep update previews local and preflight the complete rehearsal footprint --- .../20261016-lightweight-update-checks.md | 7 ++++ crates/coa-core/src/mysql_snapshot.rs | 16 ++++++++++ crates/coa-core/src/update.rs | 32 ++++++------------- 3 files changed, 32 insertions(+), 23 deletions(-) create mode 100644 changelog.d/20261016-lightweight-update-checks.md diff --git a/changelog.d/20261016-lightweight-update-checks.md b/changelog.d/20261016-lightweight-update-checks.md new file mode 100644 index 0000000..03fa93a --- /dev/null +++ b/changelog.d/20261016-lightweight-update-checks.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Update checks no longer download a server archive to compare its launcher +--- +Manager recognizes its supported launcher edits against the signed file hash locally. Overlapping checks wait briefly for one another, and the private update copy checks combined program and database space before copying. diff --git a/crates/coa-core/src/mysql_snapshot.rs b/crates/coa-core/src/mysql_snapshot.rs index 0e078a1..9a91cf8 100644 --- a/crates/coa-core/src/mysql_snapshot.rs +++ b/crates/coa-core/src/mysql_snapshot.rs @@ -257,6 +257,7 @@ pub(crate) fn rehearsal_copy_with_progress(root: &Path, meta: &Path, point: &Pat let root = canonical.as_path(); let destination = meta.join("rehearsals").join(uuid::Uuid::new_v4().simple().to_string()).join("server"); let mut files = BTreeMap::new(); + let mut file_bytes = 0u64; let mut directories = vec![root.to_path_buf()]; while let Some(directory) = directories.pop() { for entry in fs::read_dir(directory)? { @@ -275,12 +276,15 @@ pub(crate) fn rehearsal_copy_with_progress(root: &Path, meta: &Path, point: &Pat let path = fsx::ensure_within(root, &raw)?; if metadata.is_dir() { directories.push(path); } else if metadata.is_file() { + file_bytes = file_bytes.checked_add(metadata.len()).ok_or_else(|| Error::Invalid("Private copy size overflow.".into()))?; files.insert(relative, fsx::sha256_file(&path)?); if files.len() % 250 == 0 { report(&format!("Preparing the private copy: {} files checked", files.len())); } } else { return Err(Error::Invalid("Unsupported rehearsal file.".into())); } } } + let needed = file_bytes.checked_add(snapshot.expanded_bytes.max(snapshot.bytes)).ok_or_else(|| Error::Invalid("Private copy size overflow.".into()))?; + fsx::require_space(meta, needed)?; copy_verified_with_progress(root, &destination, &files, report)?; report("Restoring MySQL into the private server copy"); copy_snapshot(point, snapshot, &destination.join("mysql/data"))?; @@ -701,4 +705,16 @@ mod tests { assert!(phases.iter().any(|phase| phase.contains("Copying private server files"))); assert!(phases.last().unwrap().contains("Restoring MySQL")); } + + #[test] + fn rehearsal_checks_combined_program_and_database_space_before_copying() { + let (_temp, root, meta, point, mut snapshot) = fixture(); + fs::create_dir_all(&meta).unwrap(); + fsx::atomic_write(&root.join("Core/worldserver.exe"), b"exe").unwrap(); + snapshot.expanded_bytes = fsx::free_space(&meta).unwrap().saturating_add(1024 * 1024 * 1024); + let error = rehearsal_copy_with_progress(&root, &meta, &point, &snapshot, &|_| {}).unwrap_err(); + assert!(matches!(error, Error::InsufficientSpace { .. })); + assert!(!meta.join("rehearsals").exists()); + assert_eq!(fs::read(root.join("mysql/data/characters.ibd")).unwrap(), b"live"); + } } diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index 1ececb9..c596428 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -339,7 +339,7 @@ pub fn preview_with(root: &Path, meta: &InstallMeta, source: &Source, trusted_ke let meta_dir = crate::registry::metadata_dir_for(root)?; let observed_version = MetaDir::open(&meta_dir)?.1.core.version; let (m, _) = fetch_manifest(source, trusted_key)?; - let _lock = operation_lock_for(&meta_dir, "checking updates", false)?; + let _lock = operation_lock_for(&meta_dir, "checking updates", true)?; ensure_recovered(&meta_dir)?; let (_, current_meta) = MetaDir::open(&meta_dir)?; let meta = if current_meta.core.version != observed_version { ¤t_meta } else { meta }; @@ -347,7 +347,7 @@ pub fn preview_with(root: &Path, meta: &InstallMeta, source: &Source, trusted_ke reject_downgrade(meta, &m)?; let mut items = plan(root, meta, &m, resolutions, None)?; if items.iter().any(|item| item.path == "Scripts/manage.py" && item.action != Action::Skip) - && launcher_already_integrated(root, &meta_dir, meta, &m, source)? { + && launcher_already_integrated(root, meta, &m)? { if let Some(item) = items.iter_mut().find(|item| item.path == "Scripts/manage.py") { item.action = Action::Skip; item.reason = Some("already up to date with Manager integration".into()); @@ -377,27 +377,11 @@ pub fn preview_with(root: &Path, meta: &InstallMeta, source: &Source, trusted_ke }) } -fn launcher_already_integrated(root: &Path, meta_dir: &Path, meta: &InstallMeta, manifest: &Manifest, source: &Source) -> Result { +fn launcher_already_integrated(root: &Path, meta: &InstallMeta, manifest: &Manifest) -> Result { let Some(file) = manifest.files.iter().find(|file| file.path == "Scripts/manage.py") else { return Ok(false); }; - let actual = sha_if_exists(&root.join(&file.path)); - if actual.as_ref() != meta.original_hashes.get(&file.path) || actual.is_none() { return Ok(false); } - let cache = meta_dir.join("manifests").join(format!("launcher-{}.py", file.sha256)); - let signed = match fs::read(&cache) { - Ok(bytes) if fsx::sha256_bytes(&bytes) == file.sha256 => bytes, - _ => { - let temp = tempfile::tempdir()?; - let parts = fetch_parts(source, manifest, &temp.path().join("download"), &Cancel::default(), &|_, _| {})?; - package::extract(&parts, manifest, &temp.path().join("tree"), &|_, _| {})?; - let bytes = fs::read(temp.path().join("tree").join(&file.path))?; - if fsx::sha256_bytes(&bytes) != file.sha256 { return Err(Error::Invalid("The signed launcher failed verification.".into())); } - // This derived cache is optional; failure to save it does not change the server. - let _ = fsx::atomic_write(&cache, &bytes); - bytes - } - }; - Ok(std::str::from_utf8(&signed).ok().is_some_and(|signed| { - fs::read(root.join(&file.path)).ok().is_some_and(|actual| crate::driver::launcher_matches(signed, &actual)) - })) + let Ok(actual) = fs::read(root.join(&file.path)) else { return Ok(false); }; + if meta.original_hashes.get(&file.path) != Some(&fsx::sha256_bytes(&actual)) { return Ok(false); } + Ok(crate::driver::launcher_matches_recorded(&file.sha256, &actual)) } fn reject_downgrade(meta: &InstallMeta, manifest: &Manifest) -> Result<()> { @@ -1584,6 +1568,8 @@ mod tests { let (_, mut meta) = MetaDir::open(&w.meta).unwrap(); meta.original_hashes.insert("Scripts/manage.py".into(), fsx::sha256_bytes(integrated.as_bytes())); fsx::atomic_write_json(&w.meta.join("install.json"), &meta).unwrap(); + let original = fetch_manifest(&Source::Dir(w.pkg.clone()), &w.key).unwrap().0; + for part in &original.archive.as_ref().unwrap().parts { fs::remove_file(w.pkg.join(&part.name)).unwrap(); } let p = preview(&w.root, &meta, &Source::Dir(w.pkg.clone()), &w.key, &BTreeMap::new()).unwrap(); assert_eq!(p.items.iter().find(|item| item.path == "Scripts/manage.py").unwrap().action, Action::Skip); let manifest = fetch_manifest(&Source::Dir(w.pkg.clone()), &w.key).unwrap().0; @@ -1591,7 +1577,7 @@ mod tests { assert_eq!(applied_plan.iter().find(|item| item.path == "Scripts/manage.py").unwrap().action, Action::Skip); let mut changed = fetch_manifest(&Source::Dir(w.pkg.clone()), &w.key).unwrap().0; changed.files.iter_mut().find(|file| file.path == "Scripts/manage.py").unwrap().sha256 = fsx::sha256_bytes(b"new launcher"); - assert!(launcher_already_integrated(&w.root, &w.meta, &meta, &changed, &Source::Dir(w.pkg.clone())).is_err()); + assert!(!launcher_already_integrated(&w.root, &meta, &changed).unwrap()); write(&w.root, "Scripts/manage.py", format!("{integrated}\n# user edit\n").as_bytes()); // Same version: the owner's edit is not an update (Repair lists and restores it). let p = preview(&w.root, &meta, &Source::Dir(w.pkg.clone()), &w.key, &BTreeMap::new()).unwrap(); From f53c7cb221c720d152bb73e4c0f5209442d62ae0 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: Fri, 16 Oct 2026 07:27:12 +0500 Subject: [PATCH 60/62] Release Manager 0.6.19 --- Cargo.lock | 6 +++--- Cargo.toml | 2 +- package-lock.json | 4 ++-- package.json | 2 +- src-tauri/tauri.conf.json | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0fd30ba..291fbcb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -491,7 +491,7 @@ dependencies = [ [[package]] name = "coa-core" -version = "0.6.18" +version = "0.6.19" dependencies = [ "base64 0.22.1", "chrono", @@ -520,7 +520,7 @@ dependencies = [ [[package]] name = "coa-release" -version = "0.6.18" +version = "0.6.19" dependencies = [ "chrono", "coa-core", @@ -529,7 +529,7 @@ dependencies = [ [[package]] name = "coa-server-manager" -version = "0.6.18" +version = "0.6.19" dependencies = [ "coa-core", "serde", diff --git a/Cargo.toml b/Cargo.toml index 4784707..ba81b66 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.18" +version = "0.6.19" edition = "2021" license = "AGPL-3.0-only" diff --git a/package-lock.json b/package-lock.json index 5b24704..dba844a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coa-server-manager", - "version": "0.6.18", + "version": "0.6.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coa-server-manager", - "version": "0.6.18", + "version": "0.6.19", "dependencies": { "@tauri-apps/api": "^2.12.0", "@tauri-apps/plugin-dialog": "^2.8.0", diff --git a/package.json b/package.json index b20dd5f..a9b48f9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coa-server-manager", "private": true, - "version": "0.6.18", + "version": "0.6.19", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index bea2b80..9b317b7 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.18", + "version": "0.6.19", "identifier": "com.coa.servermanager", "build": { "beforeDevCommand": "npm run dev", From a7b56d6aed7517bd7d293b097e0416fecaf2e4c5 Mon Sep 17 00:00:00 2001 From: Corfirean Date: Fri, 16 Oct 2026 13:39:43 +0500 Subject: [PATCH 61/62] Simplify updates to one recovery-backed migration pass (#111) * Share migration planning and execution across update phases * Apply updates in one recovery-backed pass without nested server rehearsals * Preserve applied core SQL and share account migration history across realms --- Cargo.lock | 6 +- Cargo.toml | 2 +- .../20261016-consistent-update-migrations.md | 7 + .../20261016-idempotent-validation-stop.md | 7 + ...0261016-preserve-core-migration-history.md | 7 + .../20261016-shared-auth-migration-history.md | 7 + .../20261016-update-compatibility-wording.md | 7 + crates/coa-core/examples/migration_plan.rs | 57 +++++ crates/coa-core/examples/upgrade_fixture.rs | 28 +++ crates/coa-core/src/migrations.rs | 200 ++++++++++++---- crates/coa-core/src/mysql_snapshot.rs | 200 ++++------------ crates/coa-core/src/ra.rs | 3 +- crates/coa-core/src/release_schema.rs | 4 +- crates/coa-core/src/update.rs | 225 +++++------------- docs/UPDATE-WORKFLOW.md | 19 ++ package-lock.json | 4 +- package.json | 2 +- src-tauri/tauri.conf.json | 2 +- src/i18n/locales/en.ts | 2 +- src/i18n/locales/ru.ts | 2 +- 20 files changed, 409 insertions(+), 382 deletions(-) create mode 100644 changelog.d/20261016-consistent-update-migrations.md create mode 100644 changelog.d/20261016-idempotent-validation-stop.md create mode 100644 changelog.d/20261016-preserve-core-migration-history.md create mode 100644 changelog.d/20261016-shared-auth-migration-history.md create mode 100644 changelog.d/20261016-update-compatibility-wording.md create mode 100644 crates/coa-core/examples/migration_plan.rs create mode 100644 crates/coa-core/examples/upgrade_fixture.rs create mode 100644 docs/UPDATE-WORKFLOW.md diff --git a/Cargo.lock b/Cargo.lock index 291fbcb..d0d9e0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -491,7 +491,7 @@ dependencies = [ [[package]] name = "coa-core" -version = "0.6.19" +version = "0.6.20" dependencies = [ "base64 0.22.1", "chrono", @@ -520,7 +520,7 @@ dependencies = [ [[package]] name = "coa-release" -version = "0.6.19" +version = "0.6.20" dependencies = [ "chrono", "coa-core", @@ -529,7 +529,7 @@ dependencies = [ [[package]] name = "coa-server-manager" -version = "0.6.19" +version = "0.6.20" dependencies = [ "coa-core", "serde", diff --git a/Cargo.toml b/Cargo.toml index ba81b66..aec4ac2 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.19" +version = "0.6.20" edition = "2021" license = "AGPL-3.0-only" diff --git a/changelog.d/20261016-consistent-update-migrations.md b/changelog.d/20261016-consistent-update-migrations.md new file mode 100644 index 0000000..2f9e794 --- /dev/null +++ b/changelog.d/20261016-consistent-update-migrations.md @@ -0,0 +1,7 @@ +--- +area: manager +type: changed +audience: admins +title: Server updates apply migrations once after saving a recovery point +--- +Updates no longer build and start a second full server copy. The Manager checks both realms, saves recovery, applies pending SQL once and verifies startup. Failed SQL or startup restores the recovery point. Full-copy upgrade testing remains a release task. diff --git a/changelog.d/20261016-idempotent-validation-stop.md b/changelog.d/20261016-idempotent-validation-stop.md new file mode 100644 index 0000000..73b9541 --- /dev/null +++ b/changelog.d/20261016-idempotent-validation-stop.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Update validation recognizes an already stopped server +--- +Validation uses the same complete-server stop check as recovery instead of issuing redundant unconditional shutdown commands. diff --git a/changelog.d/20261016-preserve-core-migration-history.md b/changelog.d/20261016-preserve-core-migration-history.md new file mode 100644 index 0000000..d86e0a2 --- /dev/null +++ b/changelog.d/20261016-preserve-core-migration-history.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Recorded core SQL stays applied after old history tables are cleaned up +--- +Existing core migration records are preserved in Manager history. Unknown legacy checksums remain explicitly unknown, and interrupted or changed migrations are still rejected. diff --git a/changelog.d/20261016-shared-auth-migration-history.md b/changelog.d/20261016-shared-auth-migration-history.md new file mode 100644 index 0000000..deeb606 --- /dev/null +++ b/changelog.d/20261016-shared-auth-migration-history.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Switching realms no longer repeats shared account database updates +--- +Solo and Wildcard use one migration history for their shared account database. Previously applied records from either realm are recognized without running their SQL again. diff --git a/changelog.d/20261016-update-compatibility-wording.md b/changelog.d/20261016-update-compatibility-wording.md new file mode 100644 index 0000000..0ccd451 --- /dev/null +++ b/changelog.d/20261016-update-compatibility-wording.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Update offers distinguish version support from database validation +--- +An available version sequence no longer claims that every database state has been tested. Database compatibility is checked during the update. diff --git a/crates/coa-core/examples/migration_plan.rs b/crates/coa-core/examples/migration_plan.rs new file mode 100644 index 0000000..db5fc57 --- /dev/null +++ b/crates/coa-core/examples/migration_plan.rs @@ -0,0 +1,57 @@ +//! Read-only audit, or explicit SQL checks on a disposable database fixture. +use coa_core::{db::{Account, Db}, migrations::{self, Store}, manifest::Manifest, realms::Mode}; +fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args().collect(); + if args.len() < 3 { return Err("usage: migration_plan [--apply|--sql-only ]".into()); } + let root = std::path::Path::new(&args[1]); + let bytes = std::fs::read(&args[2])?; + let signature = std::fs::read_to_string(format!("{}.sig",args[2]))?; + coa_core::signing::verify(&bytes, &signature, coa_core::signing::EMBEDDED_PUBLIC_KEY)?; + let manifest = Manifest::parse(&bytes)?; + for (index,mode) in [Mode::Coa, Mode::Wildcard].into_iter().enumerate() { + let db = Db::from_repack(root, Account::Admin)?.for_realm(mode); + let rows = db.load_readonly()?; + let relevant: Vec<_> = rows.iter().filter(|r| r.id == "rev_20260903_01_live_class_baseline").map(|r| format!("{} {:?} {}",r.db,r.status,r.baseline)).collect(); + println!("{} baseline history: {:?}",mode.name(),relevant); + let list: Vec<_> = manifest.migrations.iter().filter(|m|index==0 || m.db!="auth").cloned().collect(); + let plan = migrations::status(&db,&list)?; + println!("{} pending: {:?}",mode.name(),plan.iter().filter(|m|m.status!=migrations::Status::Applied).map(|m|(&m.db,&m.id,m.status)).collect::>()); + } + if args.get(3).is_some_and(|s| s == "--apply" || s == "--sql-only") { + assert!(root.file_name().unwrap().to_string_lossy().starts_with("update-database-diagnosis-") && root.join(".coa-update-fixture").is_file(), "Only disposable fixture roots may be changed"); + if args[3] == "--sql-only" { + let db = Db::from_repack(root, Account::Admin)?.for_realm(Mode::Coa); + let identity = || -> coa_core::Result { + let mut rows = String::new(); + for (schema,table,columns) in [("acore_auth","account","id,HEX(username)"),("acore_characters","characters","guid,account,HEX(name)"),("acore_characters_wildcard","characters","guid,account,HEX(name)")] { + rows.push_str(schema); + rows.push_str(&db.query(&format!("SELECT {columns} FROM {schema}.{table} ORDER BY 1;"))?); + } + Ok(coa_core::fsx::sha256_bytes(rows.as_bytes())) + }; + let before = identity()?; + migrations::verify_files(&manifest.migrations,std::path::Path::new(&args[4]))?; + for (index,mode) in [Mode::Coa,Mode::Wildcard].into_iter().enumerate() { + let realm_db = db.clone().for_realm(mode); + let list: Vec<_> = manifest.migrations.iter().filter(|m|index==0 || m.db!="auth").cloned().collect(); + let result = migrations::apply_pending(&realm_db,&list,std::path::Path::new(&args[4]),&||Ok("disposable fixture".into()))?; + println!("{} SQL result: {}",mode.name(),serde_json::to_string(&result)?); + if result.failed.is_some() {return Err("SQL failed; fixture preserved".into());} + } + assert_eq!(before,identity()?,"Account or character identity changed"); + println!("Account and character identities preserved: {}",before); + let capture = root.join("captured-schema"); + coa_core::schema_check::capture(&db,&capture)?; + println!("Captured schema SHA256: {}",coa_core::fsx::sha256_file(&capture.join(coa_core::schema_check::CONTRACT))?); + return Ok(()); + } + if args.len() != 5 { return Err("Migration directory required".into()); } + use coa_core::update::{Env, RepackEnv}; + let meta = coa_core::registry::metadata_dir_for(root)?; + let env = RepackEnv { root, meta_dir: &meta }; + let result = env.migrate(&manifest, std::path::Path::new(&args[4])); + let _ = coa_core::driver::run(root, coa_core::driver::Verb::StopAll); + println!("Application result: {}",serde_json::to_string(&result?)?); + } + Ok(()) +} diff --git a/crates/coa-core/examples/upgrade_fixture.rs b/crates/coa-core/examples/upgrade_fixture.rs new file mode 100644 index 0000000..4b7d617 --- /dev/null +++ b/crates/coa-core/examples/upgrade_fixture.rs @@ -0,0 +1,28 @@ +//! Exercise the production updater only on a disposable copy of an installed server. +use coa_core::{registry::MetaDir, update::{self, Env, RepackEnv}}; +fn main() -> Result<(),Box> { + let args:Vec<_>=std::env::args().collect(); + if args.len()!=3 && args.len()!=4 {return Err("usage: upgrade_fixture ".into());} + let root=std::path::Path::new(&args[1]); + assert!(root.file_name().unwrap().to_string_lossy().starts_with("coa-schema-fixture-real") && root.join(".coa-update-fixture").is_file(),"Disposable fixture required"); + let meta=coa_core::registry::metadata_dir_for(root)?; + let (_,installed)=MetaDir::open(&meta)?; + assert_eq!(coa_core::fsx::canonicalize_lenient(std::path::Path::new(&installed.server_path))?,coa_core::fsx::canonicalize_lenient(root)?,"Fixture metadata must point to the copy"); + assert!(installed.client_path.is_none(),"Detach the real client from fixture metadata"); + let env=RepackEnv{root,meta_dir:&meta}; + if args.get(3).is_some_and(|flag|flag=="--validate-only") { + let result=env.validate(); + let _=coa_core::driver::run(root,coa_core::driver::Verb::StopAll); + result?; + println!("Startup and identity validation passed"); + return Ok(()); + } + let outcome=update::apply(&update::Params{root,meta_dir:&meta,source:coa_core::pkgsource::Source::Dir(args[2].clone().into()),trusted_key:coa_core::signing::EMBEDDED_PUBLIC_KEY,cancel:Default::default(),resolutions:Default::default(),env:&env,fail_after_ops:None},&|phase,percent|println!("{percent}% {phase}")); + let stopped=coa_core::driver::run(root,coa_core::driver::Verb::StopAll); + println!("Shutdown after test: {:?}",stopped.map(|r|r.ok)); + let outcome=outcome?; + println!("Final transaction: {:?}",outcome.txn.state); + env.ensure_stopped()?; + assert_eq!(outcome.txn.state,update::State::Committed); + Ok(()) +} diff --git a/crates/coa-core/src/migrations.rs b/crates/coa-core/src/migrations.rs index 7d95645..245348a 100644 --- a/crates/coa-core/src/migrations.rs +++ b/crates/coa-core/src/migrations.rs @@ -11,6 +11,7 @@ use crate::fsx; use crate::manifest::Migration; const LEDGER_SCHEMA: &str = "acore_world"; +const AUTH_LEDGER_SCHEMA: &str = "acore_auth"; const LEDGER_TABLE: &str = "coa_manager_migrations"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] @@ -37,6 +38,10 @@ pub trait Store { fn load(&self) -> Result>; fn load_readonly(&self) -> Result> { self.load() } fn put(&self, row: &LedgerRow) -> Result<()>; + fn put_batch(&self, rows: &[LedgerRow]) -> Result<()> { + for row in rows { self.put(row)?; } + Ok(()) + } fn run_file(&self, schema: &str, file: &Path) -> Result<()>; } @@ -48,59 +53,101 @@ fn ident_ok(s: &str) -> bool { !s.is_empty() && s.len() <= 150 && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')) } +pub(crate) fn shared_auth(kind: &str) -> bool { + matches!(db::schema_of(kind), Ok("acore_auth")) +} + +fn create_ledger(db: &Db, schema: &str) -> Result<()> { + db.query(&format!("CREATE TABLE IF NOT EXISTS `{schema}`.`{LEDGER_TABLE}` (`db` VARCHAR(32) NOT NULL, `id` VARCHAR(190) NOT NULL, `sha256` CHAR(64) NOT NULL, `status` VARCHAR(16) NOT NULL, `applied_at` DATETIME NOT NULL, `error` TEXT NULL, `baseline` TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (`db`,`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"))?; + Ok(()) +} + impl Store for Db { fn load(&self) -> Result> { - self.query(&format!( - "CREATE TABLE IF NOT EXISTS `{LEDGER_SCHEMA}`.`{LEDGER_TABLE}` (`db` VARCHAR(32) NOT NULL, `id` VARCHAR(190) NOT NULL, `sha256` CHAR(64) NOT NULL, `status` VARCHAR(16) NOT NULL, `applied_at` DATETIME NOT NULL, `error` TEXT NULL, `baseline` TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (`db`,`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;" - ))?; - self.load_readonly() + create_ledger(self, LEDGER_SCHEMA)?; + create_ledger(self, AUTH_LEDGER_SCHEMA)?; + let rows = self.load_readonly()?; + // Preserve already-recorded core history even if a module later replaces its updates table. + // A zero checksum remains unknown; importing it does not invent a checksum for old SQL. + let selected: std::collections::BTreeMap<_, _> = rows.iter().map(|row| ((row.db.clone(), row.id.to_ascii_lowercase()), row)).collect(); + let core: Vec<_> = selected.values().filter(|r| r.status == Status::Applied && r.baseline && r.sha256 == "0".repeat(64) + && matches!(r.db.as_str(), "world" | "characters") && ident_ok(&r.id)).map(|r| (*r).clone()).collect(); + self.put_batch(&core)?; + Ok(rows) } fn load_readonly(&self) -> Result> { - let out = if self.tables(LEDGER_SCHEMA)?.iter().any(|name| name == LEDGER_TABLE) { - self.query(&format!("SELECT `db`,`id`,`sha256`,`status`,`baseline`,COALESCE(HEX(`error`),'') FROM `{LEDGER_SCHEMA}`.`{LEDGER_TABLE}` ORDER BY `db`,`id`;"))? - } else { String::new() }; - let mut rows = Vec::new(); - // Files the core's own updater (or whoever prepared this database) already recorded count as applied. - for (kind, schema) in db::SCHEMAS { - if self.tables(schema)?.iter().any(|name| name == "updates") { - let names = self.query(&format!("SELECT name FROM `{schema}`.`updates`;"))?; - for name in names.lines().filter(|n| n.ends_with(".sql")) { - rows.push(LedgerRow { db: kind.into(), id: name.trim_end_matches(".sql").into(), sha256: "0".repeat(64), status: Status::Applied, error: None, baseline: true }); - } + // Resolve physical schemas once, then read all history in two client calls. + // Using a neutral connection prevents a Wildcard query from rerouting the other realm. + let connection = self.clone().for_realm(crate::realms::Mode::Coa); + let world = self.realm_schema("acore_world"); + let characters = self.realm_schema("acore_characters"); + let other = if self.realm() == crate::realms::Mode::Coa { "acore_world_wildcard" } else { "acore_world" }; + let schemas: std::collections::BTreeSet<_> = [world, characters, other, AUTH_LEDGER_SCHEMA].into_iter().collect(); + let catalog = connection.query(&format!("SELECT table_schema,table_name FROM information_schema.tables WHERE table_schema IN ('{}') AND table_name IN ('updates','{LEDGER_TABLE}') AND table_type='BASE TABLE';", schemas.into_iter().collect::>().join("','")))?; + let available: std::collections::BTreeSet<_> = catalog.lines().filter_map(|line| line.split_once('\t')).collect(); + let text = |expression: &str| format!("CONVERT({expression} USING utf8mb4) COLLATE utf8mb4_bin"); + let mut queries = Vec::new(); + for (kind, schema) in [("characters", characters), ("auth", AUTH_LEDGER_SCHEMA), ("world", world)] { + if available.contains(&(schema, "updates")) { + queries.push(format!("SELECT 'core','{kind}',{},REPEAT('0',64),'applied',1,'' FROM `{schema}`.`updates` WHERE BINARY RIGHT(name,4)='.sql'",text("LEFT(name,CHAR_LENGTH(name)-4)"))); } } - for line in out.lines().filter(|l| !l.is_empty()) { - let f: Vec<&str> = line.split('\t').collect(); - if f.len() < 5 { - return Err(Error::Invalid("The database migration history is malformed; no SQL was replayed.".into())); + for (origin, schema, filter) in [("local",world,""), ("other",other," WHERE `db` IN ('auth','coa-auth','wildcard-auth')"), ("shared",AUTH_LEDGER_SCHEMA,"")] { + if available.contains(&(schema, LEDGER_TABLE)) { + queries.push(format!("SELECT '{origin}',{},{},{},{},`baseline`,{} FROM `{schema}`.`{LEDGER_TABLE}`{filter}",text("`db`"),text("`id`"),text("`sha256`"),text("`status`"),text("COALESCE(HEX(`error`),'')"))); } - if !matches!(f[3], "applied" | "failed" | "running") || !ident_ok(f[1]) || f[2].len() != 64 || !f[2].chars().all(|c| c.is_ascii_hexdigit()) { + } + if queries.is_empty() { return Ok(vec![]); } + let out = connection.query(&queries.join(" UNION ALL "))?; + let (mut rows, mut local, mut auth, mut shared) = (Vec::new(), Vec::new(), Vec::new(), Vec::new()); + for line in out.lines().filter(|l| !l.is_empty()) { + let f: Vec<_> = line.split('\t').collect(); + if f.len() < 6 { return Err(Error::Invalid("The database migration history is malformed; no SQL was replayed.".into())); } + // Unrelated legacy filenames cannot match a valid manifest migration ID. + if f[0] == "core" && !ident_ok(f[2]) { continue; } + if !matches!(f[4], "applied" | "failed" | "running") || !ident_ok(f[2]) || f[3].len() != 64 || !f[3].chars().all(|c| c.is_ascii_hexdigit()) { return Err(Error::Invalid("The database migration history is invalid; no SQL was replayed.".into())); } - let err = f.get(5).filter(|h| !h.is_empty()).and_then(|h| hex::decode(h).ok()).map(|b| String::from_utf8_lossy(&b).into_owned()); - rows.push(LedgerRow { - db: f[0].into(), - id: f[1].into(), - sha256: f[2].into(), - status: match f[3] { "applied" => Status::Applied, "running" => Status::Running, _ => Status::Failed }, - baseline: f[4] == "1", - error: err, - }); + let error = f.get(6).filter(|h| !h.is_empty()).and_then(|h| hex::decode(h).ok()).map(|b| String::from_utf8_lossy(&b).into_owned()); + let row = LedgerRow { db:f[1].into(), id:f[2].into(), sha256:f[3].into(), status:match f[4] { "applied"=>Status::Applied,"running"=>Status::Running,_=>Status::Failed }, baseline:f[5]=="1", error }; + match f[0] { + "core" => rows.push(row), + "local" if !shared_auth(&row.db) => local.push(row), + "local" | "other" => auth.push(row), + "shared" => shared.push(row), + _ => return Err(Error::Invalid("Unknown migration history source.".into())), + } + } + rows.extend(local); + // An interrupted legacy auth execution must not be hidden by another realm's applied row. + auth.sort_by_key(|r| (match r.status { Status::Applied=>0,Status::Running=>1,Status::Failed=>2,Status::Pending=>3 },r.sha256!="0".repeat(64))); + for mut row in auth { row.db="auth".into(); rows.push(row); } + // New auth records live with the shared account database and are authoritative. + for mut row in shared { + if !shared_auth(&row.db) { return Err(Error::Invalid("The shared authentication migration history is invalid.".into())); } + rows.retain(|r| !(shared_auth(&r.db) && r.id.eq_ignore_ascii_case(&row.id))); + row.db="auth".into(); rows.push(row); } Ok(rows) } - fn put(&self, r: &LedgerRow) -> Result<()> { - if !ident_ok(&r.id) || !ident_ok(&r.db) || r.sha256.len() != 64 || !r.sha256.chars().all(|c| c.is_ascii_hexdigit()) { - return Err(Error::Invalid("migration record has an invalid identifier".into())); + fn put(&self, row: &LedgerRow) -> Result<()> { self.put_batch(std::slice::from_ref(row)) } + + fn put_batch(&self, rows: &[LedgerRow]) -> Result<()> { + let mut groups = std::collections::BTreeMap::<&str, Vec>::new(); + for r in rows { + if !ident_ok(&r.id) || !ident_ok(&r.db) || r.sha256.len() != 64 || !r.sha256.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(Error::Invalid("migration record has an invalid identifier".into())); + } + let status = match r.status { Status::Applied => "applied", Status::Running => "running", _ => "failed" }; + let error = r.error.as_deref().map(|e| format!("UNHEX('{}')", hex_of(e))).unwrap_or_else(|| "NULL".into()); + let (schema, kind) = if shared_auth(&r.db) { (AUTH_LEDGER_SCHEMA, "auth") } else { (LEDGER_SCHEMA, r.db.as_str()) }; + groups.entry(schema).or_default().push(format!("('{}','{}','{}','{status}',NOW(),{error},{})",kind,r.id,r.sha256,r.baseline as u8)); + } + for (schema, values) in groups { + self.query(&format!("REPLACE INTO `{schema}`.`{LEDGER_TABLE}` (`db`,`id`,`sha256`,`status`,`applied_at`,`error`,`baseline`) VALUES {};",values.join(",")))?; } - let status = match r.status { Status::Applied => "applied", Status::Running => "running", _ => "failed" }; - let err = r.error.as_deref().map(|e| format!("UNHEX('{}')", hex_of(e))).unwrap_or_else(|| "NULL".into()); - self.query(&format!( - "REPLACE INTO `{LEDGER_SCHEMA}`.`{LEDGER_TABLE}` (`db`,`id`,`sha256`,`status`,`applied_at`,`error`,`baseline`) VALUES ('{}','{}','{}','{status}',NOW(),{err},{});", - r.db, r.id, r.sha256, r.baseline as u8 - ))?; Ok(()) } @@ -137,21 +184,34 @@ pub fn status(store: &dyn Store, list: &[Migration]) -> Result> { .map(|m| { // Manager records are appended after the core's updates table and are authoritative. let row = recorded(&rows, m); - let changed = row.is_some_and(|r| hash_changed(r, m)); + let history = effective_history(&rows, m); + let changed = history.iter().any(|r| hash_changed(r,m)); + let interrupted = history.iter().find(|r| matches!(r.status, Status::Running | Status::Failed)); Item { id: m.id.clone(), db: m.db.clone(), - status: if changed || row.is_some_and(|r| r.status == Status::Running) { Status::Failed } else { row.map(|r| r.status).unwrap_or(Status::Pending) }, + status: if changed || interrupted.is_some() { Status::Failed } else { row.map(|r| r.status).unwrap_or(Status::Pending) }, destructive: m.destructive, baseline: row.map(|r| r.baseline).unwrap_or(false), - error: if changed { Some("An already applied SQL file changed. Publish a new migration instead of replaying it.".into()) } else { row.and_then(|r| r.error.clone()) }, + error: if changed { Some("An already applied SQL file changed. Publish a new migration instead of replaying it.".into()) } else { interrupted.and_then(|r| r.error.clone()).or_else(|| row.and_then(|r| r.error.clone())) }, } }) .collect()) } fn recorded<'a>(rows: &'a [LedgerRow], m: &Migration) -> Option<&'a LedgerRow> { - rows.iter().rev().find(|r| r.db == m.db && r.id == m.id) + rows.iter().rev().find(|r| (r.db == m.db || shared_auth(&r.db) && shared_auth(&m.db)) && r.id.eq_ignore_ascii_case(&m.id)) +} + +fn effective_history<'a>(rows: &'a [LedgerRow], m: &Migration) -> Vec<&'a LedgerRow> { + if shared_auth(&m.db) { rows.iter().filter(|r| shared_auth(&r.db) && r.id.eq_ignore_ascii_case(&m.id)).collect() } + else { recorded(rows, m).into_iter().collect() } +} + +fn preserve_auth_history(store: &dyn Store, rows: &[LedgerRow], list: &[Migration]) -> Result<()> { + let applied: Vec<_> = list.iter().filter(|m| shared_auth(&m.db)).filter_map(|m| recorded(rows,m)) + .filter(|r| r.status == Status::Applied).cloned().collect(); + store.put_batch(&applied) } /// Migrations that were published with different bytes but have the same effect, so a server that applied either @@ -166,7 +226,7 @@ const SAME_EFFECT: &[(&str, &[&str])] = &[( )]; fn same_effect(id: &str, a: &str, b: &str) -> bool { - SAME_EFFECT.iter().any(|(known, hashes)| *known == id + SAME_EFFECT.iter().any(|(known, hashes)| known.eq_ignore_ascii_case(id) && hashes.iter().any(|h| h.eq_ignore_ascii_case(a)) && hashes.iter().any(|h| h.eq_ignore_ascii_case(b))) } @@ -183,8 +243,7 @@ pub fn preflight(store: &dyn Store, list: &[Migration]) -> Result<()> { pub fn pending_count(store: &dyn Store, list: &[Migration]) -> Result { let rows = store.load_readonly()?; - check_history(&rows, list)?; - Ok(list.iter().filter(|m| recorded(&rows, m).is_none_or(|r| r.status != Status::Applied)).count()) + Ok(pending_migrations(&rows, list)?.len()) } fn verified_file(m: &Migration, dir: &Path) -> Result<(&'static str, std::path::PathBuf, bool)> { @@ -199,22 +258,39 @@ fn verified_file(m: &Migration, dir: &Path) -> Result<(&'static str, std::path:: /// Check every migration artifact, including applied history, before replacing server files. pub fn verify_files(list: &[Migration], dir: &Path) -> Result<()> { + validate_list(list)?; + for m in list { verified_file(m, dir)?; } + Ok(()) +} + +fn validate_list(list: &[Migration]) -> Result<()> { let mut seen = std::collections::BTreeSet::new(); for m in list { - if !seen.insert((&m.db, &m.id)) { return Err(Error::InvalidManifest(format!("duplicate migration {} ({})", m.id, m.db))); } - verified_file(m, dir)?; + if !ident_ok(&m.id) { return Err(Error::InvalidManifest(format!("migration id {:?} is not allowed", m.id))); } + let schema = db::schema_of(&m.db)?; + if !seen.insert((schema, m.id.to_ascii_lowercase())) { + return Err(Error::InvalidManifest(format!("duplicate migration {} ({})", m.id, m.db))); + } } Ok(()) } +fn pending_migrations<'a>(rows: &[LedgerRow], list: &'a [Migration]) -> Result> { + check_history(rows, list)?; + Ok(list.iter().filter(|m| recorded(rows, m).is_none_or(|r| r.status != Status::Applied)).collect()) +} + fn check_history(rows: &[LedgerRow], list: &[Migration]) -> Result<()> { + validate_list(list)?; for m in list { - if recorded(rows, m).is_some_and(|r| matches!(r.status, Status::Running | Status::Failed)) { + for row in effective_history(rows,m) { + if matches!(row.status, Status::Running | Status::Failed) { return Err(Error::Invalid(format!("Database update {} ({}) failed or was interrupted and may be partially applied. Restore its recovery point before retrying.", m.id, m.db))); } - if recorded(rows, m).is_some_and(|r| hash_changed(r, m)) { + if hash_changed(row,m) { return Err(Error::Invalid(format!("Applied database update {} ({}) has a different checksum. A new migration is required; it was not replayed.", m.id, m.db))); } + } } Ok(()) } @@ -223,9 +299,11 @@ fn check_history(rows: &[LedgerRow], list: &[Migration]) -> Result<()> { /// was brought up to date by hand). pub fn baseline(store: &dyn Store, list: &[Migration]) -> Result { let rows = store.load()?; + check_history(&rows,list)?; + preserve_auth_history(store,&rows,list)?; let mut n = 0; for m in list { - if rows.iter().any(|r| r.db == m.db && r.id == m.id && r.status == Status::Applied) { + if recorded(&rows,m).is_some_and(|r| r.status == Status::Applied) { continue; } store.put(&LedgerRow { db: m.db.clone(), id: m.id.clone(), sha256: m.sha256.clone(), status: Status::Applied, error: None, baseline: true })?; @@ -246,8 +324,7 @@ pub struct ApplyReport { pub fn apply_pending(store: &dyn Store, list: &[Migration], dir: &Path, snapshot: &dyn Fn() -> Result) -> Result { let rows = store.load()?; // The list order is the release's order of application (released updates, then pending, then modules). - check_history(&rows, list)?; - let todo: Vec<&Migration> = list.iter().filter(|m| recorded(&rows, m).is_none_or(|r| r.status != Status::Applied)).collect(); + let todo = pending_migrations(&rows, list)?; tracing::info!(total = list.len(), pending = todo.len(), skipped = list.len() - todo.len(), "database migration plan"); // Resolve and verify every file before running anything. @@ -261,11 +338,13 @@ pub fn apply_pending(store: &dyn Store, list: &[Migration], dir: &Path, snapshot let mut report = ApplyReport { applied: Vec::new(), failed: None, snapshot: None }; if files.is_empty() { + preserve_auth_history(store,&rows,list)?; return Ok(report); } if any_destructive { report.snapshot = Some(snapshot().map_err(|e| Error::Invalid(format!("No database update was applied because the safety backup failed: {e}")))?); } + preserve_auth_history(store,&rows,list)?; for (m, schema, path) in files { tracing::info!(migration = %m.id, database = %m.db, "database migration starting"); store.put(&LedgerRow { db: m.db.clone(), id: m.id.clone(), sha256: m.sha256.clone(), status: Status::Running, error: Some("Interrupted SQL must be recovered before replay.".into()), baseline: false })?; @@ -368,6 +447,23 @@ mod tests { assert!(store.ran.borrow().is_empty()); } + #[test] + fn every_entry_point_rejects_duplicate_sql_before_execution() { + let (dir, original) = setup(&[("world", "once", "SELECT 1;\n")]); + for alias in ["world", "coa-world"] { + let mut list = original.clone(); + let mut duplicate = original[0].clone(); + duplicate.db = alias.into(); + list.push(duplicate); + let store = Mem::default(); + assert!(preflight(&store, &list).is_err()); + assert!(pending_count(&store, &list).is_err()); + assert!(apply_pending(&store, &list, dir.path(), &no_snapshot).is_err()); + assert!(store.ran.borrow().is_empty()); + assert!(store.rows.borrow().is_empty()); + } + } + #[test] fn staged_files_reject_missing_corrupt_duplicate_and_invalid_migrations() { let (dir, list) = setup(&[("characters", "repair", "SELECT 1;\n")]); diff --git a/crates/coa-core/src/mysql_snapshot.rs b/crates/coa-core/src/mysql_snapshot.rs index 9a91cf8..2ec2fee 100644 --- a/crates/coa-core/src/mysql_snapshot.rs +++ b/crates/coa-core/src/mysql_snapshot.rs @@ -129,10 +129,6 @@ pub(crate) fn inventory(root: &Path) -> Result> { } pub(crate) fn copy_verified(source: &Path, target: &Path, files: &BTreeMap) -> Result<()> { - copy_verified_with_progress(source, target, files, &|_| {}) -} - -fn copy_verified_with_progress(source: &Path, target: &Path, files: &BTreeMap, report: &dyn Fn(&str)) -> 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)?)?; @@ -142,15 +138,13 @@ fn copy_verified_with_progress(source: &Path, target: &Path, files: &BTreeMap Result<()> { Ok(()) } -pub(crate) fn rehearsal_copy_with_progress(root: &Path, meta: &Path, point: &Path, snapshot: &Snapshot, report: &dyn Fn(&str)) -> Result { - report("Verifying the database recovery copy before rehearsal"); - verify(point, snapshot)?; - let canonical = fsx::canonicalize_lenient(root)?; - let root = canonical.as_path(); - let destination = meta.join("rehearsals").join(uuid::Uuid::new_v4().simple().to_string()).join("server"); - let mut files = BTreeMap::new(); - let mut file_bytes = 0u64; - 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() { - file_bytes = file_bytes.checked_add(metadata.len()).ok_or_else(|| Error::Invalid("Private copy size overflow.".into()))?; - files.insert(relative, fsx::sha256_file(&path)?); - if files.len() % 250 == 0 { report(&format!("Preparing the private copy: {} files checked", files.len())); } - } - else { return Err(Error::Invalid("Unsupported rehearsal file.".into())); } - } - } - let needed = file_bytes.checked_add(snapshot.expanded_bytes.max(snapshot.bytes)).ok_or_else(|| Error::Invalid("Private copy size overflow.".into()))?; - fsx::require_space(meta, needed)?; - copy_verified_with_progress(root, &destination, &files, report)?; - report("Restoring MySQL into the private server copy"); - 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)?; @@ -574,6 +471,46 @@ mod tests { }; 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(); + // Shared auth must not run again when the active realm changes. + use crate::migrations::Store; + db.query("CREATE DATABASE acore_world_wildcard;").unwrap(); + let migration_dir = root.join("migration-fixture"); + fs::create_dir_all(migration_dir.join("auth")).unwrap(); + fs::create_dir_all(migration_dir.join("world")).unwrap(); + let auth_sql = b"INSERT INTO acore_auth.probe VALUES(45);"; + fs::write(migration_dir.join("auth/shared_once.sql"), auth_sql).unwrap(); + let auth_list = vec![crate::manifest::Migration { id:"shared_once".into(), db:"auth".into(), sha256:fsx::sha256_bytes(auth_sql), destructive:false, compatible_sha256:vec![] }]; + assert_eq!(crate::migrations::apply_pending(&db, &auth_list, &migration_dir, &|| Ok("fixture".into())).unwrap().applied.len(), 1); + let wildcard = db.clone().for_realm(crate::realms::Mode::Wildcard); + assert_eq!(crate::migrations::pending_count(&wildcard, &auth_list).unwrap(), 0, "auth is shared by both realms"); + assert!(crate::migrations::apply_pending(&wildcard, &auth_list, &migration_dir, &|| Ok("fixture".into())).unwrap().applied.is_empty()); + assert_eq!(db.query("SELECT COUNT(*) FROM acore_auth.probe WHERE id=45;").unwrap(), "1"); + let mut failed = crate::migrations::LedgerRow { db:"auth".into(), id:"shared_once".into(), sha256:auth_list[0].sha256.clone(), status:crate::migrations::Status::Failed, error:Some("interrupted fixture".into()), baseline:false }; + db.put(&failed).unwrap(); + assert!(crate::migrations::pending_count(&wildcard, &auth_list).is_err()); + failed.status = crate::migrations::Status::Applied; failed.error = None; db.put(&failed).unwrap(); + + + // Legacy per-world auth records are adopted without replaying their SQL. + let legacy_sql = b"INSERT INTO acore_auth.probe VALUES(46);"; + fs::write(migration_dir.join("auth/legacy_auth.sql"), legacy_sql).unwrap(); + db.query(&format!("INSERT INTO acore_auth.probe VALUES(46); REPLACE INTO acore_world.coa_manager_migrations VALUES('auth','legacy_auth','{}','applied',NOW(),NULL,0);", fsx::sha256_bytes(legacy_sql))).unwrap(); + let legacy_auth = vec![crate::manifest::Migration { id:"legacy_auth".into(), db:"auth".into(), sha256:fsx::sha256_bytes(legacy_sql), destructive:false, compatible_sha256:vec![] }]; + assert_eq!(crate::migrations::pending_count(&wildcard, &legacy_auth).unwrap(), 0); + assert!(crate::migrations::apply_pending(&wildcard, &legacy_auth, &migration_dir, &|| Ok("fixture".into())).unwrap().applied.is_empty()); + assert_eq!(db.query("SELECT COUNT(*) FROM acore_auth.probe WHERE id=46;").unwrap(), "1"); + + // A core history cleanup cannot make previously recorded SQL pending again. + db.query("CREATE TABLE acore_world.updates(name VARCHAR(150) PRIMARY KEY) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; INSERT INTO acore_world.updates VALUES('LEGACY_WORLD.sql'); INSERT INTO acore_world.probe VALUES(99);").unwrap(); + db.load().unwrap(); + db.query("DROP TABLE acore_world.updates;").unwrap(); + let legacy_world_sql = b"INSERT INTO acore_world.probe VALUES(99);"; + fs::write(migration_dir.join("world/legacy_world.sql"), legacy_world_sql).unwrap(); + let legacy_world = vec![crate::manifest::Migration { id:"legacy_world".into(), db:"world".into(), sha256:fsx::sha256_bytes(legacy_world_sql), destructive:false, compatible_sha256:vec![] }]; + assert_eq!(crate::migrations::pending_count(&db, &legacy_world).unwrap(), 0); + assert!(crate::migrations::apply_pending(&db, &legacy_world, &migration_dir, &|| Ok("fixture".into())).unwrap().applied.is_empty()); + assert_eq!(db.query("SELECT COUNT(*) FROM acore_world.probe WHERE id=99;").unwrap(), "1"); + db.query("DELETE FROM acore_world.probe WHERE id=99;").unwrap(); assert_eq!(db.recovery_objects("acore_characters").unwrap().len(), 4); db.query("CREATE TABLE acore_characters.coa_squid_pending(path VARCHAR(255), sha256 CHAR(64)); INSERT INTO acore_characters.coa_squid_pending VALUES('playerbots/base/ai_playerbot_texts.sql', REPEAT('a',64));").unwrap(); let squid_problems = crate::squid::database_problems(&root, crate::realms::Mode::Coa, &|sql| { @@ -664,57 +601,10 @@ mod tests { 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()); - } - #[test] - fn rehearsal_copy_succeeds_with_nested_directories_and_unnormalized_root() { - let (_temp, root, meta, point, snapshot) = fixture(); - let meta_obj = crate::registry::InstallMeta::new(crate::registry::InstallKind::New, &root); - fsx::atomic_write_json(&meta.join("install.json"), &meta_obj).unwrap(); - fsx::atomic_write(&root.join("Core/worldserver.exe"), b"exe").unwrap(); - fsx::atomic_write(&root.join("Core/configs/worldserver.conf"), b"conf").unwrap(); - fsx::atomic_write(&root.join("Settings/repack.json"), b"{}").unwrap(); - let phases = std::cell::RefCell::new(Vec::new()); - let dest = rehearsal_copy_with_progress(&root, &meta, &point, &snapshot, &|phase| phases.borrow_mut().push(phase.to_string())).unwrap(); - assert_eq!(fs::read(dest.join("Core/configs/worldserver.conf")).unwrap(), b"conf"); - assert_eq!(fs::read(dest.join("mysql/data/characters.ibd")).unwrap(), b"saved"); - let phases = phases.into_inner(); - assert!(phases[0].contains("Verifying the database")); - assert!(phases.iter().any(|phase| phase.contains("Copying private server files"))); - assert!(phases.last().unwrap().contains("Restoring MySQL")); - } - #[test] - fn rehearsal_checks_combined_program_and_database_space_before_copying() { - let (_temp, root, meta, point, mut snapshot) = fixture(); - fs::create_dir_all(&meta).unwrap(); - fsx::atomic_write(&root.join("Core/worldserver.exe"), b"exe").unwrap(); - snapshot.expanded_bytes = fsx::free_space(&meta).unwrap().saturating_add(1024 * 1024 * 1024); - let error = rehearsal_copy_with_progress(&root, &meta, &point, &snapshot, &|_| {}).unwrap_err(); - assert!(matches!(error, Error::InsufficientSpace { .. })); - assert!(!meta.join("rehearsals").exists()); - assert_eq!(fs::read(root.join("mysql/data/characters.ibd")).unwrap(), b"live"); - } + + + + } diff --git a/crates/coa-core/src/ra.rs b/crates/coa-core/src/ra.rs index b1fcb7c..d64d7ef 100644 --- a/crates/coa-core/src/ra.rs +++ b/crates/coa-core/src/ra.rs @@ -348,7 +348,8 @@ AC>"; let (port, _h) = fake_ra("Account not found."); let mut ra = Ra::connect_to(port, "u", "p").unwrap(); assert!(ra.set_account_password("x", "newpass9").is_err(), "the name is validated before anything is sent"); - assert!(ra.set_account_password("Nobody", "newpass9").unwrap_err().to_string().contains("not found")); + let error = ra.set_account_password("Nobody", "newpass9").unwrap_err().to_string(); + assert!(error.contains("not found"), "Unexpected console error: {error}"); } #[test] diff --git a/crates/coa-core/src/release_schema.rs b/crates/coa-core/src/release_schema.rs index bb6a44e..1022215 100644 --- a/crates/coa-core/src/release_schema.rs +++ b/crates/coa-core/src/release_schema.rs @@ -14,8 +14,6 @@ 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 rehearse_with_progress(&self, manifest: &crate::manifest::Manifest, tree: &Path, items: &[crate::update::PlanItem], point: &str, report: &dyn Fn(&str, u8)) -> Result<()> { self.0.rehearse_with_progress(manifest, tree, items, point, report) } 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() } @@ -87,7 +85,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", "private-user-database-rehearsal", "database-schema", "private-auth-world-startup", "existing-identities", "user-file-preservation"], + "checks": ["manager-transaction", "recovery-backed-database-update", "database-schema", "private-auth-world-startup", "existing-identities", "user-file-preservation"], })) } diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index c596428..052b48c 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -92,9 +92,6 @@ 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(()) } - fn rehearse_with_progress(&self, manifest: &Manifest, tree: &Path, items: &[PlanItem], point: &str, _report: &dyn Fn(&str, u8)) -> Result<()> { self.rehearse(manifest, tree, items, point) } - /// Create a recovery point for the databases and configuration; returns its id. fn snapshot(&self) -> Result; fn restore_snapshot(&self, id: &str) -> Result<()>; fn migrate(&self, manifest: &Manifest, staged_migrations: &Path) -> Result; @@ -512,14 +509,6 @@ pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result { 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_with_progress(&m, &tree, &items, txn.recovery_point.as_deref().unwrap(), report) { - 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() .filter(|i| i.action != Action::Skip) @@ -907,7 +896,7 @@ impl RepackEnv<'_> { if realms.wildcard_created { modes.push(if realms.active == crate::realms::Mode::Coa { crate::realms::Mode::Wildcard } else { crate::realms::Mode::Coa }); } let mut count = 0; for (index, mode) in modes.into_iter().enumerate() { - let list: Vec<_> = manifest.migrations.iter().filter(|m| index == 0 || m.db != "auth").cloned().collect(); + let list: Vec<_> = manifest.migrations.iter().filter(|m| index == 0 || !crate::migrations::shared_auth(&m.db)).cloned().collect(); count += crate::migrations::pending_count(&db.clone().for_realm(mode), &list)?; } Ok(count) @@ -973,85 +962,6 @@ impl Env for RepackEnv<'_> { } fn automatic_rollback(&self) -> bool { true } - fn rehearse(&self, manifest: &Manifest, tree: &Path, items: &[PlanItem], id: &str) -> Result<()> { - self.rehearse_with_progress(manifest, tree, items, id, &|_, _| {}) - } - - fn rehearse_with_progress(&self, manifest: &Manifest, tree: &Path, items: &[PlanItem], id: &str, report: &dyn Fn(&str, u8)) -> 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_with_progress(self.root, self.meta_dir, &point_dir, snapshot, &|phase| step(report, phase, 54))?; - 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)?; - step(report, "Applying update files to the private server copy", 55); - 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)?; - step(report, "Testing database updates on the private server copy", 56); - 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) - })?; - step(report, "Testing startup of the private server copy", 57); - env.validate()?; - crate::backup::with_database(&fixture, |db| verify_database_identities(db, &identities))?; - env.ensure_stopped() - }); - step(report, "Stopping the private server copy and verifying the installed database", 57); - 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 { @@ -1071,7 +981,7 @@ impl Env for RepackEnv<'_> { 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"); + tracing::warn!(realm = mode.name(), table = %problem.table, "Installed schema differs; pending migrations and target validation will run with a full recovery point"); } } Ok(()) @@ -1126,43 +1036,10 @@ impl Env for RepackEnv<'_> { } fn migrate(&self, manifest: &Manifest, staged: &Path) -> Result { - 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, &|| { - if let Some(id) = &shared_snapshot { return Ok(id.clone()); } - self.migration_snapshot() - })?; - result.applied.extend(extra.applied); - 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()))); - } - } + crate::backup::with_database(self.root, |db| { + let identities = database_identities(db)?; + let result = migrate_databases(db, self.root, manifest, staged, &|| self.migration_snapshot())?; + if result.failed.is_none() { verify_database_identities(db, &identities)?; } Ok(result) }) } @@ -1178,23 +1055,24 @@ impl Env for RepackEnv<'_> { 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())); } + crate::mysql_snapshot::stop(self.root)?; self.ensure_stopped()?; self.recover_validation()?; let result = (|| -> Result<()> { crate::update_isolation::begin(self.root, self.meta_dir)?; use crate::process::{observe, ServiceState}; - 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 { - 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))); + let identities = crate::backup::with_database(self.root, |db| { + if check_schema { + 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_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))); + } } } - Ok(()) - })?; } + database_identities(db) + })?; // 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) { @@ -1232,6 +1110,7 @@ impl RepackEnv<'_> { } }; if healthy { + crate::backup::with_database(self.root, |db| verify_database_identities(db, &identities))?; return Ok(()); } let cause = crate::driver::startup_failure(self.root, &started); @@ -1239,10 +1118,7 @@ impl RepackEnv<'_> { 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())); - } + crate::mysql_snapshot::stop(self.root)?; self.ensure_stopped()?; self.recover_validation()?; result @@ -1250,6 +1126,40 @@ impl RepackEnv<'_> { } +/// One migration pass for both realms, with shared history checks and one recovery point. +fn migrate_databases(db: &crate::db::Db, root: &Path, manifest: &Manifest, staged: &Path, snapshot: &dyn Fn() -> Result) -> Result { + let realms = crate::realms::state(root)?; + let mut modes = vec![realms.active]; + if realms.wildcard_created { + modes.push(if realms.active == crate::realms::Mode::Coa { crate::realms::Mode::Wildcard } else { crate::realms::Mode::Coa }); + } + // Reject a broken history in either realm before changing the first one. + for (index, mode) in modes.iter().enumerate() { + let list: Vec<_> = manifest.migrations.iter().filter(|m| index == 0 || !crate::migrations::shared_auth(&m.db)).cloned().collect(); + crate::migrations::preflight(&db.clone().for_realm(*mode), &list)?; + } + let contract = manifest.files.iter().find(|file| file.path == crate::schema_check::CONTRACT).map(|file| file.sha256.as_str()); + let mut result = ApplyReport { applied: vec![], failed: None, snapshot: None }; + if contract.is_some() { result.snapshot = Some(snapshot()?); } + for (index, mode) in modes.into_iter().enumerate() { + let db = db.clone().for_realm(mode); + let list: Vec<_> = manifest.migrations.iter().filter(|m| index == 0 || !crate::migrations::shared_auth(&m.db)).cloned().collect(); + crate::schema_check::repair_missing_defaults(&db, root, contract)?; + let extra = crate::migrations::apply_pending(&db, &list, staged, &|| { + if let Some(id) = &result.snapshot { Ok(id.clone()) } else { snapshot() } + })?; + result.applied.extend(extra.applied); + result.failed = extra.failed; + if result.snapshot.is_none() { result.snapshot = extra.snapshot; } + if result.failed.is_some() { return Ok(result); } + crate::schema_check::repair_missing_defaults(&db, root, contract)?; + if let Some(p) = crate::schema_check::check_with_report(&db, root)?.first() { + return Err(Error::Invalid(format!("Database validation failed on {}: {}.{}: {}", mode.name(), p.table, p.column, p.detail))); + } + } + Ok(result) +} + fn database_identities(db: &crate::db::Db) -> Result>> { let db = db.clone().for_realm(crate::realms::Mode::Coa); let mut identities = BTreeMap::new(); @@ -1269,7 +1179,7 @@ fn verify_database_identities(db: &crate::db::Db, before: &BTreeMap, 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), rehearsal_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 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)?; @@ -2058,20 +1964,17 @@ mod tests { } #[test] - fn failed_rehearsal_preserves_installed_files_version_and_recovery_point() { + fn update_saves_recovery_then_migrates_and_validates_once() { 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()); + let result = run(&w, &env, BTreeMap::new(), None).unwrap(); + assert_eq!(result.txn.state, State::Committed); + let calls = env.calls.borrow(); + assert_eq!(calls.iter().filter(|call| **call == "snapshot").count(), 1); + assert_eq!(calls.iter().filter(|call| **call == "migrate").count(), 1); + assert_eq!(calls.iter().filter(|call| **call == "validate").count(), 1); + assert!(calls.iter().position(|call| *call == "snapshot") < calls.iter().position(|call| *call == "migrate")); + assert!(!w.meta.join("rehearsals").exists()); } #[test] diff --git a/docs/UPDATE-WORKFLOW.md b/docs/UPDATE-WORKFLOW.md new file mode 100644 index 0000000..1176f41 --- /dev/null +++ b/docs/UPDATE-WORKFLOW.md @@ -0,0 +1,19 @@ +# Update workflow + +Version compatibility selects a package; it does not prove that a particular installed database can apply it. + +The updater verifies the signed manifest and archive, checks file changes and database history, stops the server, and saves one complete recovery point. It then replaces files, applies pending SQL once, verifies the target schema and starts the updated server once for validation. Shared authentication migrations run once; both realm histories are checked before changing either. Account and character identities are compared before and after migration and startup. + +Account migration history is stored in the shared account database. Read-only checks recognize legacy auth records from both realms. Write operations adopt existing records and preserve already-applied core history; unknown legacy checksums stay unknown. History is read in two batched client calls, with explicit physical schemas and consistent text collations. Use the current Manager for subsequent database operations. + +Applied migrations are skipped; changed checksums and interrupted/failed records stop the update. Duplicate SQL identifiers for the same schema are rejected before execution. SQL or startup failure restores files and databases together. If recovery fails, startup remains blocked and the recovery artifacts remain available. + +The per-update nested server rehearsal has been removed. Full-copy upgrade/startup testing belongs to release qualification, on independent disposable installations. Cleanup of interrupted rehearsals from older Manager versions remains for compatibility. Signed packages, recovery verification, configuration merge and startup validation stay enabled. No migration guard is bypassed, no unexecuted SQL is marked applied, and the package compatibility matrix is unchanged. + +## Verify with an actual installation copy + +`cargo run -p coa-core --example upgrade_fixture -- ` exercises the production updater, including recovery copies and startup. It refuses mutation unless the directory name starts with `coa-schema-fixture-real` and contains `.coa-update-fixture`. Prepare a disposable copy from a verified recovery point, with separate ports, metadata and no client path. Never point it at a working installation. + +`migration_plan ` reads database history without creating a migration ledger. On a separate marked `update-database-diagnosis-*` copy, `--apply ` exercises the production database phase. `--sql-only ` verifies and applies the signed SQL list for both realms and compares account/character identities. SQL-only success is not full package/startup qualification and does not verify the target schema contract. + +Record package signatures, exact source recovery point, actual result, repeat-run behavior and unavailable stages. Do not label a route universally tested based on a version list or SQL-only success. diff --git a/package-lock.json b/package-lock.json index dba844a..3db365a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coa-server-manager", - "version": "0.6.19", + "version": "0.6.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coa-server-manager", - "version": "0.6.19", + "version": "0.6.20", "dependencies": { "@tauri-apps/api": "^2.12.0", "@tauri-apps/plugin-dialog": "^2.8.0", diff --git a/package.json b/package.json index a9b48f9..209eb21 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coa-server-manager", "private": true, - "version": "0.6.19", + "version": "0.6.20", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9b317b7..baab552 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.19", + "version": "0.6.20", "identifier": "com.coa.servermanager", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index bc0eb4d..cd158eb 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -430,7 +430,7 @@ const en = { "upd.folderHint": "Update package folder (leave empty to use the official source)", "upd.available": "Version {to} is available", "upd.availableFrom": "Version {to} is available (you have {from})", - "upd.intermediate": "Update to {to} first, then check again to continue to {latest}. Each step uses a tested upgrade path.", + "upd.intermediate": "Update to {to} first, then check again to continue to {latest}. The package supports this version sequence; your database is checked when you update.", "upd.summary": "Download {size} · {files} file(s) change", "upd.summaryDb": "Download {size} · {files} file(s) change · {db} database update(s)", "upd.conflict": "This file was modified outside CoA Server Manager.", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index ab433fd..3b8d2b4 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -412,7 +412,7 @@ const ru: Partial> = { "upd.folderHint": "Папка с пакетом обновления (оставьте пустым, чтобы использовать официальный источник)", "upd.available": "Доступна версия {to}", "upd.availableFrom": "Доступна версия {to} (у вас {from})", - "upd.intermediate": "Сначала обновитесь до {to}, затем проверьте обновления ещё раз для перехода на {latest}. Каждый этап использует проверенный путь обновления.", + "upd.intermediate": "Сначала обновитесь до {to}, затем проверьте обновления ещё раз для перехода на {latest}. Пакет поддерживает эту последовательность версий; состояние вашей базы проверяется при обновлении.", "upd.summary": "Загрузка {size} · изменится файлов: {files}", "upd.summaryDb": "Загрузка {size} · изменится файлов: {files} · обновлений базы данных: {db}", "upd.conflict": "Этот файл был изменён вне CoA Server Manager.", From e3fbb2e4b6dbe40fa00264abcd12b4cfd9702305 Mon Sep 17 00:00:00 2001 From: Corfirean Date: Fri, 16 Oct 2026 16:56:32 +0500 Subject: [PATCH 62/62] Update older versions directly using database migration history (#112) --- Cargo.lock | 6 +- Cargo.toml | 2 +- .../20261016-direct-database-upgrades.md | 7 + crates/coa-core/src/manifest.rs | 1 + crates/coa-core/src/update.rs | 15 +- crates/coa-core/src/update_route.rs | 187 +++++------------- docs/UPDATE-WORKFLOW.md | 4 +- package-lock.json | 4 +- package.json | 2 +- src-tauri/tauri.conf.json | 2 +- 10 files changed, 79 insertions(+), 151 deletions(-) create mode 100644 changelog.d/20261016-direct-database-upgrades.md diff --git a/Cargo.lock b/Cargo.lock index d0d9e0f..008cd61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -491,7 +491,7 @@ dependencies = [ [[package]] name = "coa-core" -version = "0.6.20" +version = "0.6.21" dependencies = [ "base64 0.22.1", "chrono", @@ -520,7 +520,7 @@ dependencies = [ [[package]] name = "coa-release" -version = "0.6.20" +version = "0.6.21" dependencies = [ "chrono", "coa-core", @@ -529,7 +529,7 @@ dependencies = [ [[package]] name = "coa-server-manager" -version = "0.6.20" +version = "0.6.21" dependencies = [ "coa-core", "serde", diff --git a/Cargo.toml b/Cargo.toml index aec4ac2..4248c97 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.20" +version = "0.6.21" edition = "2021" license = "AGPL-3.0-only" diff --git a/changelog.d/20261016-direct-database-upgrades.md b/changelog.d/20261016-direct-database-upgrades.md new file mode 100644 index 0000000..b385164 --- /dev/null +++ b/changelog.d/20261016-direct-database-upgrades.md @@ -0,0 +1,7 @@ +--- +area: manager +type: changed +audience: admins +title: Older server versions update directly without installing intermediate packages +--- +The Manager checks database history and applies missing SQL from the latest signed package. A version missing from the release test list no longer blocks the update. Unknown versions, interrupted migrations and downgrades remain blocked. diff --git a/crates/coa-core/src/manifest.rs b/crates/coa-core/src/manifest.rs index 7cbd4a9..0f6ac54 100644 --- a/crates/coa-core/src/manifest.rs +++ b/crates/coa-core/src/manifest.rs @@ -93,6 +93,7 @@ pub struct Compatibility { pub core_commit: String, pub module_commits: std::collections::BTreeMap, pub client_patch_version: String, + /// Sources exercised during release qualification, not an exclusive updater allowlist. pub source_versions: Vec, #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub source_databases: std::collections::BTreeMap, diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index 052b48c..29e94ba 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -387,8 +387,10 @@ fn reject_downgrade(meta: &InstallMeta, manifest: &Manifest) -> Result<()> { 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| version != &manifest.version && !c.source_versions.contains(version)) { - return Err(Error::Invalid(format!("Installed server {} is outside the compatibility matrix for {}. Tested source versions: {}.", meta.core.version.as_deref().unwrap_or("unknown"), manifest.version, c.source_versions.join(", ")))); + // The source list records release qualification, not an exclusive upgrade route. + // Database history and target validation decide whether the update can complete. + if meta.core.version.as_deref().and_then(crate::manifest::parse_version).is_none() { + return Err(Error::Invalid("The installed server version is unknown. An imported repack needs a verified starting state before updating.".into())); } } if let Some(installed) = meta.core.version.as_deref() { @@ -1630,7 +1632,7 @@ mod tests { } #[test] - fn incompatible_source_or_platform_is_rejected_without_touching_installation() { + fn unlisted_source_is_allowed_but_unknown_versions_and_other_platforms_are_rejected() { 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(); @@ -1643,7 +1645,12 @@ mod tests { 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")); + validate_candidate(&meta, &manifest).unwrap(); + let mut unknown = meta.clone(); + unknown.core.version = None; + assert!(validate_candidate(&unknown, &manifest).unwrap_err().to_string().contains("unknown")); + unknown.core.version = Some("unverified-repack".into()); + assert!(validate_candidate(&unknown, &manifest).is_err()); let mut already_updated = meta.clone(); already_updated.core.version = Some(manifest.version.clone()); validate_candidate(&already_updated, &manifest).unwrap(); diff --git a/crates/coa-core/src/update_route.rs b/crates/coa-core/src/update_route.rs index 73cb9c7..5cabaac 100644 --- a/crates/coa-core/src/update_route.rs +++ b/crates/coa-core/src/update_route.rs @@ -1,12 +1,9 @@ -use std::collections::BTreeSet; - -use crate::{fsx, manifest::Manifest, pkgsource::{self, Source}, registry::InstallMeta, update, Error, Result}; - -const RELEASES: &str = "https://github.com/Corfirean/coa-server-build/releases/download/server-"; +use crate::{manifest::Manifest, pkgsource::{self, Source}, registry::InstallMeta, update, Result}; #[derive(Debug)] pub struct Selection { pub source: Source, + // Retained for the frontend response shape; direct updates never offer a bridge. pub latest_version: Option, } @@ -16,48 +13,8 @@ pub fn select(meta: &InstallMeta, source: Source, key: &str) -> Result Result<(Manifest, Vec)>) -> Result { let (manifest, _) = fetch(&source)?; - if update::validate_candidate(meta, &manifest).is_ok() { - return Ok(Selection { source, latest_version: None }); - } - let original = update::validate_candidate(meta, &manifest).unwrap_err(); - if !matches!(&source, Source::Url(url) if url.trim_end_matches('/') == format!("{RELEASES}{}", manifest.version)) { - return Err(original); - } - let Some(installed) = meta.core.version.as_deref().and_then(crate::manifest::parse_version) else { return Err(original); }; - if manifest.kind != crate::manifest::Kind::Update - || manifest.compatibility.as_ref().is_none_or(|c| c.platform != if cfg!(windows) { "windows-x86_64" } else { "linux-x86_64" }) - || !manifest.compatible_with_manager(crate::MANAGER_VERSION) - || crate::manifest::parse_version(&manifest.version).is_none_or(|version| version <= installed) { - return Err(original); - } - let mut visited = BTreeSet::new(); - let Some(source) = find(meta, &manifest, fetch, &mut visited, 0)? else { return Err(original); }; - Ok(Selection { source, latest_version: Some(manifest.version) }) -} - -fn find(meta: &InstallMeta, manifest: &Manifest, fetch: &mut impl FnMut(&Source) -> Result<(Manifest, Vec)>, visited: &mut BTreeSet, depth: usize) -> Result> { - if depth >= 8 || visited.len() >= 32 { return Ok(None); } - let Some(compatibility) = &manifest.compatibility else { return Ok(None); }; - let Some(installed) = meta.core.version.as_deref().and_then(crate::manifest::parse_version) else { return Ok(None); }; - let target = crate::manifest::parse_version(&manifest.version).unwrap(); - let mut versions: Vec<_> = compatibility.source_versions.iter().filter_map(|version| { - crate::manifest::parse_version(version).filter(|number| *number > installed && *number < target).map(|number| (number, version)) - }).collect(); - versions.sort_by(|left, right| right.0.cmp(&left.0)); - for (_, version) in versions { - let Some(database) = compatibility.source_databases.get(version) else { continue; }; - if !visited.insert(version.clone()) || visited.len() > 32 { continue; } - let source = Source::Url(format!("{RELEASES}{version}")); - let (bridge, bytes) = fetch(&source)?; - if bridge.version != *version || fsx::sha256_bytes(&bytes) != database.manifest_sha256 { - return Err(Error::InvalidManifest("The intermediate update differs from the signed compatibility matrix.".into())); - } - if update::validate_candidate(meta, &bridge).is_ok() { return Ok(Some(source)); } - if bridge.kind != crate::manifest::Kind::Update || !bridge.compatible_with_manager(crate::MANAGER_VERSION) - || bridge.compatibility.as_ref().is_none_or(|c| c.platform != compatibility.platform) { continue; } - if let Some(source) = find(meta, &bridge, fetch, visited, depth + 1)? { return Ok(Some(source)); } - } - Ok(None) + update::validate_candidate(meta, &manifest)?; + Ok(Selection { source, latest_version: None }) } #[cfg(test)] @@ -67,12 +24,12 @@ mod tests { use ed25519_dalek::{Signer, SigningKey}; use std::{fs, path::Path}; - fn package(version: &str, from: &str, digest: &str) -> serde_json::Value { - serde_json::json!({"schema":1,"kind":"update","version":version,"core":{"commit":"a".repeat(40)}, + fn package() -> serde_json::Value { + serde_json::json!({"schema":1,"kind":"update","version":"0.261015.58","core":{"commit":"a".repeat(40)}, "builtAt":"test","minManagerVersion":"0.6.16","files":[],"migrations":[],"archive":null, "compatibility":{"schema":1,"platform":if cfg!(windows) {"windows-x86_64"} else {"linux-x86_64"}, "coreCommit":"a".repeat(40),"moduleCommits":{"squid":"b".repeat(40)},"clientPatchVersion":"1.7", - "sourceVersions":[from],"sourceDatabases":{from:{"manifestSha256":digest,"schemaSha256":null}}}}) + "sourceVersions":["0.261013.54"],"sourceDatabases":{"0.261013.54":{"manifestSha256":"c".repeat(64),"schemaSha256":null}}}}) } fn meta(version: &str) -> InstallMeta { @@ -81,100 +38,56 @@ mod tests { meta } - fn signed_fixture(values: &[serde_json::Value], tamper: Option<&str>) -> (tempfile::TempDir, String) { + fn fixture(value: &serde_json::Value, tamper: bool) -> (tempfile::TempDir, String) { let folder = tempfile::tempdir().unwrap(); let signing = SigningKey::from_bytes(&[37; 32]); - for value in values { - let version = value["version"].as_str().unwrap(); - let dir = folder.path().join(version); - fs::create_dir(&dir).unwrap(); - let bytes = serde_json::to_vec(value).unwrap(); - fs::write(dir.join("manifest.json.sig"), STANDARD.encode(signing.sign(&bytes).to_bytes())).unwrap(); - fs::write(dir.join("manifest.json"), &bytes).unwrap(); - if tamper == Some(version) { fs::write(dir.join("manifest.json"), b"tampered").unwrap(); } - } + let bytes = serde_json::to_vec(value).unwrap(); + fs::write(folder.path().join("manifest.json.sig"), STANDARD.encode(signing.sign(&bytes).to_bytes())).unwrap(); + fs::write(folder.path().join("manifest.json"), if tamper { b"tampered".as_slice() } else { &bytes }).unwrap(); (folder, STANDARD.encode(signing.verifying_key().to_bytes())) } - fn select_fixture(values: &[serde_json::Value], installed: &str, tamper: Option<&str>) -> Result { - let (folder, key) = signed_fixture(values, tamper); - let latest = values.last().unwrap()["version"].as_str().unwrap(); - select_with(&meta(installed), Source::Url(format!("{RELEASES}{latest}")), &mut |source| { - let Source::Url(url) = source else { unreachable!() }; - let version = url.strip_prefix(RELEASES).unwrap(); - pkgsource::fetch_manifest(&Source::Dir(folder.path().join(version)), &key) - }) - } - - fn pair() -> Vec { - let bridge = package("1.1.0", "1.0.0", &"c".repeat(64)); - let digest = fsx::sha256_bytes(&serde_json::to_vec(&bridge).unwrap()); - vec![bridge, package("1.2.0", "1.1.0", &digest)] - } - #[test] - fn finds_a_signed_intermediate_without_relaxing_candidate_validation() { - let selection = select_fixture(&pair(), "1.0.0", None).unwrap(); - assert!(matches!(selection.source, Source::Url(url) if url.ends_with("1.1.0"))); - assert_eq!(selection.latest_version.as_deref(), Some("1.2.0")); - let latest = Manifest::parse(&serde_json::to_vec(pair().last().unwrap()).unwrap()).unwrap(); - assert!(update::validate_candidate(&meta("1.0.0"), &latest).is_err()); - } - - #[test] - fn direct_updates_and_current_versions_do_not_request_an_intermediate() { - for installed in ["1.1.0", "1.2.0"] { - let selection = select_fixture(&pair(), installed, None).unwrap(); - assert!(selection.latest_version.is_none()); - } - } - - #[test] - fn rejects_tampered_or_replaced_intermediate_manifests() { - assert!(select_fixture(&pair(), "1.0.0", Some("1.1.0")).is_err()); - let mut values = pair(); - values[0]["builtAt"] = "replaced".into(); - assert!(select_fixture(&values, "1.0.0", None).unwrap_err().to_string().contains("differs")); - } - - #[test] - fn follows_multiple_tested_edges_and_rejects_missing_paths() { - let mut values = pair(); - let digest = fsx::sha256_bytes(&serde_json::to_vec(values.last().unwrap()).unwrap()); - values.push(package("1.3.0", "1.2.0", &digest)); - let selection = select_fixture(&values, "1.0.0", None).unwrap(); - assert!(matches!(selection.source, Source::Url(url) if url.ends_with("1.1.0"))); - assert!(select_fixture(&values, "0.9.0", None).is_err()); - assert!(select_fixture(&values, "2.0.0", None).is_err()); - } - - #[test] - fn never_routes_custom_sources_or_unknown_installations() { - let values = pair(); - let (folder, key) = signed_fixture(&values, None); - let mut calls = 0; - let result = select_with(&meta("1.0.0"), Source::Dir(folder.path().join("1.2.0")), &mut |source| { - calls += 1; - pkgsource::fetch_manifest(source, &key) - }); - assert!(result.is_err()); - assert_eq!(calls, 1); - assert!(select_fixture(&values, "unknown", None).is_err()); - } - - #[test] - fn rejects_incompatible_platforms_and_manager_versions() { - for index in [0, 1] { - for (field, value) in [("minManagerVersion", "99.0.0"), ("platform", "other-platform")] { - let mut values = pair(); - if field == "platform" { values[index]["compatibility"][field] = value.into(); } - else { values[index][field] = value.into(); } - if index == 0 { - let digest = fsx::sha256_bytes(&serde_json::to_vec(&values[0]).unwrap()); - values[1]["compatibility"]["sourceDatabases"]["1.1.0"]["manifestSha256"] = digest.into(); - } - assert!(select_fixture(&values, "1.0.0", None).is_err()); + fn every_known_older_version_selects_the_latest_signed_package_once() { + for installed in ["0.2.0", "0.261004.20", "0.261005.0", "0.261007.24", "0.261011.800006", "0.261013.54", "0.261015.58"] { + let (folder, key) = fixture(&package(), false); + for kind in [crate::registry::InstallKind::New, crate::registry::InstallKind::Imported] { + let mut installation = meta(installed); + installation.kind = kind; + let mut calls = 0; + let selected = select_with(&installation, Source::Dir(folder.path().into()), &mut |source| { + calls += 1; + pkgsource::fetch_manifest(source, &key) + }).unwrap(); + assert!(matches!(selected.source, Source::Dir(path) if path == folder.path())); + assert!(selected.latest_version.is_none()); + assert_eq!(calls, 1, "no intermediate package fetches for {installed}"); } } } + + #[test] + fn unknown_versions_and_downgrades_remain_blocked() { + let (folder, key) = fixture(&package(), false); + for installed in ["unknown", "a65be06ec62f4", "0.261016.59"] { + assert!(select(&meta(installed), Source::Dir(folder.path().into()), &key).is_err()); + } + } + + #[test] + fn signed_target_is_required_even_for_unlisted_old_versions() { + let (folder, key) = fixture(&package(), true); + assert!(select(&meta("0.261004.20"), Source::Dir(folder.path().into()), &key).is_err()); + } + + #[test] + fn platform_and_manager_requirements_are_still_enforced() { + for (field, value) in [("minManagerVersion", "99.0.0"), ("platform", "other-platform")] { + let mut target = package(); + if field == "platform" { target["compatibility"][field] = value.into(); } + else { target[field] = value.into(); } + let (folder, key) = fixture(&target, false); + assert!(select(&meta("0.261004.20"), Source::Dir(folder.path().into()), &key).is_err()); + } + } } diff --git a/docs/UPDATE-WORKFLOW.md b/docs/UPDATE-WORKFLOW.md index 1176f41..be829da 100644 --- a/docs/UPDATE-WORKFLOW.md +++ b/docs/UPDATE-WORKFLOW.md @@ -1,6 +1,6 @@ # Update workflow -Version compatibility selects a package; it does not prove that a particular installed database can apply it. +The Manager selects the latest signed package directly for any known older server version. The source-version list records release qualification; it is not an exclusive allowlist and does not prove that a particular installed database can apply the package. Unknown imported versions, downgrades, incompatible platforms and newer Manager requirements remain blocked. The updater verifies the signed manifest and archive, checks file changes and database history, stops the server, and saves one complete recovery point. It then replaces files, applies pending SQL once, verifies the target schema and starts the updated server once for validation. Shared authentication migrations run once; both realm histories are checked before changing either. Account and character identities are compared before and after migration and startup. @@ -8,7 +8,7 @@ Account migration history is stored in the shared account database. Read-only ch Applied migrations are skipped; changed checksums and interrupted/failed records stop the update. Duplicate SQL identifiers for the same schema are rejected before execution. SQL or startup failure restores files and databases together. If recovery fails, startup remains blocked and the recovery artifacts remain available. -The per-update nested server rehearsal has been removed. Full-copy upgrade/startup testing belongs to release qualification, on independent disposable installations. Cleanup of interrupted rehearsals from older Manager versions remains for compatibility. Signed packages, recovery verification, configuration merge and startup validation stay enabled. No migration guard is bypassed, no unexecuted SQL is marked applied, and the package compatibility matrix is unchanged. +The per-update nested server rehearsal and intermediate-package routing have been removed. Full-copy upgrade/startup testing belongs to release qualification, on independent disposable installations. Cleanup of interrupted rehearsals from older Manager versions remains for compatibility. Signed packages, recovery verification, configuration merge and startup validation stay enabled. No migration guard is bypassed and no unexecuted SQL is marked applied. Retired source archives do not prevent a direct update, but the database history must still be consistent and target checks must pass. ## Verify with an actual installation copy diff --git a/package-lock.json b/package-lock.json index 3db365a..501453d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coa-server-manager", - "version": "0.6.20", + "version": "0.6.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coa-server-manager", - "version": "0.6.20", + "version": "0.6.21", "dependencies": { "@tauri-apps/api": "^2.12.0", "@tauri-apps/plugin-dialog": "^2.8.0", diff --git a/package.json b/package.json index 209eb21..1e601e8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coa-server-manager", "private": true, - "version": "0.6.20", + "version": "0.6.21", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index baab552..dd98f84 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.20", + "version": "0.6.21", "identifier": "com.coa.servermanager", "build": { "beforeDevCommand": "npm run dev",