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/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-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-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/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/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/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-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/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/changelog.d/20261010-release-compatibility.md b/changelog.d/20261010-release-compatibility.md new file mode 100644 index 0000000..ba91b80 --- /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 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/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/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/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/cleanbase.rs b/crates/coa-core/src/cleanbase.rs index ced4b04..44cef0b 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)?); } @@ -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/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 0466bff..a5ad4e8 100644 --- a/crates/coa-core/src/diag.rs +++ b/crates/coa-core/src/diag.rs @@ -401,6 +401,19 @@ 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| { + 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) { + 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 +599,9 @@ 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(); + 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"); @@ -600,6 +616,10 @@ 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("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/lib.rs b/crates/coa-core/src/lib.rs index e276e03..3476ebf 100644 --- a/crates/coa-core/src/lib.rs +++ b/crates/coa-core/src/lib.rs @@ -44,9 +44,12 @@ pub mod release; 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; +pub mod channels; pub mod srp6; pub use error::{Error, ErrorCode, Result}; diff --git a/crates/coa-core/src/manifest.rs b/crates/coa-core/src/manifest.rs index b87dbe9..7cbd4a9 100644 --- a/crates/coa-core/src/manifest.rs +++ b/crates/coa-core/src/manifest.rs @@ -78,9 +78,31 @@ 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 { + 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, + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub source_databases: std::collections::BTreeMap, +} + #[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 +158,26 @@ 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)) + || 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.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)) { return bad(format!("{}: invalid migration checksum compatibility", migration.id)); @@ -232,6 +274,53 @@ 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(), + 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()); + 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 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/mysql_snapshot.rs b/crates/coa-core/src/mysql_snapshot.rs new file mode 100644 index 0000000..9d445ee --- /dev/null +++ b/crates/coa-core/src/mysql_snapshot.rs @@ -0,0 +1,478 @@ +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 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() { + 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 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() { + 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/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/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] diff --git a/crates/coa-core/src/release_schema.rs b/crates/coa-core/src/release_schema.rs index 16d274c..c79f57c 100644 --- a/crates/coa-core/src/release_schema.rs +++ b/crates/coa-core/src/release_schema.rs @@ -5,6 +5,82 @@ 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 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() } + 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", "private-user-database-rehearsal", "database-schema", "private-auth-world-startup", "existing-identities", "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)?); } @@ -72,6 +148,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) { @@ -83,12 +214,36 @@ 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::*; 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(); @@ -127,6 +282,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-core/src/schema_check.rs b/crates/coa-core/src/schema_check.rs index 0bb5e5c..8e692af 100644 --- a/crates/coa-core/src/schema_check.rs +++ b/crates/coa-core/src/schema_check.rs @@ -39,6 +39,35 @@ 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)?; + 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(), 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"); + } + 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;"))?; @@ -56,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))?; @@ -98,7 +167,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()) }); } } } @@ -146,6 +215,23 @@ 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() { + 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() { diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index c90711f..b91bf0d 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -85,9 +85,14 @@ 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<()>; 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<()>; @@ -177,6 +182,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))); } @@ -360,6 +368,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 +397,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, @@ -472,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() @@ -495,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)?; @@ -514,7 +544,14 @@ 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, + 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)?; @@ -522,6 +559,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 }) @@ -594,17 +632,24 @@ 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) } 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}") }); 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."), @@ -628,6 +673,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)?; @@ -815,13 +861,27 @@ 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(); 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(()) }) @@ -839,7 +899,116 @@ 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 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)) { + 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_with_report(&db.clone().for_realm(mode), self.root)?.first() { + 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(()) + })?; + } + } self.pending_migrations(manifest).map(|_| ()) } @@ -873,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)?; } @@ -884,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, &|| { @@ -900,13 +1081,15 @@ 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() { + 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() { - let problems = crate::schema_check::check(db, root)?; + 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()))); } @@ -916,61 +1099,111 @@ 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))); - } - } - 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)) + 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()?; + 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))); + } + } + 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())); + } + self.ensure_stopped()?; + self.recover_validation()?; + result + } + +} + +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)] @@ -983,6 +1216,11 @@ 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, healthy: Cell, snapshot_ok: Cell, @@ -990,15 +1228,32 @@ mod tests { migrate_fail: Cell, preflight_fail: Cell, restore_fail: Cell, + rehearsal_fail: Cell, } impl Fake { fn ok() -> Fake { - Fake { stopped: Cell::new(true), healthy: Cell::new(true), snapshot_ok: Cell::new(true), calls: Default::default(), migrate_fail: Cell::new(false), 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)?; + 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"); + 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(()) } } @@ -1195,6 +1450,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"; @@ -1304,6 +1579,75 @@ 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(), + 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()]; + 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 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); + 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); @@ -1497,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/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(()) +} diff --git a/crates/coa-release/src/main.rs b/crates/coa-release/src/main.rs index 7551547..9dddaf2 100644 --- a/crates/coa-release/src/main.rs +++ b/crates/coa-release/src/main.rs @@ -47,6 +47,36 @@ 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")?), + &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)?; + 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); + } + "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")?), @@ -112,6 +142,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/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 1d9ade2..7dc9d80 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,10 +827,13 @@ 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 (dir, _) = install_meta(&root)?; + let src = update_source(source)?; + 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", 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 diff --git a/tools/test_module_sql.py b/tools/test_module_sql.py new file mode 100644 index 0000000..aa954d3 --- /dev/null +++ b/tools/test_module_sql.py @@ -0,0 +1,98 @@ +"""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) + 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) + 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) + 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) + 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') + 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: + process.wait(timeout=15) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +if __name__ == '__main__': + main()