diff --git a/.gitignore b/.gitignore index 92daadd..1dcd39e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ src-tauri/gen # standalone service workspaces build into their own target folders services/*/target/ +# Disposable database fixtures and private recovery data +.cache/ diff --git a/changelog.d/20261005-linux-docker-server-settings.md b/changelog.d/20261005-linux-docker-server-settings.md new file mode 100644 index 0000000..fa6ba2b --- /dev/null +++ b/changelog.d/20261005-linux-docker-server-settings.md @@ -0,0 +1,7 @@ +--- +area: manager +type: added +audience: admins +title: Linux Docker servers follow Settings, sharing and backups (experimental) +--- +Experimental. Settings the Docker backend fixes are hidden, friends sharing and logs work, the game data folder can be changed, and the realm no longer shows offline after a start. 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-custom-races-bot-exclusions.md b/changelog.d/20261010-custom-races-bot-exclusions.md new file mode 100644 index 0000000..29fb894 --- /dev/null +++ b/changelog.d/20261010-custom-races-bot-exclusions.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Custom races bot setting synchronizes Playerbots excluded race lists +--- +The "Bots can be custom races" setting now configures AiPlayerbot.ExcludedBotRaces in playerbots.conf. Off excludes all added races (recommended), while on allows stable added races (experimental). diff --git a/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-local-host-while-sharing.md b/changelog.d/20261010-local-host-while-sharing.md new file mode 100644 index 0000000..80ad6f9 --- /dev/null +++ b/changelog.d/20261010-local-host-while-sharing.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Local hosts can connect while sharing a server online +--- +The Manager no longer replaces the realm's local address with its public address when Friends mode is enabled. diff --git a/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/changelog.d/20261011-player-race-patches.md b/changelog.d/20261011-player-race-patches.md new file mode 100644 index 0000000..15b050a --- /dev/null +++ b/changelog.d/20261011-player-race-patches.md @@ -0,0 +1,7 @@ +--- +area: manager +type: added +audience: players +title: Players can install custom race patches when connecting to a friend +--- +Install the custom race client patch directly from the Game client section without hosting a server. diff --git a/changelog.d/20261012-bulk-navigation-update.md b/changelog.d/20261012-bulk-navigation-update.md new file mode 100644 index 0000000..3b8cf4b --- /dev/null +++ b/changelog.d/20261012-bulk-navigation-update.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Large server updates no longer repeatedly rewrite the recovery journal +--- +Updates containing rebuilt navigation data now prepare all recovery copies before replacing files. Interrupted updates remain recoverable without rewriting the full journal for every file. \ No newline at end of file diff --git a/changelog.d/20261012-rehearsal-path-normalization.md b/changelog.d/20261012-rehearsal-path-normalization.md new file mode 100644 index 0000000..e558f86 --- /dev/null +++ b/changelog.d/20261012-rehearsal-path-normalization.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Normalize rehearsal server roots to prevent Windows path prefix errors +--- +Server update rehearsals now canonicalize the installation root before collecting files, resolving a "prefix not found" error during rehearsal preparation on Windows. diff --git a/changelog.d/20261013-custom-races-client-update.md b/changelog.d/20261013-custom-races-client-update.md new file mode 100644 index 0000000..b663df9 --- /dev/null +++ b/changelog.d/20261013-custom-races-client-update.md @@ -0,0 +1,7 @@ +--- +area: client +type: changed +audience: admins +title: Custom race client installation follows the current upstream release +--- +The installer verifies the current race archives and includes the two additional MPQs. Legacy patch hashes remain available so upgrading never mistakes the old custom executable for an original launcher file. diff --git a/changelog.d/20261013-docker-remote-client-restart.md b/changelog.d/20261013-docker-remote-client-restart.md new file mode 100644 index 0000000..01bafc6 --- /dev/null +++ b/changelog.d/20261013-docker-remote-client-restart.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Docker friend mode requests a restart when remote client support changes +--- +Enabling remote CoA clients now requests a world restart even when the game ports were already open. Repeating the same unchanged setting does not request another restart. diff --git a/changelog.d/20261013-installed-module-versions.md b/changelog.d/20261013-installed-module-versions.md new file mode 100644 index 0000000..7916c12 --- /dev/null +++ b/changelog.d/20261013-installed-module-versions.md @@ -0,0 +1,7 @@ +--- +area: manager +type: added +audience: admins +title: Module cards show installed server revisions and the client race patch version +--- +Module revisions come from the currently installed release metadata. The race card shows the client patch version separately; missing version metadata is reported honestly instead of showing a download version as installed. diff --git a/changelog.d/20261013-long-rehearsal-paths.md b/changelog.d/20261013-long-rehearsal-paths.md new file mode 100644 index 0000000..aaa5593 --- /dev/null +++ b/changelog.d/20261013-long-rehearsal-paths.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Server updates accept long Windows paths inside the private test server +--- +Long SQL file paths no longer fail the folder safety check during update rehearsals. Files outside the server folder remain blocked. diff --git a/changelog.d/20261013-maintenance-diagnostic-export.md b/changelog.d/20261013-maintenance-diagnostic-export.md new file mode 100644 index 0000000..635b5d7 --- /dev/null +++ b/changelog.d/20261013-maintenance-diagnostic-export.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Diagnostics record blocked startup requests and maintenance owner information +--- +A start refused before the launcher runs is now logged with its reason. Exported diagnostics include the last recorded maintenance owner, while clearly distinguishing old metadata from a currently held operating-system lock. diff --git a/changelog.d/20261013-preserve-client-patches.md b/changelog.d/20261013-preserve-client-patches.md new file mode 100644 index 0000000..6190ff6 --- /dev/null +++ b/changelog.d/20261013-preserve-client-patches.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Client race patches can be repaired and removed while preserving original files +--- +Every overwritten client file gets an original backup. Switching preserves newer launcher files; checksums detect missing or replaced assets. Repair and removal are always available, with rollback if a file operation fails. diff --git a/changelog.d/20261013-recheck-installed-release.md b/changelog.d/20261013-recheck-installed-release.md new file mode 100644 index 0000000..c77c8d7 --- /dev/null +++ b/changelog.d/20261013-recheck-installed-release.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Checking an already installed server update no longer reports incompatibility +--- +The installed release can be checked again even when its compatibility list only names earlier releases. Platform checks and protection against downgrades still apply. diff --git a/changelog.d/20261013-startup-error-details.md b/changelog.d/20261013-startup-error-details.md new file mode 100644 index 0000000..7324d0c --- /dev/null +++ b/changelog.d/20261013-startup-error-details.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Failed server startup shows service errors even when the launcher reported ready +--- +Update rehearsal and startup failures include recent world, authentication and database error lines. Missing tables and late startup failures are visible directly in the error details, with secrets redacted. diff --git a/changelog.d/20261013-stock-bot-races.md b/changelog.d/20261013-stock-bot-races.md new file mode 100644 index 0000000..032c1ac --- /dev/null +++ b/changelog.d/20261013-stock-bot-races.md @@ -0,0 +1,7 @@ +--- +area: bots +type: fixed +audience: admins +title: Disabling custom races for SQUID bots now excludes all nonstandard races +--- +The standard-races preset keeps only the ten original playable races. High elves, worgen and other custom race IDs are excluded, and the displayed setting reads complete race IDs rather than matching number substrings. diff --git a/changelog.d/20261013-update-check-start-contention.md b/changelog.d/20261013-update-check-start-contention.md new file mode 100644 index 0000000..24d7cf7 --- /dev/null +++ b/changelog.d/20261013-update-check-start-contention.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Starting the server waits for update checks and identifies ongoing maintenance +--- +Downloading an update manifest no longer blocks startup. A start waits up to 30 seconds for a check to finish; other active maintenance still blocks it safely and shows its operation, process and start time. diff --git a/changelog.d/20261013-upstream-bot-race-preset.md b/changelog.d/20261013-upstream-bot-race-preset.md new file mode 100644 index 0000000..a51228d --- /dev/null +++ b/changelog.d/20261013-upstream-bot-race-preset.md @@ -0,0 +1,7 @@ +--- +area: bots +type: changed +audience: admins +title: The experimental SQUID race preset follows the current race release +--- +Custom-race bots now use the upstream supported-race preset, excluding Test Race, Skyborne and Darkfallen. The standard-races preset continues to exclude all custom races. diff --git a/changelog.d/20261014-already-running-mysql-is-recognized-before-start.md b/changelog.d/20261014-already-running-mysql-is-recognized-before-start.md new file mode 100644 index 0000000..0ce2397 --- /dev/null +++ b/changelog.d/20261014-already-running-mysql-is-recognized-before-start.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Already running MySQL is recognized before startup readiness checks +--- +A verified MySQL listener replaces a stale or supervisor-only launcher record before startup. Existing databases are reused instead of waiting for a port owned by a different process from the same executable. diff --git a/changelog.d/20261014-current-clean-install.md b/changelog.d/20261014-current-clean-install.md new file mode 100644 index 0000000..83f0d6e --- /dev/null +++ b/changelog.d/20261014-current-clean-install.md @@ -0,0 +1,7 @@ +--- +area: manager +type: changed +audience: admins +title: New servers install the current stable build directly +--- +A clean installation downloads the complete package of the selected stable release, including its prepared database. It no longer installs an old base and asks for a separate update. Missing or mismatched full packages stop installation before changing the destination. diff --git a/changelog.d/20261014-custom-races-grummle-client.md b/changelog.d/20261014-custom-races-grummle-client.md new file mode 100644 index 0000000..b2c904b --- /dev/null +++ b/changelog.d/20261014-custom-races-grummle-client.md @@ -0,0 +1,7 @@ +--- +area: manager +type: changed +audience: players +title: Install the matching client patches for Grummle +--- +Custom race installation includes the new Grummle appearance and voices while retaining the existing race assets. Older installations can still be safely switched off or removed. diff --git a/changelog.d/20261014-failed-repairs-restore-the-previous-server-after.md b/changelog.d/20261014-failed-repairs-restore-the-previous-server-after.md new file mode 100644 index 0000000..32652d2 --- /dev/null +++ b/changelog.d/20261014-failed-repairs-restore-the-previous-server-after.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Failed repairs restore the previous server after final verification errors +--- +Errors after file replacement now undo the repair using its verified recovery point, including final file or database check errors. If recovery fails, the journal retains the cause and further server actions remain blocked. diff --git a/changelog.d/20261014-overview-update-errors.md b/changelog.d/20261014-overview-update-errors.md new file mode 100644 index 0000000..eeac22f --- /dev/null +++ b/changelog.d/20261014-overview-update-errors.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Failed server updates show an error on the Overview screen +--- +The error stays visible after the progress bar disappears, with technical details and a copy button. Recovery-state errors are shown there too. diff --git a/changelog.d/20261014-private-update-checks-show-preparation-stages-an.md b/changelog.d/20261014-private-update-checks-show-preparation-stages-an.md new file mode 100644 index 0000000..71cef60 --- /dev/null +++ b/changelog.d/20261014-private-update-checks-show-preparation-stages-an.md @@ -0,0 +1,7 @@ +--- +area: manager +type: changed +audience: admins +title: Private update checks show preparation stages and stop failure details +--- +Private-copy preparation reports checked and copied file counts, database tests and startup stages. Failed shutdown reports the server folder, remaining processes and launcher reason. Port conflicts identify the owning process and executable. diff --git a/changelog.d/20261014-repair-recovery-copies-preserve-all-bundled-data.md b/changelog.d/20261014-repair-recovery-copies-preserve-all-bundled-data.md new file mode 100644 index 0000000..09ea928 --- /dev/null +++ b/changelog.d/20261014-repair-recovery-copies-preserve-all-bundled-data.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Repair recovery copies preserve all bundled database objects and accounts +--- +Windows repairs now save a verified copy of stopped MySQL before changing server files or SQL. Recovery preserves procedures, triggers, views and database accounts as well as player rows. diff --git a/changelog.d/20261014-server-checks-report-interrupted-squid-database.md b/changelog.d/20261014-server-checks-report-interrupted-squid-database.md new file mode 100644 index 0000000..77e0e07 --- /dev/null +++ b/changelog.d/20261014-server-checks-report-interrupted-squid-database.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Server checks report interrupted SQUID database imports +--- +Check and Repair now report SQUID pending imports and missing bot character tables separately from package migrations. Interrupted imports retain their records and require verified database recovery. diff --git a/changelog.d/20261014-shutdown-remains-available-when-startup-template.md b/changelog.d/20261014-shutdown-remains-available-when-startup-template.md new file mode 100644 index 0000000..4aae615 --- /dev/null +++ b/changelog.d/20261014-shutdown-remains-available-when-startup-template.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Shutdown remains available when startup templates or database records are damaged +--- +Stop uses existing shutdown settings instead of rendering startup templates. Missing or stale MySQL records are recovered only from the exact installed executable and its owned port; foreign processes are never adopted. diff --git a/changelog.d/20261014-successful-server-repairs-no-longer-block-later.md b/changelog.d/20261014-successful-server-repairs-no-longer-block-later.md new file mode 100644 index 0000000..24bca4c --- /dev/null +++ b/changelog.d/20261014-successful-server-repairs-no-longer-block-later.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Successful server repairs no longer block later server actions +--- +Repair verifies restored files before completing its recovery record. Older completed repair records are accepted only when every repaired file still matches its expected content. diff --git a/changelog.d/20261016-consistent-update-migrations.md b/changelog.d/20261016-consistent-update-migrations.md new file mode 100644 index 0000000..2f9e794 --- /dev/null +++ b/changelog.d/20261016-consistent-update-migrations.md @@ -0,0 +1,7 @@ +--- +area: manager +type: changed +audience: admins +title: Server updates apply migrations once after saving a recovery point +--- +Updates no longer build and start a second full server copy. The Manager checks both realms, saves recovery, applies pending SQL once and verifies startup. Failed SQL or startup restores the recovery point. Full-copy upgrade testing remains a release task. diff --git a/changelog.d/20261016-direct-database-upgrades.md b/changelog.d/20261016-direct-database-upgrades.md new file mode 100644 index 0000000..b385164 --- /dev/null +++ b/changelog.d/20261016-direct-database-upgrades.md @@ -0,0 +1,7 @@ +--- +area: manager +type: changed +audience: admins +title: Older server versions update directly without installing intermediate packages +--- +The Manager checks database history and applies missing SQL from the latest signed package. A version missing from the release test list no longer blocks the update. Unknown versions, interrupted migrations and downgrades remain blocked. diff --git a/changelog.d/20261016-idempotent-validation-stop.md b/changelog.d/20261016-idempotent-validation-stop.md new file mode 100644 index 0000000..73b9541 --- /dev/null +++ b/changelog.d/20261016-idempotent-validation-stop.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Update validation recognizes an already stopped server +--- +Validation uses the same complete-server stop check as recovery instead of issuing redundant unconditional shutdown commands. diff --git a/changelog.d/20261016-intermediate-updates.md b/changelog.d/20261016-intermediate-updates.md new file mode 100644 index 0000000..8e8cc68 --- /dev/null +++ b/changelog.d/20261016-intermediate-updates.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Older servers find a tested intermediate update instead of being blocked +--- +When the latest release cannot update an older server directly, Manager offers a signed intermediate release from its compatibility matrix and explains how to continue to the latest version. diff --git a/changelog.d/20261016-lightweight-update-checks.md b/changelog.d/20261016-lightweight-update-checks.md new file mode 100644 index 0000000..03fa93a --- /dev/null +++ b/changelog.d/20261016-lightweight-update-checks.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Update checks no longer download a server archive to compare its launcher +--- +Manager recognizes its supported launcher edits against the signed file hash locally. Overlapping checks wait briefly for one another, and the private update copy checks combined program and database space before copying. diff --git a/changelog.d/20261016-preserve-core-migration-history.md b/changelog.d/20261016-preserve-core-migration-history.md new file mode 100644 index 0000000..d86e0a2 --- /dev/null +++ b/changelog.d/20261016-preserve-core-migration-history.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Recorded core SQL stays applied after old history tables are cleaned up +--- +Existing core migration records are preserved in Manager history. Unknown legacy checksums remain explicitly unknown, and interrupted or changed migrations are still rejected. diff --git a/changelog.d/20261016-shared-auth-migration-history.md b/changelog.d/20261016-shared-auth-migration-history.md new file mode 100644 index 0000000..deeb606 --- /dev/null +++ b/changelog.d/20261016-shared-auth-migration-history.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Switching realms no longer repeats shared account database updates +--- +Solo and Wildcard use one migration history for their shared account database. Previously applied records from either realm are recognized without running their SQL again. diff --git a/changelog.d/20261016-update-compatibility-wording.md b/changelog.d/20261016-update-compatibility-wording.md new file mode 100644 index 0000000..0ccd451 --- /dev/null +++ b/changelog.d/20261016-update-compatibility-wording.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Update offers distinguish version support from database validation +--- +An available version sequence no longer claims that every database state has been tested. Database compatibility is checked during the update. diff --git a/crates/coa-core/examples/migration_plan.rs b/crates/coa-core/examples/migration_plan.rs new file mode 100644 index 0000000..db5fc57 --- /dev/null +++ b/crates/coa-core/examples/migration_plan.rs @@ -0,0 +1,57 @@ +//! Read-only audit, or explicit SQL checks on a disposable database fixture. +use coa_core::{db::{Account, Db}, migrations::{self, Store}, manifest::Manifest, realms::Mode}; +fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args().collect(); + if args.len() < 3 { return Err("usage: migration_plan [--apply|--sql-only ]".into()); } + let root = std::path::Path::new(&args[1]); + let bytes = std::fs::read(&args[2])?; + let signature = std::fs::read_to_string(format!("{}.sig",args[2]))?; + coa_core::signing::verify(&bytes, &signature, coa_core::signing::EMBEDDED_PUBLIC_KEY)?; + let manifest = Manifest::parse(&bytes)?; + for (index,mode) in [Mode::Coa, Mode::Wildcard].into_iter().enumerate() { + let db = Db::from_repack(root, Account::Admin)?.for_realm(mode); + let rows = db.load_readonly()?; + let relevant: Vec<_> = rows.iter().filter(|r| r.id == "rev_20260903_01_live_class_baseline").map(|r| format!("{} {:?} {}",r.db,r.status,r.baseline)).collect(); + println!("{} baseline history: {:?}",mode.name(),relevant); + let list: Vec<_> = manifest.migrations.iter().filter(|m|index==0 || m.db!="auth").cloned().collect(); + let plan = migrations::status(&db,&list)?; + println!("{} pending: {:?}",mode.name(),plan.iter().filter(|m|m.status!=migrations::Status::Applied).map(|m|(&m.db,&m.id,m.status)).collect::>()); + } + if args.get(3).is_some_and(|s| s == "--apply" || s == "--sql-only") { + assert!(root.file_name().unwrap().to_string_lossy().starts_with("update-database-diagnosis-") && root.join(".coa-update-fixture").is_file(), "Only disposable fixture roots may be changed"); + if args[3] == "--sql-only" { + let db = Db::from_repack(root, Account::Admin)?.for_realm(Mode::Coa); + let identity = || -> coa_core::Result { + let mut rows = String::new(); + for (schema,table,columns) in [("acore_auth","account","id,HEX(username)"),("acore_characters","characters","guid,account,HEX(name)"),("acore_characters_wildcard","characters","guid,account,HEX(name)")] { + rows.push_str(schema); + rows.push_str(&db.query(&format!("SELECT {columns} FROM {schema}.{table} ORDER BY 1;"))?); + } + Ok(coa_core::fsx::sha256_bytes(rows.as_bytes())) + }; + let before = identity()?; + migrations::verify_files(&manifest.migrations,std::path::Path::new(&args[4]))?; + for (index,mode) in [Mode::Coa,Mode::Wildcard].into_iter().enumerate() { + let realm_db = db.clone().for_realm(mode); + let list: Vec<_> = manifest.migrations.iter().filter(|m|index==0 || m.db!="auth").cloned().collect(); + let result = migrations::apply_pending(&realm_db,&list,std::path::Path::new(&args[4]),&||Ok("disposable fixture".into()))?; + println!("{} SQL result: {}",mode.name(),serde_json::to_string(&result)?); + if result.failed.is_some() {return Err("SQL failed; fixture preserved".into());} + } + assert_eq!(before,identity()?,"Account or character identity changed"); + println!("Account and character identities preserved: {}",before); + let capture = root.join("captured-schema"); + coa_core::schema_check::capture(&db,&capture)?; + println!("Captured schema SHA256: {}",coa_core::fsx::sha256_file(&capture.join(coa_core::schema_check::CONTRACT))?); + return Ok(()); + } + if args.len() != 5 { return Err("Migration directory required".into()); } + use coa_core::update::{Env, RepackEnv}; + let meta = coa_core::registry::metadata_dir_for(root)?; + let env = RepackEnv { root, meta_dir: &meta }; + let result = env.migrate(&manifest, std::path::Path::new(&args[4])); + let _ = coa_core::driver::run(root, coa_core::driver::Verb::StopAll); + println!("Application result: {}",serde_json::to_string(&result?)?); + } + Ok(()) +} diff --git a/crates/coa-core/examples/update_recovery_e2e.rs b/crates/coa-core/examples/update_recovery_e2e.rs index 0ae4ed9..6c4f693 100644 --- a/crates/coa-core/examples/update_recovery_e2e.rs +++ b/crates/coa-core/examples/update_recovery_e2e.rs @@ -48,6 +48,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"); @@ -118,7 +119,12 @@ fn exercise(root: &Path) -> Result<()> { ) .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!( @@ -134,6 +140,19 @@ fn exercise(root: &Path) -> Result<()> { 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)); @@ -149,6 +168,6 @@ fn exercise(root: &Path) -> Result<()> { 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/examples/upgrade_fixture.rs b/crates/coa-core/examples/upgrade_fixture.rs new file mode 100644 index 0000000..4b7d617 --- /dev/null +++ b/crates/coa-core/examples/upgrade_fixture.rs @@ -0,0 +1,28 @@ +//! Exercise the production updater only on a disposable copy of an installed server. +use coa_core::{registry::MetaDir, update::{self, Env, RepackEnv}}; +fn main() -> Result<(),Box> { + let args:Vec<_>=std::env::args().collect(); + if args.len()!=3 && args.len()!=4 {return Err("usage: upgrade_fixture ".into());} + let root=std::path::Path::new(&args[1]); + assert!(root.file_name().unwrap().to_string_lossy().starts_with("coa-schema-fixture-real") && root.join(".coa-update-fixture").is_file(),"Disposable fixture required"); + let meta=coa_core::registry::metadata_dir_for(root)?; + let (_,installed)=MetaDir::open(&meta)?; + assert_eq!(coa_core::fsx::canonicalize_lenient(std::path::Path::new(&installed.server_path))?,coa_core::fsx::canonicalize_lenient(root)?,"Fixture metadata must point to the copy"); + assert!(installed.client_path.is_none(),"Detach the real client from fixture metadata"); + let env=RepackEnv{root,meta_dir:&meta}; + if args.get(3).is_some_and(|flag|flag=="--validate-only") { + let result=env.validate(); + let _=coa_core::driver::run(root,coa_core::driver::Verb::StopAll); + result?; + println!("Startup and identity validation passed"); + return Ok(()); + } + let outcome=update::apply(&update::Params{root,meta_dir:&meta,source:coa_core::pkgsource::Source::Dir(args[2].clone().into()),trusted_key:coa_core::signing::EMBEDDED_PUBLIC_KEY,cancel:Default::default(),resolutions:Default::default(),env:&env,fail_after_ops:None},&|phase,percent|println!("{percent}% {phase}")); + let stopped=coa_core::driver::run(root,coa_core::driver::Verb::StopAll); + println!("Shutdown after test: {:?}",stopped.map(|r|r.ok)); + let outcome=outcome?; + println!("Final transaction: {:?}",outcome.txn.state); + env.ensure_stopped()?; + assert_eq!(outcome.txn.state,update::State::Committed); + Ok(()) +} diff --git a/crates/coa-core/src/backup.rs b/crates/coa-core/src/backup.rs index b0c907d..b586264 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"; @@ -259,7 +261,7 @@ pub fn config_files(root: &Path) -> Vec { for e in rd.flatten() { let n = e.file_name().to_string_lossy().to_string(); // database.json holds generated database passwords; it is launcher state, not configuration. - if e.path().is_file() && (n.contains(".template") || n == "repack.json") { + if e.path().is_file() && (n.contains(".template") || n == "repack.json" || n == "docker.json") { out.push(format!("Settings/{n}")); } } @@ -435,6 +437,16 @@ pub fn create( return Err(e); } }; + let mysql_snapshot = if kind == Kind::Full && matches!(trigger, Trigger::BeforeUpdate | Trigger::BeforeRepair) && cfg!(windows) && !crate::docker::is_docker(root) { + progress("Saving a complete stopped MySQL recovery copy"); + match crate::mysql_snapshot::capture(root, &partial) { + Ok(snapshot) => Some(snapshot), + 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, @@ -445,6 +457,7 @@ pub fn create( 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)?; @@ -481,6 +494,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 3b590b1..4f7c5ff 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, @@ -24,7 +24,7 @@ pub struct Params<'a> { } /// Auth tables that hold player data. Everything else (permissions, build info, realmlist) is configuration. -const AUTH_PLAYER_TABLES: [&str; 11] = [ +pub(crate) const AUTH_PLAYER_TABLES: [&str; 11] = [ "account", "account_access", "account_banned", @@ -237,7 +237,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)?); } @@ -418,6 +418,7 @@ mod tests { "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", diff --git a/crates/coa-core/src/config/mod.rs b/crates/coa-core/src/config/mod.rs index d8254dd..8cc45c0 100644 --- a/crates/coa-core/src/config/mod.rs +++ b/crates/coa-core/src/config/mod.rs @@ -248,6 +248,10 @@ pub fn drift(root: &Path) -> Result> { } pub fn load(root: &Path, scope: Scope) -> Result { + // A Docker server that was added but never started has no active world configuration yet; create it from its template. + if scope == Scope::Server && crate::docker::is_docker(root) { + let _ = crate::docker::ensure_main_configs(root); + } let schema = scope.schema(); let t = targets(root, scope)?; let conf = load_conf(&t.read)?; @@ -259,9 +263,12 @@ pub fn load(root: &Path, scope: Scope) -> Result { let known: BTreeSet<&str> = schema.settings.iter().map(|s| s.key.as_str()).collect(); let unknown_keys = conf.entries().filter(|(k, _)| !known.contains(k)).count(); + // On a Docker server some settings are fixed by the backend (environment beats the file): not offered. + let docker = scope == Scope::Server && crate::docker::is_docker(root); let settings = schema .settings .iter() + .filter(|s| !(docker && crate::docker::managed_setting(&s.key))) .map(|s| { let raw = conf.get(&s.key); let (value, problem) = match raw { @@ -357,14 +364,30 @@ pub fn validate( let schema = scope.schema(); let mut errors = Vec::new(); let mut raws = BTreeMap::new(); + let docker = scope == Scope::Server && crate::docker::is_docker(root); for (key, value) in changes { match schema.get(key) { None => errors.push(FieldError { key: key.clone(), message: "is not a setting the Manager can change".into(), }), + Some(_) if docker && crate::docker::managed_setting(key) => errors.push(FieldError { + key: key.clone(), + message: + "is set by the Docker backend; changing it in the file would have no effect" + .into(), + }), Some(s) => match s.to_raw(value) { Ok(raw) => { + if let Some(problem) = crate::docker::setting_problem(key, &raw) + .filter(|_| crate::docker::is_docker(root)) + { + errors.push(FieldError { + key: key.clone(), + message: problem.into(), + }); + continue; + } raws.insert(key.clone(), raw); } Err(m) => errors.push(FieldError { @@ -1077,6 +1100,61 @@ Spellbook.New = 5 ); } + #[test] + fn a_docker_server_is_not_offered_the_settings_its_backend_fixes() { + let (_d, root, meta) = fixture(); + let keys = |root: &Path| { + load(root, Scope::Server) + .unwrap() + .settings + .into_iter() + .map(|s| s.meta.key) + .collect::>() + }; + for k in [ + "Ra.Enable", + "Ra.IP", + "Updates.EnableDatabases", + "PlayerLimit", + ] { + assert!(keys(&root).iter().any(|x| x == k), "a repack offers {k}"); + } + fs::write(root.join("Settings/docker.json"), br#"{"project":"t1"}"#).unwrap(); + let docker = keys(&root); + for k in ["Ra.Enable", "Ra.IP", "Updates.EnableDatabases"] { + assert!( + !docker.iter().any(|x| x == k), + "a Docker server does not offer {k}: the containers' environment beats the file" + ); + } + assert!( + docker.iter().any(|x| x == "PlayerLimit"), + "everything else is still offered" + ); + // Forcing a change through is refused and says why, instead of being saved and ignored. + let err = save( + &root, + &meta, + Scope::Server, + &set(&[("Ra.Enable", json!(0))]), + ) + .unwrap_err() + .to_string(); + assert!(err.contains("Docker backend"), "{err}"); + assert!( + !fs::read_to_string(root.join("Core/configs/worldserver.conf")) + .unwrap() + .contains("Ra.Enable = 0") + ); + save( + &root, + &meta, + Scope::Server, + &set(&[("PlayerLimit", json!(77))]), + ) + .unwrap(); + } + #[test] fn missing_key_is_appended_and_noop_saves_do_nothing() { let (_d, root, meta) = fixture(); diff --git a/crates/coa-core/src/console.rs b/crates/coa-core/src/console.rs index e764a35..91098ad 100644 --- a/crates/coa-core/src/console.rs +++ b/crates/coa-core/src/console.rs @@ -55,6 +55,19 @@ fn classify(l: &str) -> Level { } } +/// The last `max_lines` lines of some text (after filtering), classified and redacted like the lines of a log file. +pub fn lines_from_text(text: &str, filter: Option<&str>, max_lines: usize) -> Vec { + let needle = filter.map(str::to_lowercase).filter(|n| !n.is_empty()); + let out: Vec = text + .lines() + .filter(|l| !l.trim().is_empty()) + .filter(|l| needle.as_ref().map(|n| l.to_lowercase().contains(n)).unwrap_or(true)) + .map(|l| Line { level: classify(l), text: crate::diag::redact(l) }) + .collect(); + let skip = out.len().saturating_sub(max_lines); + out.into_iter().skip(skip).collect() +} + /// The last `max_lines` lines (after filtering) from the last ~512 KB of a log. Works on multi-gigabyte files. pub fn tail(path: &Path, filter: Option<&str>, max_lines: usize) -> Result> { const WINDOW: u64 = 512 * 1024; diff --git a/crates/coa-core/src/custom_races.rs b/crates/coa-core/src/custom_races.rs index 4ddf914..3eba815 100644 --- a/crates/coa-core/src/custom_races.rs +++ b/crates/coa-core/src/custom_races.rs @@ -14,20 +14,24 @@ use crate::download::Cancel; use crate::error::{Error, Result}; use crate::fsx; -pub const CLIENT_VERSION: &str = "1.5"; +pub const CLIENT_VERSION: &str = "1.7"; pub const MANIFEST_FILENAME: &str = "custom_races_patch_manifest.txt"; pub const ARCHIVES: &[(&str, &str)] = &[ ( - "CoA-Custom-1.5-main-20261009-cb2a0ca4.zip", - "https://github.com/ilusixn/azerothcore-wotlk-coa/releases/download/coa-custom-1.5/CoA-Custom-1.5-main-20261009-cb2a0ca4.zip", + "CoA-Custom-1.6-main-20261012-4dd3a4ec.zip", + "https://github.com/ilusixn/azerothcore-wotlk-coa/releases/download/coa-custom-1.6/CoA-Custom-1.6-main-20261012-4dd3a4ec.zip", ), ( - "CoA-Custom-1.5-client-part1.zip", - "https://github.com/ilusixn/azerothcore-wotlk-coa/releases/download/coa-custom-1.5/CoA-Custom-1.5-client-part1.zip", + "CoA-Custom-1.6-client-part1.zip", + "https://github.com/ilusixn/azerothcore-wotlk-coa/releases/download/coa-custom-1.6/CoA-Custom-1.6-client-part1.zip", ), ( - "CoA-Custom-1.5-client-part2.zip", - "https://github.com/ilusixn/azerothcore-wotlk-coa/releases/download/coa-custom-1.5/CoA-Custom-1.5-client-part2.zip", + "CoA-Custom-1.6-client-part2.zip", + "https://github.com/ilusixn/azerothcore-wotlk-coa/releases/download/coa-custom-1.6/CoA-Custom-1.6-client-part2.zip", + ), + ( + "CoA-Custom-1.7-client.zip", + "https://github.com/ilusixn/azerothcore-wotlk-coa/releases/download/coa-custom-1.7/CoA-Custom-1.7-client.zip", ), ]; @@ -52,8 +56,99 @@ pub const RACE_MPQS: &[&str] = &[ "patch-ZJ.MPQ", "patch-ZZE.MPQ", "patch-ZZR.MPQ", + "patch-ZZS.MPQ", + "patch-ZZT.MPQ", + "patch-ZZU.MPQ", + "patch-ZZV.MPQ", + "patch-ZZW.MPQ", ]; +pub const EXCLUDED_BOT_RACES_RECOMMENDED: &str = + "9,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128"; + +pub const EXCLUDED_BOT_RACES_EXPERIMENTAL: &str = "65,72,77,80,81"; + +/// Finds all active playerbots.conf files on this server (root, coa-bots folder, and secondary realm). +pub fn playerbots_conf_paths(root: &Path) -> Vec { + let mut paths = Vec::new(); + let candidates = [ + root.join("Core/configs/modules/playerbots.conf"), + root.join("coa-bots/Core/configs/modules/playerbots.conf"), + root.join("coa-bots/core/configs/modules/playerbots.conf"), + crate::multiworld::root(root).join("Core/configs/modules/playerbots.conf"), + ]; + for path in candidates { + if path.is_file() { + paths.push(path); + } + } + if paths.is_empty() { + for dist in [ + root.join("Core/configs/modules/playerbots.conf.dist"), + root.join("coa-bots/Core/configs/modules/playerbots.conf.dist"), + ] { + if dist.is_file() { + let target = dist.with_extension(""); + if fs::copy(&dist, &target).is_ok() { + paths.push(target); + break; + } + } + } + } + paths +} + +/// Checks whether playerbots are configured to allow custom races. +/// Returns true if AiPlayerbot.ExcludedBotRaces exists and does not exclude general custom races. +pub fn is_bot_custom_races_allowed(root: &Path) -> Option { + for path in playerbots_conf_paths(root) { + if let Ok(bytes) = fs::read(&path) { + if let Ok(conf) = crate::config::parser::ConfFile::parse_bytes(&bytes) { + if let Some(val) = conf.get("AiPlayerbot.ExcludedBotRaces") { + let clean = val.trim().trim_matches(['"', '\'']); + let excluded: std::collections::BTreeSet = clean + .split(',') + .filter_map(|race| race.trim().parse().ok()) + .collect(); + return Some( + (1..=128) + .filter(|race| ![1, 2, 3, 4, 5, 6, 7, 8, 10, 11].contains(race)) + .any(|race| !excluded.contains(&race)), + ); + } + } + } + } + None +} + +/// Sets AiPlayerbot.ExcludedBotRaces in all discovered playerbots.conf files. +pub fn sync_bot_races_config(root: &Path, allow_custom_races: bool) -> Result<()> { + let excluded = if allow_custom_races { + EXCLUDED_BOT_RACES_EXPERIMENTAL + } else { + EXCLUDED_BOT_RACES_RECOMMENDED + }; + for path in playerbots_conf_paths(root) { + if let Ok(bytes) = fs::read(&path) { + if let Ok(mut conf) = crate::config::parser::ConfFile::parse_bytes(&bytes) { + conf.set( + "AiPlayerbot.ExcludedBotRaces", + &format!("\"{excluded}\""), + &[ + "CoA Custom: races new random bots never get", + "Off (recommended): excludes all added races", + "On (experimental): allows stable added races", + ], + ); + fsx::atomic_write(&path, conf.to_text().as_bytes())?; + } + } + } + Ok(()) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum ClientPatchState { @@ -67,152 +162,424 @@ pub enum ClientPatchState { #[serde(rename_all = "camelCase")] pub struct ClientPatchStatus { pub state: ClientPatchState, + pub version: Option, pub installed_mpqs: usize, pub total_mpqs: usize, pub has_root_files: bool, pub client_path: Option, } -/// Inspect the client folder and determine the state of custom races patches. -pub fn client_patch_status(client_path: &Path) -> ClientPatchStatus { - let data_dir = client_path.join("Data"); - let mut enabled_count = 0; - let mut disabled_count = 0; +#[derive(Clone, Serialize, Deserialize)] +struct PatchFile { + path: String, + sha256: String, +} - for mpq in RACE_MPQS { - if data_dir.join(format!("{mpq}.disabled")).is_file() { - disabled_count += 1; - } else if data_dir.join(mpq).is_file() { - enabled_count += 1; +fn patch_files(client: &Path) -> Result> { + let manifest = fs::read_to_string(client.join(MANIFEST_FILENAME)).unwrap_or_default(); + let hashes = if manifest.lines().any(|line| line == "# CoA Custom 1.5") { + include_str!("custom_races_hashes_1_5.json") + } else if manifest.lines().any(|line| line == "# CoA Custom 1.6") { + include_str!("custom_races_hashes_1_6.json") + } else { + include_str!("custom_races_hashes.json") + }; + let bundled: std::collections::BTreeMap = serde_json::from_str(hashes)?; + let mut files = std::collections::BTreeMap::new(); + for line in manifest.lines() { + let path = if let Some(name) = line.strip_prefix("MPQ:") { + if name.contains(['/', '\\']) { + return Err(Error::Invalid( + "Invalid custom races patch manifest.".into(), + )); + } + Some(format!("Data/{name}")) + } else if let Some(name) = line.strip_prefix("ROOT:") { + if name.contains(['/', '\\']) { + return Err(Error::Invalid( + "Invalid custom races patch manifest.".into(), + )); + } + Some(name.to_string()) + } else { + None + }; + if let Some(path) = path { + fsx::safe_join(client, &path)?; + files.insert( + path.clone(), + bundled.get(&path).cloned().unwrap_or_default(), + ); } } + if files.is_empty() { + files = bundled; + } + for line in manifest + .lines() + .filter_map(|line| line.strip_prefix("SHA256:")) + { + let (hash, path) = line + .split_once(':') + .ok_or_else(|| Error::Invalid("Invalid custom races checksum.".into()))?; + if hash.len() != 64 + || !hash.bytes().all(|b| b.is_ascii_hexdigit()) + || !files.contains_key(path) + { + return Err(Error::Invalid("Invalid custom races checksum.".into())); + } + files.insert(path.into(), hash.into()); + } + Ok(files + .into_iter() + .map(|(path, sha256)| PatchFile { path, sha256 }) + .collect()) +} - let has_root = client_path.join("EsteriaAppearance.dll").is_file() +fn suffix(path: &Path, extension: &str) -> PathBuf { + path.with_file_name(format!( + "{}.{extension}", + path.file_name().unwrap_or_default().to_string_lossy() + )) +} + +fn matches_patch(path: &Path, file: &PatchFile) -> bool { + path.is_file() && fsx::sha256_file(path).is_ok_and(|hash| hash == file.sha256) +} + +/// Only complete, verified payloads are reported as enabled or disabled. +pub fn client_patch_status(client_path: &Path) -> ClientPatchStatus { + let data = client_path.join("Data"); + let installed_mpqs = RACE_MPQS + .iter() + .filter(|name| data.join(name).is_file() || suffix(&data.join(name), "disabled").is_file()) + .count(); + let has_root_files = client_path.join("EsteriaAppearance.dll").is_file() || client_path.join("dinput8.dll").is_file() || client_path.join("dinput8.dll.disabled").is_file(); - - let total = RACE_MPQS.len(); - let current_version = fs::read_to_string(client_path.join(MANIFEST_FILENAME)) - .ok() - .is_some_and(|manifest| { - manifest - .lines() - .any(|line| line == format!("# CoA Custom {CLIENT_VERSION}")) - }); - let state = - if enabled_count == total && current_version && client_path.join("dinput8.dll").is_file() { - ClientPatchState::Enabled - } else if disabled_count == total - && current_version - && client_path.join("dinput8.dll.disabled").is_file() - { - ClientPatchState::Disabled - } else if (enabled_count + disabled_count) > 0 { - ClientPatchState::PartiallyInstalled - } else { - ClientPatchState::NotInstalled - }; - + let manifest = fs::read_to_string(client_path.join(MANIFEST_FILENAME)).unwrap_or_default(); + let current = manifest + .lines() + .any(|line| line == format!("# CoA Custom {CLIENT_VERSION}")); + let state = if !manifest.is_empty() + && current + && !client_path.join(".coa-races-transaction").exists() + { + match patch_files(client_path) { + Ok(files) + if files.iter().all(|f| { + matches_patch(&client_path.join(&f.path), f) + && !suffix(&client_path.join(&f.path), "disabled").exists() + }) => + { + ClientPatchState::Enabled + } + Ok(files) + if files + .iter() + .all(|f| matches_patch(&suffix(&client_path.join(&f.path), "disabled"), f)) => + { + ClientPatchState::Disabled + } + _ => ClientPatchState::PartiallyInstalled, + } + } else if !manifest.is_empty() + || patch_files(client_path).is_ok_and(|files| { + files + .iter() + .filter(|file| file.path.starts_with("Data/") && file.path != "Data/patch-T.MPQ") + .any(|file| { + matches_patch(&client_path.join(&file.path), file) + || matches_patch(&suffix(&client_path.join(&file.path), "disabled"), file) + }) + }) + { + ClientPatchState::PartiallyInstalled + } else { + ClientPatchState::NotInstalled + }; + let version = manifest + .lines() + .find_map(|line| line.strip_prefix("# CoA Custom ").map(str::to_string)); ClientPatchStatus { state, - installed_mpqs: enabled_count + disabled_count, - total_mpqs: total, - has_root_files: has_root, + version, + installed_mpqs, + total_mpqs: RACE_MPQS.len(), + has_root_files, client_path: Some(client_path.to_string_lossy().into_owned()), } } -/// Enable or disable client patches by renaming MPQs and `dinput8.dll` with `.disabled`. -pub fn set_client_patch_enabled(client_path: &Path, enabled: bool) -> Result<()> { - if !client_path.is_dir() { +#[derive(Serialize, Deserialize)] +struct SavedFile { + path: String, + existed: bool, +} + +struct PatchTransaction<'a> { + root: &'a Path, + entries: Vec, + finished: bool, +} +impl<'a> PatchTransaction<'a> { + fn new(root: &'a Path) -> Result { + recover_patch_transaction(root)?; + fs::create_dir(root.join(".coa-races-transaction"))?; + Ok(Self { + root, + entries: Vec::new(), + finished: false, + }) + } + fn save(&mut self, path: &Path) -> Result<()> { + let relative = path + .strip_prefix(self.root) + .map_err(|_| Error::Invalid("Patch path escaped the client directory.".into()))? + .to_string_lossy() + .replace('\\', "/"); + fsx::safe_join(self.root, &relative)?; + if self.entries.iter().any(|entry| entry.path == relative) { + return Ok(()); + } + let index = self.entries.len(); + self.entries.push(SavedFile { + path: relative, + existed: path.is_file(), + }); + fsx::atomic_write_json( + &self.root.join(".coa-races-transaction/journal.json"), + &self.entries, + )?; + if path.is_file() { + fs::rename( + path, + self.root + .join(".coa-races-transaction") + .join(index.to_string()), + )?; + } + Ok(()) + } + fn copy(&mut self, source: &Path, target: &Path) -> Result<()> { + self.save(target)?; + fs::create_dir_all( + target + .parent() + .ok_or_else(|| Error::Invalid("Patch file has no parent directory.".into()))?, + )?; + fs::copy(source, target)?; + Ok(()) + } + fn move_file(&mut self, source: &Path, target: &Path) -> Result<()> { + self.save(source)?; + let index = self + .entries + .iter() + .position(|entry| self.root.join(&entry.path) == source) + .unwrap(); + // Copy from the journal so rollback retains the previous bytes. + let saved = self + .root + .join(".coa-races-transaction") + .join(index.to_string()); + self.copy(&saved, target) + } + fn finish(mut self) -> Result<()> { + fsx::atomic_write( + &self.root.join(".coa-races-transaction/committed"), + b"committed", + )?; + self.finished = true; + fs::remove_dir_all(self.root.join(".coa-races-transaction"))?; + Ok(()) + } +} +impl Drop for PatchTransaction<'_> { + fn drop(&mut self) { + if !self.finished { + let _ = recover_patch_transaction(self.root); + } + } +} + +fn recover_patch_transaction(root: &Path) -> Result<()> { + let directory = root.join(".coa-races-transaction"); + if !directory.is_dir() { + return Ok(()); + } + if !directory.join("committed").exists() && directory.join("journal.json").exists() { + let entries: Vec = fsx::read_json(&directory.join("journal.json"))?; + for (index, entry) in entries.iter().enumerate().rev() { + let path = fsx::safe_join(root, &entry.path)?; + let saved = directory.join(index.to_string()); + if saved.is_file() { + if path.is_file() { + fs::remove_file(&path)?; + } + fs::rename(saved, path)?; + } else if !entry.existed && path.is_file() { + fs::remove_file(path)?; + } + } + } + fs::remove_dir_all(directory)?; + Ok(()) +} + +fn patch_lock(client: &Path) -> Result { + use fs4::fs_std::FileExt; + let lock = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(client.join(".coa-races.lock"))?; + if !lock.try_lock_exclusive()? { + return Err(Error::Invalid( + "Another custom races operation is using this client.".into(), + )); + } + Ok(lock) +} + +fn preserve_original(tx: &mut PatchTransaction<'_>, active: &Path) -> Result<()> { + let original = suffix(active, "original"); + if original.is_file() && fsx::sha256_file(active)? == fsx::sha256_file(&original)? { + return Ok(()); + } + if original.is_file() { + let previous = suffix( + active, + &format!("original.previous-{}", uuid::Uuid::new_v4().simple()), + ); + tx.copy(&original, &previous)?; + } + tx.copy(active, &original) +} + +/// Preserve launcher replacements, and switch every file owned by the package. +pub fn set_client_patch_enabled(client: &Path, enabled: bool) -> Result<()> { + if !client.is_dir() { return Err(Error::Invalid("Game client directory not found.".into())); } - - if crate::client::is_running(client_path) { + if crate::client::is_running(client) { return Err(Error::Invalid( "Close the game before changing custom races patches.".into(), )); } - if !client_path.join(MANIFEST_FILENAME).is_file() - && !RACE_MPQS.iter().skip(1).any(|name| { - client_path.join("Data").join(name).is_file() - || client_path - .join("Data") - .join(format!("{name}.disabled")) - .is_file() - }) - { + let _lock = patch_lock(client)?; + recover_patch_transaction(client)?; + if !client.join(MANIFEST_FILENAME).is_file() { return Ok(()); } - let mut paths: Vec = RACE_MPQS - .iter() - .map(|name| client_path.join("Data").join(name)) - .collect(); - if let Ok(manifest) = fs::read_to_string(client_path.join(MANIFEST_FILENAME)) { - for line in manifest.lines() { - if let Some(name) = line.strip_prefix("MPQ:") { - if name.contains(['/', '\\']) || !name.to_ascii_lowercase().ends_with(".mpq") { - return Err(Error::Invalid( - "Invalid custom races patch manifest.".into(), - )); - } - paths.push(fsx::safe_join(&client_path.join("Data"), name)?); + let files = patch_files(client)?; + if enabled { + for file in &files { + let active = client.join(&file.path); + if !matches_patch(&active, file) && !matches_patch(&suffix(&active, "disabled"), file) { + return Err(Error::Invalid(format!("Custom races file {} was replaced or is missing. Repair the client patch before enabling it.", file.path))); } } } - paths.sort(); - paths.dedup(); - paths.push(client_path.join("dinput8.dll")); - paths.push(client_path.join("Ascension.exe")); - for active in paths { - let disabled = active.with_extension(format!( - "{}.disabled", - active.extension().unwrap_or_default().to_string_lossy() - )); - let original = active.with_extension(format!( - "{}.original", - active.extension().unwrap_or_default().to_string_lossy() - )); - if enabled && disabled.is_file() { - if active.is_file() { - if !original.is_file() { - return Err(Error::Invalid(format!( - "Cannot replace existing {} without an original backup.", - active.display() - ))); - } - fs::remove_file(&active)?; + let mut tx = PatchTransaction::new(client)?; + for file in files { + let active = client.join(&file.path); + let disabled = suffix(&active, "disabled"); + let original = suffix(&active, "original"); + if enabled { + if active.is_file() && !matches_patch(&active, &file) { + preserve_original(&mut tx, &active)?; } - fs::rename(&disabled, &active)?; - } else if !enabled && active.is_file() && !disabled.is_file() { - if active - .file_name() - .is_some_and(|name| name == "Ascension.exe") - && !original.is_file() - { - continue; + if matches_patch(&disabled, &file) { + tx.move_file(&disabled, &active)?; } - fs::rename(&active, &disabled)?; + } else if matches_patch(&active, &file) { + if disabled.is_file() && !matches_patch(&disabled, &file) { + tx.move_file( + &disabled, + &suffix( + &disabled, + &format!("replaced-{}", uuid::Uuid::new_v4().simple()), + ), + )?; + } + tx.move_file(&active, &disabled)?; if original.is_file() { - fs::copy(&original, &active)?; + tx.copy(&original, &active)?; + } + } else if active.is_file() { + preserve_original(&mut tx, &active)?; + } + } + tx.finish() +} + +pub fn uninstall_client_patches(client: &Path) -> Result<()> { + if crate::client::is_running(client) { + return Err(Error::Invalid( + "Close the game before removing custom races patches.".into(), + )); + } + let _lock = patch_lock(client)?; + recover_patch_transaction(client)?; + if !client.join(MANIFEST_FILENAME).is_file() { + return Ok(()); + } + let mut tx = PatchTransaction::new(client)?; + for file in patch_files(client)? { + let active = client.join(&file.path); + if matches_patch(&active, &file) { + tx.save(&active)?; + let original = suffix(&active, "original"); + if original.is_file() { + tx.copy(&original, &active)?; + } + } + let disabled = suffix(&active, "disabled"); + if active.is_file() + && !matches_patch(&active, &file) + && !suffix(&active, "original").is_file() + { + tx.move_file( + &active, + &suffix( + &active, + &format!("replaced-{}", uuid::Uuid::new_v4().simple()), + ), + )?; + } + if disabled.is_file() { + if !matches_patch(&disabled, &file) { + tx.move_file( + &disabled, + &suffix( + &disabled, + &format!("replaced-{}", uuid::Uuid::new_v4().simple()), + ), + )?; + } else { + tx.save(&disabled)?; } } } - - tracing::info!(enabled, "custom races client patches toggled"); - Ok(()) + tx.save(&client.join(MANIFEST_FILENAME))?; + tx.finish() } fn verify_archive(path: &Path, filename: &str) -> Result<()> { let expected = match filename { - "CoA-Custom-1.5-main-20261009-cb2a0ca4.zip" => { - "117f5d99add18191676be6648fb0f2e99019a06c18705c71454e371e7df2afd2" + "CoA-Custom-1.6-main-20261012-4dd3a4ec.zip" => { + "dfe802ea70047c129ac2e0aaa557bab3110e3cc6926d36403b8c880a0e310ee4" } - "CoA-Custom-1.5-client-part1.zip" => { - "19eb372805b3268653914da03377d67f056e5b50d818a8a06a5f260aad03211e" + "CoA-Custom-1.6-client-part1.zip" => { + "1dbf1901ad5be61eba66973609df90712ffc78ef9e9b2c8549a5f3e36332bf12" } - "CoA-Custom-1.5-client-part2.zip" => { - "122f2882da255913e01610e33c6446ded897fecbe60b6d5ee026149becf8f278" + "CoA-Custom-1.6-client-part2.zip" => { + "689f7f946b4180fe977dabedc7cfe642e3e7f27431600c64c4dd5b7960b1bc66" + } + "CoA-Custom-1.7-client.zip" => { + "ad389ef486f44f269445f80807b25b214c39fc5d21272b5708bfeac8b1efefa0" } _ => return Err(Error::Invalid("Unknown custom races archive.".into())), }; @@ -344,30 +711,14 @@ pub fn install_client_patches( "Close the game before installing custom races patches.".into(), )); } - let data_dir = client_path.join("Data"); + let _lock = patch_lock(client_path)?; + recover_patch_transaction(client_path)?; + let staging = tempfile::Builder::new() + .prefix(".coa-races-stage-") + .tempdir_in(client_path)?; + let data_dir = staging.path().join("Data"); fs::create_dir_all(&data_dir)?; - // 1. Back up original Ascension.exe and patch-T.MPQ if they exist and haven't been backed up yet - let exe = client_path.join("Ascension.exe"); - let exe_bak = client_path.join("Ascension.exe.original"); - if exe.is_file() && !exe_bak.is_file() { - report("Backing up original Ascension.exe...", 0.02); - fs::copy(&exe, &exe_bak)?; - } - - let t_mpq = data_dir.join("patch-T.MPQ"); - let t_mpq_bak = data_dir.join("patch-T.MPQ.original"); - if t_mpq.is_file() && !t_mpq_bak.is_file() { - report("Backing up original patch-T.MPQ...", 0.04); - fs::copy(&t_mpq, &t_mpq_bak)?; - } - - let dll = client_path.join("dinput8.dll"); - let dll_backup = client_path.join("dinput8.dll.original"); - if dll.is_file() && !dll_backup.is_file() { - fs::copy(&dll, &dll_backup)?; - } - // 2. Resolve/download archives (0.05 .. 0.50) let http = reqwest::blocking::Client::builder() .user_agent(concat!("CoA-Server-Manager/", env!("CARGO_PKG_VERSION"))) @@ -440,10 +791,10 @@ pub fn install_client_patches( } installed_mpqs.push(base); } else if base.eq_ignore_ascii_case("dinput8.dll") { - let dest = client_path.join(&base); + let dest = staging.path().join(&base); let mut out = File::create(&dest)?; io::copy(&mut zfile, &mut out)?; - let disabled = client_path.join(format!("{base}.disabled")); + let disabled = staging.path().join(format!("{base}.disabled")); if disabled.is_file() { fs::remove_file(disabled)?; } @@ -456,10 +807,10 @@ pub fn install_client_patches( .unwrap_or_default(); if !base.is_empty() { - let dest = client_path.join(&base); + let dest = staging.path().join(&base); let mut out = File::create(&dest)?; io::copy(&mut zfile, &mut out)?; - let disabled = client_path.join(format!("{base}.disabled")); + let disabled = staging.path().join(format!("{base}.disabled")); if disabled.is_file() { fs::remove_file(disabled)?; } @@ -495,7 +846,67 @@ pub fn install_client_patches( manifest_body.push_str(&format!("ROOT:{r}\n")); } + let bundled: std::collections::BTreeMap = + serde_json::from_str(include_str!("custom_races_hashes.json"))?; + let mut payload = Vec::new(); + for path in installed_mpqs + .iter() + .map(|name| format!("Data/{name}")) + .chain(installed_roots.iter().cloned()) + { + let staged = fsx::safe_join(staging.path(), &path)?; + let hash = fsx::sha256_file(&staged)?; + if bundled.get(&path) != Some(&hash) { + return Err(Error::Invalid(format!( + "Custom races file {path} failed verification." + ))); + } + manifest_body.push_str(&format!("SHA256:{hash}:{path}\n")); + payload.push(PatchFile { path, sha256: hash }); + } + if cancel.is_set() { + return Err(Error::Invalid("Operation cancelled.".into())); + } + if payload.len() != bundled.len() { + return Err(Error::Invalid( + "Custom races archives are missing required runtime files.".into(), + )); + } + let previous = patch_files(client_path)?; + let mut tx = PatchTransaction::new(client_path)?; + for file in payload { + let dest = fsx::safe_join(client_path, &file.path)?; + if dest.is_file() + && !previous + .iter() + .any(|old| old.path == file.path && matches_patch(&dest, old)) + && !matches_patch(&dest, &file) + { + preserve_original(&mut tx, &dest)?; + } + tx.copy(&staging.path().join(&file.path), &dest)?; + let disabled = suffix(&dest, "disabled"); + if disabled.is_file() { + if !previous + .iter() + .any(|old| old.path == file.path && matches_patch(&disabled, old)) + && !matches_patch(&disabled, &file) + { + tx.move_file( + &disabled, + &suffix( + &disabled, + &format!("replaced-{}", uuid::Uuid::new_v4().simple()), + ), + )?; + } else { + tx.save(&disabled)?; + } + } + } + tx.save(&manifest_path)?; fsx::atomic_write(&manifest_path, manifest_body.as_bytes())?; + tx.finish()?; report("Custom races client patches installed successfully!", 1.0); tracing::info!( mpqs = installed_mpqs.len(), @@ -510,35 +921,262 @@ pub fn install_client_patches( mod tests { use super::*; - #[test] - fn disabling_restores_original_client_files_and_enabling_restores_custom_files() { - let temp = tempfile::tempdir().unwrap(); - let data = temp.path().join("Data"); - fs::create_dir_all(&data).unwrap(); - for path in [ - data.join("patch-T.MPQ"), - temp.path().join("Ascension.exe"), - temp.path().join("dinput8.dll"), - ] { - fs::write(&path, b"custom").unwrap(); - let backup = path.with_extension(format!( - "{}.original", - path.extension().unwrap().to_string_lossy() + fn record_files(root: &Path, names: &[&str]) { + let mut manifest = format!("# CoA Custom {CLIENT_VERSION}\n"); + for name in names { + let (kind, base) = name + .strip_prefix("Data/") + .map_or(("ROOT", *name), |base| ("MPQ", base)); + manifest.push_str(&format!( + "{kind}:{base}\nSHA256:{}:{name}\n", + fsx::sha256_file(&root.join(name)).unwrap() )); - fs::write(&backup, b"original").unwrap(); } - fs::write(temp.path().join(MANIFEST_FILENAME), "MPQ:patch-T.MPQ\n").unwrap(); - set_client_patch_enabled(temp.path(), false).unwrap(); - assert_eq!(fs::read(data.join("patch-T.MPQ")).unwrap(), b"original"); - assert_eq!( - fs::read(temp.path().join("Ascension.exe")).unwrap(), - b"original" + fs::write(root.join(MANIFEST_FILENAME), manifest).unwrap(); + } + + #[test] + #[ignore = "Requires the downloaded release archives"] + fn bundled_hashes_match_verified_release_archives() { + use sha2::{Digest, Sha256}; + let folder = PathBuf::from( + std::env::var_os("COA_RACES_ARCHIVE_DIR").expect("COA_RACES_ARCHIVE_DIR"), ); - set_client_patch_enabled(temp.path(), true).unwrap(); - assert_eq!(fs::read(data.join("patch-T.MPQ")).unwrap(), b"custom"); + let expected: std::collections::BTreeMap = + serde_json::from_str(include_str!("custom_races_hashes.json")).unwrap(); + let mut actual = std::collections::BTreeMap::new(); + for (name, _) in ARCHIVES { + let path = folder.join(name); + verify_archive(&path, name).unwrap(); + let mut archive = zip::ZipArchive::new(File::open(path).unwrap()).unwrap(); + for index in 0..archive.len() { + let mut entry = archive.by_index(index).unwrap(); + let name = entry.name().replace('\\', "/"); + if entry.is_dir() { + continue; + } + let relative = if let Some(name) = name.split("files/client_root/").nth(1) { + name.to_string() + } else if let Some(name) = name.split("files/client/").nth(1) { + if name == "dinput8.dll" { + name.to_string() + } else { + format!("Data/{name}") + } + } else { + continue; + }; + let mut hash = Sha256::new(); + let mut buffer = vec![0; 1024 * 1024]; + loop { + let read = entry.read(&mut buffer).unwrap(); + if read == 0 { + break; + } + hash.update(&buffer[..read]); + } + actual.insert(relative, hex::encode(hash.finalize())); + } + } + assert_eq!(actual, expected); + } + + #[test] + fn legacy_installation_uses_its_own_hashes_when_preserving_originals() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join(MANIFEST_FILENAME), "# CoA Custom 1.5\n").unwrap(); + let legacy: std::collections::BTreeMap = + serde_json::from_str(include_str!("custom_races_hashes_1_5.json")).unwrap(); + let current: std::collections::BTreeMap = + serde_json::from_str(include_str!("custom_races_hashes.json")).unwrap(); + let files = patch_files(temp.path()).unwrap(); assert_eq!( - fs::read(temp.path().join("Ascension.exe")).unwrap(), - b"custom" + files + .iter() + .find(|file| file.path == "Ascension.exe") + .unwrap() + .sha256, + legacy["Ascension.exe"] + ); + assert_eq!(files.len(), legacy.len()); + assert!(current.len() > legacy.len()); + assert!(!files.iter().any(|file| file.path == "Data/patch-ZZS.MPQ")); + } + + #[test] + fn previous_release_uses_its_own_hashes_for_upgrade_and_removal() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join(MANIFEST_FILENAME), "# CoA Custom 1.6\n").unwrap(); + let legacy: std::collections::BTreeMap = + serde_json::from_str(include_str!("custom_races_hashes_1_6.json")).unwrap(); + let current: std::collections::BTreeMap = + serde_json::from_str(include_str!("custom_races_hashes.json")).unwrap(); + let files = patch_files(temp.path()).unwrap(); + assert_eq!(files.len(), legacy.len()); + assert_eq!( + files + .iter() + .find(|file| file.path == "Data/patch-T.MPQ") + .unwrap() + .sha256, + legacy["Data/patch-T.MPQ"] + ); + assert_ne!(legacy["Data/patch-T.MPQ"], current["Data/patch-T.MPQ"]); + assert!(!files.iter().any(|file| file.path == "Data/patch-ZZW.MPQ")); + assert!(legacy.keys().all(|key| current.contains_key(key))); + } + + #[test] + fn another_patch_operation_cannot_enter_the_same_client() { + let temp = tempfile::tempdir().unwrap(); + let first = patch_lock(temp.path()).unwrap(); + assert!(patch_lock(temp.path()).is_err()); + drop(first); + patch_lock(temp.path()).unwrap(); + } + + #[test] + fn switching_preserves_launcher_updates_for_every_owned_file() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + fs::create_dir_all(root.join("Data")).unwrap(); + let names = [ + "Data/patch-T.MPQ", + "Data/patch-ZE1.MPQ", + "Ascension.exe", + "dinput8.dll", + "EsteriaAppearance.dll", + ]; + for name in names { + fs::write(root.join(name), b"custom").unwrap(); + fs::write(suffix(&root.join(name), "original"), b"old official").unwrap(); + } + record_files(root, &names); + set_client_patch_enabled(root, false).unwrap(); + for name in names { + assert_eq!(fs::read(root.join(name)).unwrap(), b"old official"); + fs::write(root.join(name), b"new official").unwrap(); + } + set_client_patch_enabled(root, true).unwrap(); + for name in names { + assert_eq!(fs::read(root.join(name)).unwrap(), b"custom"); + assert_eq!( + fs::read(suffix(&root.join(name), "original")).unwrap(), + b"new official" + ); + } + // An official launcher can also replace a currently enabled file. + fs::write(root.join("Ascension.exe"), b"newest official").unwrap(); + assert_eq!( + client_patch_status(root).state, + ClientPatchState::PartiallyInstalled + ); + set_client_patch_enabled(root, false).unwrap(); + assert_eq!( + fs::read(root.join("Ascension.exe")).unwrap(), + b"newest official" + ); + assert!(set_client_patch_enabled(root, true) + .unwrap_err() + .to_string() + .contains("Repair")); + uninstall_client_patches(root).unwrap(); + assert_eq!( + fs::read(root.join("Ascension.exe")).unwrap(), + b"newest official" + ); + assert_eq!( + client_patch_status(root).state, + ClientPatchState::NotInstalled + ); + } + + #[test] + fn failed_and_interrupted_transactions_restore_previous_bytes() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let file = root.join("Ascension.exe"); + fs::write(&file, b"previous").unwrap(); + { + let mut tx = PatchTransaction::new(root).unwrap(); + tx.save(&file).unwrap(); + fs::write(&file, b"replacement").unwrap(); + let staged = root.join("staged.dll"); + fs::write(&staged, b"new").unwrap(); + tx.copy(&staged, &root.join("Data/new.dll")).unwrap(); + assert_eq!(fs::read(root.join("Data/new.dll")).unwrap(), b"new"); + } + assert_eq!(fs::read(&file).unwrap(), b"previous"); + assert!(!root.join("Data/new.dll").exists()); + let mut tx = PatchTransaction::new(root).unwrap(); + tx.save(&file).unwrap(); + fs::write(&file, b"interrupted").unwrap(); + std::mem::forget(tx); + recover_patch_transaction(root).unwrap(); + assert_eq!(fs::read(&file).unwrap(), b"previous"); + } + + #[test] + fn existing_backups_and_foreign_disabled_files_are_retained() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let file = root.join("dinput8.dll"); + fs::write(&file, b"custom").unwrap(); + record_files(root, &["dinput8.dll"]); + fs::write(suffix(&file, "disabled"), b"third party").unwrap(); + fs::write(suffix(&file, "original"), b"original").unwrap(); + set_client_patch_enabled(root, false).unwrap(); + assert_eq!(fs::read(&file).unwrap(), b"original"); + assert!(fs::read_dir(root) + .unwrap() + .flatten() + .any(|entry| entry.file_name().to_string_lossy().contains("replaced-"))); + fs::write(&file, b"updated original").unwrap(); + set_client_patch_enabled(root, true).unwrap(); + assert!(fs::read_dir(root).unwrap().flatten().any(|entry| entry + .file_name() + .to_string_lossy() + .contains("original.previous-"))); + } + + #[test] + fn uninstall_moves_corrupted_custom_only_assets_out_of_the_active_client() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + fs::create_dir_all(root.join("Data")).unwrap(); + let file = root.join("Data/patch-ZE1.MPQ"); + fs::write(&file, b"custom").unwrap(); + record_files(root, &["Data/patch-ZE1.MPQ"]); + fs::write(&file, b"corrupted custom").unwrap(); + uninstall_client_patches(root).unwrap(); + assert!(!file.exists()); + assert!(fs::read_dir(root.join("Data")) + .unwrap() + .flatten() + .any(|entry| entry.file_name().to_string_lossy().contains("replaced-"))); + assert_eq!( + client_patch_status(root).state, + ClientPatchState::NotInstalled + ); + } + + #[test] + fn unknown_or_corrupt_bytes_are_not_reported_as_enabled() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + fs::create_dir_all(root.join("Data")).unwrap(); + for name in RACE_MPQS { + fs::write(root.join("Data").join(name), b"corrupt").unwrap(); + } + fs::write(root.join("dinput8.dll"), b"corrupt").unwrap(); + fs::write( + root.join(MANIFEST_FILENAME), + format!("# CoA Custom {CLIENT_VERSION}\n"), + ) + .unwrap(); + assert_eq!( + client_patch_status(root).state, + ClientPatchState::PartiallyInstalled ); } @@ -599,6 +1237,15 @@ mod tests { fs::write(client_path.join("dinput8.dll"), b"test dll").unwrap(); fs::write(client_path.join("EsteriaAppearance.dll"), b"test dll").unwrap(); + let names: Vec = RACE_MPQS + .iter() + .map(|name| format!("Data/{name}")) + .chain(["dinput8.dll".into(), "EsteriaAppearance.dll".into()]) + .collect(); + record_files( + client_path, + &names.iter().map(String::as_str).collect::>(), + ); let st = client_patch_status(client_path); assert_eq!(st.state, ClientPatchState::Enabled); assert_eq!(st.installed_mpqs, RACE_MPQS.len()); @@ -621,4 +1268,27 @@ mod tests { assert!(client_path.join("dinput8.dll").is_file()); assert!(!client_path.join("dinput8.dll.disabled").is_file()); } + #[test] + fn disabling_bot_custom_races_excludes_every_nonstandard_race() { + let temp = tempfile::tempdir().unwrap(); + let conf = temp.path().join("Core/configs/modules/playerbots.conf"); + fs::create_dir_all(conf.parent().unwrap()).unwrap(); + fs::write(&conf, "AiPlayerbot.ExcludedBotRaces = \"\"\n").unwrap(); + sync_bot_races_config(temp.path(), false).unwrap(); + assert_eq!(is_bot_custom_races_allowed(temp.path()), Some(false)); + let parsed = + crate::config::parser::ConfFile::parse_bytes(&fs::read(conf).unwrap()).unwrap(); + let value = parsed + .get("AiPlayerbot.ExcludedBotRaces") + .unwrap() + .trim_matches('"'); + let excluded: Vec = value.split(',').map(|race| race.parse().unwrap()).collect(); + for race in 1..=128 { + assert_eq!( + excluded.contains(&race), + ![1, 2, 3, 4, 5, 6, 7, 8, 10, 11].contains(&race), + "race {race}" + ); + } + } } diff --git a/crates/coa-core/src/custom_races_hashes.json b/crates/coa-core/src/custom_races_hashes.json new file mode 100644 index 0000000..dbd0dbf --- /dev/null +++ b/crates/coa-core/src/custom_races_hashes.json @@ -0,0 +1,55 @@ +{ + "Ascension.exe": "e2c347019d554a59639e5978fd68e50e906973ab313c6573cc56ca82e8e3c547", + "EsteriaAppearance.bin": "b3fb7c69b7600c39f888ea1925701be5667097dcb02bb6ddd830e5489306bb50", + "EsteriaAppearance.dll": "392c5a2a25f9a69d705bdf841bb899aab768d4a3ee62734d576286c7198710d8", + "EsteriaAppearanceGeometry.bin": "c95c53ca19c53c3434e12b99c1966f9c015ec3944c46dff7784d5e4ffc472d5d", + "EsteriaAppearanceMaterials.bin": "f72f027f54a55c05aa0f76b457b97b484fe0e1aedc0e068cc0e0ca53e1878cba", + "EsteriaCosmeticWings.bin": "57352c28459d073a29b3014d33b70f3b3756afa37fa9583ef6b30d503422ac3f", + "EsteriaEarthen.bin": "8a0db93ba2d5c756b44ed84f2f7bc7c15eef13be6a7d87baf1383c6c0c624e02", + "EsteriaGnoll.bin": "cfacc8e681bb8e54a4f01ee9c12a91bf0bd23a23527bb548d4dc21ec1b33eea2", + "EsteriaGnollTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605", + "EsteriaHaranir.bin": "59f31d00e372fd2646e1cf0d9d3d5d671b31edd60ff7b862f8d1ce7e53ba1f2f", + "EsteriaHaranirTextures.bin": "99948ca570be2d376f0c67e3b8088739b6fa9f9d2c73985e19f68df77689a229", + "EsteriaHighmountain.bin": "e83349252ad2687ac23fcfc5623db7401cf32e5faf47a5a7be20c2f94b014938", + "EsteriaNaga.bin": "e87547cff5d09500c294f8b06935d134cdd0bbb962e294a72de09404505bd8b3", + "EsteriaNagaTextures.bin": "57ec560f99c1d48ac1e8f6a797c156513b99e7d349e3071afe405f812d159ae3", + "EsteriaSaberon.bin": "7735c9325c807c5125760d87f323d6b7d4162fb542c1d8b5f435ea2d6b9a4daa", + "EsteriaSaberonCoverage.bin": "9cdc227f08329e1245d2b864a996e7a772c96ecdbe5d81925f4d60cdbe4fa11e", + "EsteriaSaberonTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605", + "EsteriaSethrak.bin": "bd14857a8c11fdf8027128763293701586b2102f9a6539d33a727014789fd606", + "EsteriaSethrakTextures.bin": "d2b2cf58087a391f00198008fe871edd400ddf28d54a3a05674df74c5198528a", + "EsteriaThinHuman.bin": "75794d192ea0c228b3da7bf7d0bdff1119f79d3f9bfef6650cd5e1a2b5d106eb", + "EsteriaThinHumanTextures.bin": "02aeb4c05893d06125b8b58fdb7c2a56fe3d66a7d094706a44b43cb503d1731e", + "EsteriaTuskarr.bin": "ecbe7ef490ba67eca029a57639192db61238522618491549766fe38529914387", + "EsteriaTuskarrTextures.bin": "e2116c2c83adea079126fd5da765d20da24f044d4f161c4124e4368cb3d6bc71", + "EsteriaVrykul.bin": "39e2b7b4c67997dbbd34bae840bd606a59290f7f2fc82565a0ba00090335415c", + "EsteriaVrykulTextures.bin": "46098750a0e235724c6e9cc8d9d4e26550aa47bef95fd5f0f82efe5e945bf296", + "EsteriaVulpera.bin": "c5434993b9af4a17d881965e4735cd2ac379e39c7142aac9844adff9528562d4", + "EsteriaVulperaTextures.bin": "1ab844e74684aced2b069f1b91cd563aade45e05bd5e23f866a6c0c301cb098e", + "dinput8.dll": "f8de78606c241fb3911dce22faf68f5b9f9885b142b183c1e2c09ddc8f7e380c", + "Data/patch-T.MPQ": "fe597138ab7f56881b5f4f27d6dc9d81bb278b483ca0d91b7207ffd4a5ae7bfd", + "Data/patch-ZE5.MPQ": "476b5a1fcf69f64fdb281e3b445362b936d016066ccd12f897acfcf200d6c0cf", + "Data/patch-ZE6.MPQ": "762592c9ed950b4a7b6c7d85d45050b30fa3f35d53e348ee1e1516fdbb7e5484", + "Data/patch-ZE7.MPQ": "de54b0189d242763d2872223419a259b6b8a4b380c24b50a2ef9c6fa44466248", + "Data/patch-ZE8.MPQ": "1bc5691fb84a84f3e7a0a70baaa9192a97b4ec5906ee37c85838ffe08adef934", + "Data/patch-ZE9.MPQ": "d49c94463e9548e76378ce2c0454d8f204f0745c8c0de32b6dce7f0b0fd67c4a", + "Data/patch-ZES.MPQ": "a2d9348f917f745c7aa25b2eed96efa312f7b2d2f62cb86a4f2eaabc2dfa895f", + "Data/patch-ZG.MPQ": "48979e1fda775f8deb47bffa48e0a468595d4c71286fee518f51dc5801c5ca39", + "Data/patch-ZH.MPQ": "5e1d2fe287053abba703d7af381e10096c6525ba477ffed3971c2fc4f93e919c", + "Data/patch-ZHM.MPQ": "317d3ae00f0bd674b358af07544976acb7804aa6d8b19d1788ad96030e8c9bf0", + "Data/patch-ZJ.MPQ": "f821e99add86ef68d108c5ec466328c97677d3ed52f7944e200f3fe38c13b4de", + "Data/patch-ZM.MPQ": "424ab1978986e854551633983a94bd15beba6328aabea8c02f98eaa32f6f0b61", + "Data/patch-ZVU.MPQ": "c679b85a2e97344395756b54d2cf6abefe4e80e74944cb55b6e573ad17b7d020", + "Data/patch-ZE1.MPQ": "69d79dbbea54ec0312c37770debc79c7bc9ce2e21eedef99c03cd8d911955283", + "Data/patch-ZE2.MPQ": "1a41cae77ad730e9cf69e8d63fd12ae3a579418bb550d351fff469573a97ac3c", + "Data/patch-ZE3.MPQ": "b587202c21b6f580682b601b7c2e905fbfa766276e74733db55c1bddbffb7f87", + "Data/patch-ZEA.MPQ": "78fd10ec5262ae2417eba9700d2172553bbc1e5cfb0b597aada8f1753ca46d9d", + "Data/patch-ZHA.MPQ": "0fc6ebeafe1d14192dc64bafc90bde3c49230df1297f98bf65900a3bcf953617", + "Data/patch-ZZE.MPQ": "4b1e289e12c82644cb78f50170e6b5fd53918eb2de834f9e0cebddd81873a8bb", + "Data/patch-ZZR.MPQ": "5a7575e592dc0c8a6913e0d946bbe06fa0ca2e4d817ef1f8548d3d13142bcbd3", + "Data/patch-ZZS.MPQ": "533fd69d5784d83e872a5a28d27301dc9ab5d0cb925f8b2116926854e4a0a5b8", + "Data/patch-ZZT.MPQ": "aef3bbe4951333d62fb55224484724fbaa9c35773a19944ffb9935e250cfb59c", + "Data/patch-ZZU.MPQ": "8ee1839415b56d46c6eff7eb4dfbaf6fbafc4c6b29b39380d69f4d827d02a99e", + "Data/patch-ZZV.MPQ": "8d5e61d37008abf70478e5d5f46ec7afb0490b9a643323bd75a1c70cadd63290", + "Data/patch-ZZW.MPQ": "ed452a0a0118c9f4deaf02db753064ac95b6e4a84022bdabd39a7e70d775223f" +} diff --git a/crates/coa-core/src/custom_races_hashes_1_5.json b/crates/coa-core/src/custom_races_hashes_1_5.json new file mode 100644 index 0000000..56f320e --- /dev/null +++ b/crates/coa-core/src/custom_races_hashes_1_5.json @@ -0,0 +1,50 @@ +{ + "Data/patch-ZE1.MPQ": "69d79dbbea54ec0312c37770debc79c7bc9ce2e21eedef99c03cd8d911955283", + "Data/patch-ZE2.MPQ": "1a41cae77ad730e9cf69e8d63fd12ae3a579418bb550d351fff469573a97ac3c", + "Data/patch-ZE3.MPQ": "b587202c21b6f580682b601b7c2e905fbfa766276e74733db55c1bddbffb7f87", + "Data/patch-ZEA.MPQ": "78fd10ec5262ae2417eba9700d2172553bbc1e5cfb0b597aada8f1753ca46d9d", + "Data/patch-ZHA.MPQ": "0fc6ebeafe1d14192dc64bafc90bde3c49230df1297f98bf65900a3bcf953617", + "Data/patch-ZZE.MPQ": "4b1e289e12c82644cb78f50170e6b5fd53918eb2de834f9e0cebddd81873a8bb", + "Data/patch-ZZR.MPQ": "5a7575e592dc0c8a6913e0d946bbe06fa0ca2e4d817ef1f8548d3d13142bcbd3", + "Ascension.exe": "e2c347019d554a59639e5978fd68e50e906973ab313c6573cc56ca82e8e3c547", + "EsteriaAppearance.bin": "b3fb7c69b7600c39f888ea1925701be5667097dcb02bb6ddd830e5489306bb50", + "EsteriaAppearance.dll": "392c5a2a25f9a69d705bdf841bb899aab768d4a3ee62734d576286c7198710d8", + "EsteriaAppearanceGeometry.bin": "c95c53ca19c53c3434e12b99c1966f9c015ec3944c46dff7784d5e4ffc472d5d", + "EsteriaAppearanceMaterials.bin": "f72f027f54a55c05aa0f76b457b97b484fe0e1aedc0e068cc0e0ca53e1878cba", + "EsteriaCosmeticWings.bin": "57352c28459d073a29b3014d33b70f3b3756afa37fa9583ef6b30d503422ac3f", + "EsteriaEarthen.bin": "8a0db93ba2d5c756b44ed84f2f7bc7c15eef13be6a7d87baf1383c6c0c624e02", + "EsteriaGnoll.bin": "cfacc8e681bb8e54a4f01ee9c12a91bf0bd23a23527bb548d4dc21ec1b33eea2", + "EsteriaGnollTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605", + "EsteriaHaranir.bin": "59f31d00e372fd2646e1cf0d9d3d5d671b31edd60ff7b862f8d1ce7e53ba1f2f", + "EsteriaHaranirTextures.bin": "99948ca570be2d376f0c67e3b8088739b6fa9f9d2c73985e19f68df77689a229", + "EsteriaHighmountain.bin": "e83349252ad2687ac23fcfc5623db7401cf32e5faf47a5a7be20c2f94b014938", + "EsteriaNaga.bin": "e87547cff5d09500c294f8b06935d134cdd0bbb962e294a72de09404505bd8b3", + "EsteriaNagaTextures.bin": "57ec560f99c1d48ac1e8f6a797c156513b99e7d349e3071afe405f812d159ae3", + "EsteriaSaberon.bin": "7735c9325c807c5125760d87f323d6b7d4162fb542c1d8b5f435ea2d6b9a4daa", + "EsteriaSaberonCoverage.bin": "9cdc227f08329e1245d2b864a996e7a772c96ecdbe5d81925f4d60cdbe4fa11e", + "EsteriaSaberonTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605", + "EsteriaSethrak.bin": "bd14857a8c11fdf8027128763293701586b2102f9a6539d33a727014789fd606", + "EsteriaSethrakTextures.bin": "d2b2cf58087a391f00198008fe871edd400ddf28d54a3a05674df74c5198528a", + "EsteriaThinHuman.bin": "75794d192ea0c228b3da7bf7d0bdff1119f79d3f9bfef6650cd5e1a2b5d106eb", + "EsteriaThinHumanTextures.bin": "02aeb4c05893d06125b8b58fdb7c2a56fe3d66a7d094706a44b43cb503d1731e", + "EsteriaTuskarr.bin": "ecbe7ef490ba67eca029a57639192db61238522618491549766fe38529914387", + "EsteriaTuskarrTextures.bin": "e2116c2c83adea079126fd5da765d20da24f044d4f161c4124e4368cb3d6bc71", + "EsteriaVrykul.bin": "39e2b7b4c67997dbbd34bae840bd606a59290f7f2fc82565a0ba00090335415c", + "EsteriaVrykulTextures.bin": "46098750a0e235724c6e9cc8d9d4e26550aa47bef95fd5f0f82efe5e945bf296", + "EsteriaVulpera.bin": "c5434993b9af4a17d881965e4735cd2ac379e39c7142aac9844adff9528562d4", + "EsteriaVulperaTextures.bin": "1ab844e74684aced2b069f1b91cd563aade45e05bd5e23f866a6c0c301cb098e", + "dinput8.dll": "f8de78606c241fb3911dce22faf68f5b9f9885b142b183c1e2c09ddc8f7e380c", + "Data/patch-T.MPQ": "9403e0680c74754eb0cb063718119aa6ace3616f7cecf488c8faf46f22505ae5", + "Data/patch-ZE5.MPQ": "476b5a1fcf69f64fdb281e3b445362b936d016066ccd12f897acfcf200d6c0cf", + "Data/patch-ZE6.MPQ": "762592c9ed950b4a7b6c7d85d45050b30fa3f35d53e348ee1e1516fdbb7e5484", + "Data/patch-ZE7.MPQ": "de54b0189d242763d2872223419a259b6b8a4b380c24b50a2ef9c6fa44466248", + "Data/patch-ZE8.MPQ": "1bc5691fb84a84f3e7a0a70baaa9192a97b4ec5906ee37c85838ffe08adef934", + "Data/patch-ZE9.MPQ": "d49c94463e9548e76378ce2c0454d8f204f0745c8c0de32b6dce7f0b0fd67c4a", + "Data/patch-ZES.MPQ": "a2d9348f917f745c7aa25b2eed96efa312f7b2d2f62cb86a4f2eaabc2dfa895f", + "Data/patch-ZG.MPQ": "48979e1fda775f8deb47bffa48e0a468595d4c71286fee518f51dc5801c5ca39", + "Data/patch-ZH.MPQ": "5e1d2fe287053abba703d7af381e10096c6525ba477ffed3971c2fc4f93e919c", + "Data/patch-ZHM.MPQ": "317d3ae00f0bd674b358af07544976acb7804aa6d8b19d1788ad96030e8c9bf0", + "Data/patch-ZJ.MPQ": "f821e99add86ef68d108c5ec466328c97677d3ed52f7944e200f3fe38c13b4de", + "Data/patch-ZM.MPQ": "424ab1978986e854551633983a94bd15beba6328aabea8c02f98eaa32f6f0b61", + "Data/patch-ZVU.MPQ": "c679b85a2e97344395756b54d2cf6abefe4e80e74944cb55b6e573ad17b7d020" +} diff --git a/crates/coa-core/src/custom_races_hashes_1_6.json b/crates/coa-core/src/custom_races_hashes_1_6.json new file mode 100644 index 0000000..9baa919 --- /dev/null +++ b/crates/coa-core/src/custom_races_hashes_1_6.json @@ -0,0 +1,52 @@ +{ + "Ascension.exe": "e2c347019d554a59639e5978fd68e50e906973ab313c6573cc56ca82e8e3c547", + "EsteriaAppearance.bin": "b3fb7c69b7600c39f888ea1925701be5667097dcb02bb6ddd830e5489306bb50", + "EsteriaAppearance.dll": "392c5a2a25f9a69d705bdf841bb899aab768d4a3ee62734d576286c7198710d8", + "EsteriaAppearanceGeometry.bin": "c95c53ca19c53c3434e12b99c1966f9c015ec3944c46dff7784d5e4ffc472d5d", + "EsteriaAppearanceMaterials.bin": "f72f027f54a55c05aa0f76b457b97b484fe0e1aedc0e068cc0e0ca53e1878cba", + "EsteriaCosmeticWings.bin": "57352c28459d073a29b3014d33b70f3b3756afa37fa9583ef6b30d503422ac3f", + "EsteriaEarthen.bin": "8a0db93ba2d5c756b44ed84f2f7bc7c15eef13be6a7d87baf1383c6c0c624e02", + "EsteriaGnoll.bin": "cfacc8e681bb8e54a4f01ee9c12a91bf0bd23a23527bb548d4dc21ec1b33eea2", + "EsteriaGnollTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605", + "EsteriaHaranir.bin": "59f31d00e372fd2646e1cf0d9d3d5d671b31edd60ff7b862f8d1ce7e53ba1f2f", + "EsteriaHaranirTextures.bin": "99948ca570be2d376f0c67e3b8088739b6fa9f9d2c73985e19f68df77689a229", + "EsteriaHighmountain.bin": "e83349252ad2687ac23fcfc5623db7401cf32e5faf47a5a7be20c2f94b014938", + "EsteriaNaga.bin": "e87547cff5d09500c294f8b06935d134cdd0bbb962e294a72de09404505bd8b3", + "EsteriaNagaTextures.bin": "57ec560f99c1d48ac1e8f6a797c156513b99e7d349e3071afe405f812d159ae3", + "EsteriaSaberon.bin": "7735c9325c807c5125760d87f323d6b7d4162fb542c1d8b5f435ea2d6b9a4daa", + "EsteriaSaberonCoverage.bin": "9cdc227f08329e1245d2b864a996e7a772c96ecdbe5d81925f4d60cdbe4fa11e", + "EsteriaSaberonTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605", + "EsteriaSethrak.bin": "bd14857a8c11fdf8027128763293701586b2102f9a6539d33a727014789fd606", + "EsteriaSethrakTextures.bin": "d2b2cf58087a391f00198008fe871edd400ddf28d54a3a05674df74c5198528a", + "EsteriaThinHuman.bin": "75794d192ea0c228b3da7bf7d0bdff1119f79d3f9bfef6650cd5e1a2b5d106eb", + "EsteriaThinHumanTextures.bin": "02aeb4c05893d06125b8b58fdb7c2a56fe3d66a7d094706a44b43cb503d1731e", + "EsteriaTuskarr.bin": "ecbe7ef490ba67eca029a57639192db61238522618491549766fe38529914387", + "EsteriaTuskarrTextures.bin": "e2116c2c83adea079126fd5da765d20da24f044d4f161c4124e4368cb3d6bc71", + "EsteriaVrykul.bin": "39e2b7b4c67997dbbd34bae840bd606a59290f7f2fc82565a0ba00090335415c", + "EsteriaVrykulTextures.bin": "46098750a0e235724c6e9cc8d9d4e26550aa47bef95fd5f0f82efe5e945bf296", + "EsteriaVulpera.bin": "c5434993b9af4a17d881965e4735cd2ac379e39c7142aac9844adff9528562d4", + "EsteriaVulperaTextures.bin": "1ab844e74684aced2b069f1b91cd563aade45e05bd5e23f866a6c0c301cb098e", + "dinput8.dll": "f8de78606c241fb3911dce22faf68f5b9f9885b142b183c1e2c09ddc8f7e380c", + "Data/patch-T.MPQ": "9403e0680c74754eb0cb063718119aa6ace3616f7cecf488c8faf46f22505ae5", + "Data/patch-ZE5.MPQ": "476b5a1fcf69f64fdb281e3b445362b936d016066ccd12f897acfcf200d6c0cf", + "Data/patch-ZE6.MPQ": "762592c9ed950b4a7b6c7d85d45050b30fa3f35d53e348ee1e1516fdbb7e5484", + "Data/patch-ZE7.MPQ": "de54b0189d242763d2872223419a259b6b8a4b380c24b50a2ef9c6fa44466248", + "Data/patch-ZE8.MPQ": "1bc5691fb84a84f3e7a0a70baaa9192a97b4ec5906ee37c85838ffe08adef934", + "Data/patch-ZE9.MPQ": "d49c94463e9548e76378ce2c0454d8f204f0745c8c0de32b6dce7f0b0fd67c4a", + "Data/patch-ZES.MPQ": "a2d9348f917f745c7aa25b2eed96efa312f7b2d2f62cb86a4f2eaabc2dfa895f", + "Data/patch-ZG.MPQ": "48979e1fda775f8deb47bffa48e0a468595d4c71286fee518f51dc5801c5ca39", + "Data/patch-ZH.MPQ": "5e1d2fe287053abba703d7af381e10096c6525ba477ffed3971c2fc4f93e919c", + "Data/patch-ZHM.MPQ": "317d3ae00f0bd674b358af07544976acb7804aa6d8b19d1788ad96030e8c9bf0", + "Data/patch-ZJ.MPQ": "f821e99add86ef68d108c5ec466328c97677d3ed52f7944e200f3fe38c13b4de", + "Data/patch-ZM.MPQ": "424ab1978986e854551633983a94bd15beba6328aabea8c02f98eaa32f6f0b61", + "Data/patch-ZVU.MPQ": "c679b85a2e97344395756b54d2cf6abefe4e80e74944cb55b6e573ad17b7d020", + "Data/patch-ZE1.MPQ": "69d79dbbea54ec0312c37770debc79c7bc9ce2e21eedef99c03cd8d911955283", + "Data/patch-ZE2.MPQ": "1a41cae77ad730e9cf69e8d63fd12ae3a579418bb550d351fff469573a97ac3c", + "Data/patch-ZE3.MPQ": "b587202c21b6f580682b601b7c2e905fbfa766276e74733db55c1bddbffb7f87", + "Data/patch-ZEA.MPQ": "78fd10ec5262ae2417eba9700d2172553bbc1e5cfb0b597aada8f1753ca46d9d", + "Data/patch-ZHA.MPQ": "0fc6ebeafe1d14192dc64bafc90bde3c49230df1297f98bf65900a3bcf953617", + "Data/patch-ZZE.MPQ": "4b1e289e12c82644cb78f50170e6b5fd53918eb2de834f9e0cebddd81873a8bb", + "Data/patch-ZZR.MPQ": "5a7575e592dc0c8a6913e0d946bbe06fa0ca2e4d817ef1f8548d3d13142bcbd3", + "Data/patch-ZZS.MPQ": "533fd69d5784d83e872a5a28d27301dc9ab5d0cb925f8b2116926854e4a0a5b8", + "Data/patch-ZZT.MPQ": "aef3bbe4951333d62fb55224484724fbaa9c35773a19944ffb9935e250cfb59c" +} diff --git a/crates/coa-core/src/db.rs b/crates/coa-core/src/db.rs index 9bfb20f..7ecce98 100644 --- a/crates/coa-core/src/db.rs +++ b/crates/coa-core/src/db.rs @@ -364,6 +364,13 @@ impl Db { .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); @@ -470,6 +477,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"; @@ -578,6 +597,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 858dedd..b83e0e5 100644 --- a/crates/coa-core/src/diag.rs +++ b/crates/coa-core/src/diag.rs @@ -199,7 +199,12 @@ pub fn run(root: &Path, meta: &InstallMeta) -> Report { }); } - let exposed: Vec<&str> = crate::net::exposure(&ports) + let exposure = if crate::docker::is_docker(root) { + crate::docker::exposure(root, &ports) + } else { + crate::net::exposure(&ports) + }; + let exposed: Vec<&str> = exposure .iter() .filter(|e| { (e.what == "database" || e.what == "server console") && e.reachable_from_network @@ -690,6 +695,18 @@ pub fn export_package( "summary.json", serde_json::to_string_pretty(&summary)?.as_bytes(), )?; + let owner = fs::read_to_string(meta_dir.join("update-lock-owner.json")) + .ok() + .and_then(|text| serde_json::from_str::(&redact_json(&text)).ok()); + let maintenance = serde_json::json!({ + "lockFilePresent": meta_dir.join("update.lock").is_file(), + "lastRecordedOwner": owner, + "note": "The lock file and owner record may remain after completion. Their presence does not establish whether the OS lock is currently held." + }); + add( + "maintenance.json", + serde_json::to_string_pretty(&maintenance)?.as_bytes(), + )?; // Logs. The newest update logs of the repack's own updater are added under their own names. let mut logs: Vec<(String, PathBuf)> = vec![ @@ -796,6 +813,29 @@ pub fn export_package( 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() @@ -882,6 +922,7 @@ pub fn stamp() -> String { chrono::Local::now().format("%Y%m%d-%H%M%S").to_string() } +#[cfg(windows)] pub fn desktop_or_temp() -> PathBuf { let d = std::env::var_os("USERPROFILE") .map(PathBuf::from) @@ -894,11 +935,80 @@ pub fn desktop_or_temp() -> PathBuf { } } +/// Where a file meant for the person goes on Linux: the desktop folder they have (the XDG user directories file names it), else +/// `~/Desktop`, else `~/Downloads`, else the temporary folder. +#[cfg(not(windows))] +pub fn desktop_or_temp() -> PathBuf { + let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else { + return std::env::temp_dir(); + }; + let dirs = std::fs::read_to_string(home.join(".config/user-dirs.dirs")).ok(); + pick_desktop(&home, dirs.as_deref(), &|p| p.is_dir()) +} + +#[cfg(not(windows))] +fn pick_desktop(home: &Path, user_dirs: Option<&str>, exists: &dyn Fn(&Path) -> bool) -> PathBuf { + let named = user_dirs.and_then(|t| { + t.lines().find_map(|l| { + let v = l.trim().strip_prefix("XDG_DESKTOP_DIR=")?.trim_matches('"'); + Some(PathBuf::from(v.replace("$HOME", &home.to_string_lossy()))) + }) + }); + // A user without a desktop sets XDG_DESKTOP_DIR to the home folder itself: that is not a place to drop files into. + [ + named, + Some(home.join("Desktop")), + Some(home.join("Downloads")), + ] + .into_iter() + .flatten() + .filter(|p| p != home) + .find(|p| exists(p)) + .unwrap_or_else(std::env::temp_dir) +} + #[cfg(test)] mod tests { use super::*; use crate::registry::{InstallKind, InstallMeta}; + #[cfg(not(windows))] + #[test] + fn the_desktop_folder_on_linux_follows_the_users_own_directories() { + let home = Path::new("/home/ana"); + let only = + |wanted: &'static [&'static str]| move |p: &Path| wanted.contains(&p.to_str().unwrap()); + let dirs = + "# comment\nXDG_DOWNLOAD_DIR=\"$HOME/Downloads\"\nXDG_DESKTOP_DIR=\"$HOME/Bureau\"\n"; + assert_eq!( + pick_desktop( + home, + Some(dirs), + &only(&["/home/ana/Bureau", "/home/ana/Desktop"]) + ), + PathBuf::from("/home/ana/Bureau") + ); + assert_eq!( + pick_desktop(home, None, &only(&["/home/ana/Desktop"])), + PathBuf::from("/home/ana/Desktop") + ); + assert_eq!( + pick_desktop(home, Some(dirs), &only(&["/home/ana/Downloads"])), + PathBuf::from("/home/ana/Downloads") + ); + assert_eq!(pick_desktop(home, None, &only(&[])), std::env::temp_dir()); + // No desktop: the user directories file names the home folder itself. + let none = "XDG_DESKTOP_DIR=\"$HOME/\"\n"; + assert_eq!( + pick_desktop( + home, + Some(none), + &only(&["/home/ana", "/home/ana/Downloads"]) + ), + PathBuf::from("/home/ana/Downloads") + ); + } + #[test] fn repeated_config_warnings_are_folded() { let t = "start @@ -1115,6 +1225,11 @@ real error .unwrap(); let meta_dir = d.path().join("srv.manager"); fs::create_dir_all(&meta_dir).unwrap(); + fs::write(meta_dir.join("update.lock"), b"").unwrap(); + fs::write(meta_dir.join("update-lock-owner.json"), r#"{"operation":"checking updates","pid":1234,"started":"2026-10-13T01:00:00Z","token":"owner-secret"}"#).unwrap(); + fs::create_dir_all(meta_dir.join("diagnostics")).unwrap(); + fs::write(meta_dir.join("diagnostics/schema-validation-CoA-test.json"), r#"{"problems":[{"table":"creature_display_preset","column":"display_id","detail":"default mismatch"}],"password":"schema-secret"}"#).unwrap(); + fs::write(meta_dir.join("diagnostics/database-recovery-test.json"), r#"{"objects":[{"database":"acore_characters","kind":"TRIGGER","name":"user_character_trigger"}],"password":"recovery-secret"}"#).unwrap(); let meta = InstallMeta::new(InstallKind::Imported, &root); let report = run(&root, &meta); let zip_path = d.path().join("diag.zip"); @@ -1140,6 +1255,14 @@ real error "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("checking updates") && all.contains("1234")); + assert!(all.contains("lockFilePresent") && all.contains("does not establish")); + assert!(!all.contains("owner-secret")); + assert_eq!(fs::read(meta_dir.join("update.lock")).unwrap(), b""); assert!( !all.contains("hunter2") && !all.contains("SECRETPW") diff --git a/crates/coa-core/src/docker/install.rs b/crates/coa-core/src/docker/install.rs index 40f0ad6..7103145 100644 --- a/crates/coa-core/src/docker/install.rs +++ b/crates/coa-core/src/docker/install.rs @@ -171,7 +171,7 @@ pub fn preflight( } } -fn data_problems(data_dir: &Path) -> Vec { +pub(crate) fn data_problems(data_dir: &Path) -> Vec { let s = data_dir.to_string_lossy(); if !data_dir.is_absolute() || s.contains(':') { return vec![problem( diff --git a/crates/coa-core/src/docker/lifecycle.rs b/crates/coa-core/src/docker/lifecycle.rs index f814cee..4bd1ecc 100644 --- a/crates/coa-core/src/docker/lifecycle.rs +++ b/crates/coa-core/src/docker/lifecycle.rs @@ -297,6 +297,10 @@ fn start(d: &dyn Docker, root: &Path, cfg: &Config, with_game: bool, log: &mut L run_container(d, &call, &n.world, log)?; } wait_listening(d, cfg, &n.world, ports.world, WORLD_WAIT, log)?; + // The core leaves with code 2 for `server restart` and expects a supervisor to start it again (the repack has one). Docker + // does that for any non-zero exit, but only once the server has come up: a start that fails must stay failed, not loop. + // A stop by `docker stop` or `server shutdown` (code 0) is never restarted. + let _ = docker(d, &["update", "--restart", "on-failure:3", &n.world], QUICK); if !state.get(&n.auth).is_some_and(Container::running) { remove(d, &n.auth); @@ -315,9 +319,51 @@ fn start(d: &dyn Docker, root: &Path, cfg: &Config, with_game: bool, log: &mut L run_container(d, &call, &n.auth, log)?; } wait_listening(d, cfg, &n.auth, ports.auth, AUTH_WAIT, log)?; + mark_realm_online(d, &n, &secrets, realm, log); Ok(()) } +/// The auth server marks every realm offline when it starts, and the world server (already up, see the order above) only +/// puts itself back after its next uptime update, ten minutes later by default: until then the client shows "Realm is +/// Offline". The world is known to be listening here, so say so for the realm it serves. The other realm of a Wildcard +/// install keeps the offline flag it is given on purpose (`realms::setup_realmlist`). +fn mark_realm_online( + d: &dyn Docker, + n: &Names, + secrets: &Secrets, + realm: crate::realms::Mode, + log: &mut Log, +) { + let sql = format!( + "UPDATE acore_auth.realmlist SET flag = flag & ~2 WHERE id = {};", + realm.realm_id() + ); + let mut call = Call::new( + &[ + "exec", + "-i", + "-e", + "MYSQL_PWD", + &n.db, + "mysql", + "--user=root", + "--protocol=tcp", + "--host=127.0.0.1", + ], + QUICK, + ); + call.env = vec![("MYSQL_PWD".into(), secrets.root.clone())]; + call.stdin = Some(sql.as_bytes()); + match d.run(&call) { + Ok(o) if o.ok() => {} + Ok(o) => log.say(format!( + "the realm could not be marked online: {}", + o.text().trim() + )), + Err(e) => log.say(format!("the realm could not be marked online: {e}")), + } +} + fn remove(d: &dyn Docker, name: &str) { // A stopped container of the same name is replaced, so its settings always match the files on disk. let _ = d.run(&Call::new(&["rm", "-f", name], QUICK)); @@ -508,6 +554,30 @@ fn database_env(s: &Secrets, world: bool, realm: crate::realms::Mode) -> Vec<(St env } +/// Every setting variable (`AC_...`) the containers get, for the check that the screens know which settings are fixed. +#[cfg(test)] +pub(crate) fn forced_variables() -> Vec { + let cfg = Config { + project: "t".into(), + bind_address: "127.0.0.1".into(), + mysql_image: "mysql:8.4".into(), + data_dir: None, + mysql_data: None, + }; + let n = cfg.names(); + let mut found = Vec::new(); + for kind in [GameKind::World, GameKind::Auth] { + let args = game_args(&cfg, &n, kind, Path::new("/s"), Path::new("/d"), 1, 2, None); + found.extend(args.iter().filter_map(|a| { + a.strip_prefix("AC_") + .map(|rest| format!("AC_{}", rest.split('=').next().unwrap())) + })); + } + found.sort(); + found.dedup(); + found +} + /// Realm profiles are only in play once a profile has been chosen on this server (`Settings/realm-profile.json`). fn prepare_realm(root: &Path, log: &mut Log) -> Step { let fail_with = |e: crate::error::Error| Failure { @@ -948,6 +1018,8 @@ mod tests { } out(0, "containerid\n", "") } + ["update", "--restart", _, _] => out(0, "", ""), + ["exec", ..] => out(0, "", ""), ["stop", "--time", _, name] => { self.running.borrow_mut().remove(*name); self.exited @@ -1016,6 +1088,17 @@ mod tests { ["coa-t1-db", "coa-t1-world", "coa-t1-auth"], "database, then world, then auth" ); + let exec = sim.calls_of("exec"); + assert_eq!( + exec.len(), + 1, + "the realm is marked online once the auth server is up" + ); + assert!(exec[0].stdin && exec[0].env == [("MYSQL_PWD".to_string(), ROOT_PW.to_string())]); + assert!( + !exec[0].args.iter().any(|a| a.contains(ROOT_PW)), + "the password is not on the command line" + ); assert!( at("build") < verbs.iter().rposition(|v| v.starts_with("run")).unwrap(), "the runtime image exists before the game servers" @@ -1119,6 +1202,34 @@ mod tests { .any(|a| a == "--lower-case-table-names=1" || a == "--user")); } + #[test] + fn the_world_server_is_started_again_when_it_restarts_itself_but_only_once_it_is_up() { + let (_d, root) = server("t1"); + let sim = Sim::new(); + assert!(start_all(&sim, &root).ok); + let world_run = sim + .calls_of("run") + .into_iter() + .find(|c| c.args.iter().any(|a| a == "coa-t1-world")) + .unwrap(); + assert!( + !world_run.args.iter().any(|a| a == "--restart"), + "no restart while it is still starting" + ); + let updates = sim.calls_of("update"); + assert_eq!(updates.len(), 1); + assert_eq!( + updates[0].args, + ["update", "--restart", "on-failure:3", "coa-t1-world"] + ); + let verbs = sim.verbs(); + let pos = |p: &str| verbs.iter().position(|v| v.starts_with(p)).unwrap(); + assert!( + pos("run --detach") < pos("update"), + "after the container exists and listens" + ); + } + #[test] fn the_containers_use_the_databases_of_the_selected_realm() { let s = Secrets { diff --git a/crates/coa-core/src/docker/logs.rs b/crates/coa-core/src/docker/logs.rs new file mode 100644 index 0000000..19ca170 --- /dev/null +++ b/crates/coa-core/src/docker/logs.rs @@ -0,0 +1,72 @@ +//! The database of a Docker server has no log file in the server folder: MySQL writes to its container's output. + +use super::cli::{Call, Docker, SystemDocker}; +use super::Config; +use crate::console::Line; +use crate::error::{Error, Result}; +use std::path::Path; +use std::time::Duration; + +/// The last lines MySQL wrote (what `docker logs` returns), filtered and classified like the lines of a log file. +pub fn database_log(root: &Path, filter: Option<&str>, max_lines: usize) -> Result> { + database_log_with(&SystemDocker, root, filter, max_lines) +} + +pub fn database_log_with(d: &dyn Docker, root: &Path, filter: Option<&str>, max_lines: usize) -> Result> { + let name = Config::load(root)?.names().db; + // Ask for more than wanted: the filter may drop most of them. + let tail = (max_lines * 4).clamp(200, 8000).to_string(); + let out = d.run(&Call::new(&["logs", "--tail", &tail, &name], Duration::from_secs(20)))?; + if !out.ok() { + return Err(Error::Invalid("This log does not exist yet.".into())); + } + // MySQL writes to the error stream. + Ok(crate::console::lines_from_text(&format!("{}\n{}", out.stderr, out.stdout), filter, max_lines)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::docker::cli::Output; + use std::cell::RefCell; + + struct Fake { + out: Output, + calls: RefCell>>, + } + + impl Docker for Fake { + fn run(&self, call: &Call) -> Result { + self.calls.borrow_mut().push(call.args.clone()); + Ok(self.out.clone()) + } + } + + fn root() -> (tempfile::TempDir, std::path::PathBuf) { + let d = tempfile::tempdir().unwrap(); + let root = d.path().join("srv"); + std::fs::create_dir_all(root.join("Settings")).unwrap(); + std::fs::write(root.join("Settings/docker.json"), r#"{"project":"t1"}"#).unwrap(); + (d, root) + } + + #[test] + fn the_database_log_is_what_the_container_printed() { + let (_d, root) = root(); + let fake = Fake { out: Output { code: Some(0), stdout: "2026-10-04T10:00:00Z [System] ready\n".into(), stderr: "2026-10-04T10:00:01Z [ERROR] [MY-010] boom\n2026-10-04T10:00:02Z [Warning] slow\n".into() }, calls: Default::default() }; + let lines = database_log_with(&fake, &root, None, 100).unwrap(); + assert_eq!(fake.calls.borrow()[0][..2], ["logs", "--tail"]); + assert_eq!(fake.calls.borrow()[0].last().unwrap(), "coa-t1-db"); + assert_eq!(lines.len(), 3); + assert!(lines.iter().any(|l| l.text.contains("boom") && l.level == crate::console::Level::Error)); + let only = database_log_with(&fake, &root, Some("slow"), 100).unwrap(); + assert_eq!(only.len(), 1); + } + + #[test] + fn a_database_that_never_ran_has_no_log_yet() { + let (_d, root) = root(); + let fake = Fake { out: Output { code: Some(1), stdout: String::new(), stderr: "Error: No such container: coa-t1-db".into() }, calls: Default::default() }; + assert!(database_log_with(&fake, &root, None, 10).unwrap_err().to_string().contains("does not exist yet")); + } +} diff --git a/crates/coa-core/src/docker/mod.rs b/crates/coa-core/src/docker/mod.rs index 6a3da5a..044e95a 100644 --- a/crates/coa-core/src/docker/mod.rs +++ b/crates/coa-core/src/docker/mod.rs @@ -18,10 +18,13 @@ mod cli; pub mod fixture; pub mod install; mod lifecycle; +pub mod logs; +pub mod sharing; pub use cli::{Call, Docker, Output, SystemDocker}; pub(crate) use lifecycle::destroy; pub use lifecycle::{check_docker, observe, observe_with, run, run_with}; +pub use sharing::{bind_is_open, exposure, set_open}; use std::net::IpAddr; use std::path::Path; @@ -170,6 +173,103 @@ pub fn ensure_main_configs(root: &Path) -> Result> { Ok(created) } +/// Settings the Docker backend gives the containers as environment variables. The core reads `AC_` before the +/// configuration file, so a value written to the file for one of these keys is silently ignored. They are what makes the +/// containers work (where things are inside them, listening on every interface, the console, no built-in database +/// updater, the databases); the owner has no use for them and the screens do not offer them. +pub(crate) const FORCED_KEYS: [&str; 12] = [ + "BindIP", + "WorldServerPort", + "RealmServerPort", + "LogsDir", + "DataDir", + "Updates.EnableDatabases", + "Ra.Enable", + "Ra.IP", + "Ra.Port", + "LoginDatabaseInfo", + "WorldDatabaseInfo", + "CharacterDatabaseInfo", +]; + +/// Is this setting fixed by the Docker backend (see `FORCED_KEYS`)? +pub fn managed_setting(key: &str) -> bool { + FORCED_KEYS.iter().any(|k| k.eq_ignore_ascii_case(key)) +} + +/// The environment variable the core reads for a setting: `AC_` and the key in upper snake case, a `_` between a lower-case +/// letter and a capital, and at letter / digit boundaries (the core's `IniKeyToEnvVarKey`). +#[cfg(test)] +pub(crate) fn env_name(key: &str) -> String { + let chars: Vec = key.chars().collect(); + let mut out = String::from("AC_"); + for (i, &c) in chars.iter().enumerate() { + if matches!(c, ' ' | '.' | '-') { + out.push('_'); + continue; + } + out.push(c.to_ascii_uppercase()); + if let Some(&next) = chars.get(i + 1) { + let split = (!c.is_ascii_uppercase() && next.is_ascii_uppercase()) + || (!c.is_ascii_digit() && next.is_ascii_digit()) + || (c.is_ascii_digit() + && !next.is_ascii_digit() + && !matches!(next, ' ' | '.' | '-')); + if split { + out.push('_'); + } + } + } + out +} + +/// The folder mounted as the game data of this Docker server. +pub fn game_data_dir(root: &Path) -> Result { + Ok(Config::load(root)?.data_path(root)) +} + +/// Point a Docker server at another game data folder (used in place, read-only). The containers are recreated at the next +/// start, so the servers must be stopped: a running one keeps the folder it has. +pub fn set_game_data(root: &Path, path: &Path) -> Result<()> { + let problems = install::data_problems(path); + if !problems.is_empty() { + return Err(Error::Invalid( + problems + .iter() + .map(|p| p.message.clone()) + .collect::>() + .join(" "), + )); + } + let observed = crate::process::observe(root, &crate::layout::read_ports(root)); + let busy = |s: &crate::process::ServiceStatus| { + matches!( + s.state, + crate::process::ServiceState::Running + | crate::process::ServiceState::Starting + | crate::process::ServiceState::Stopping + ) + }; + if busy(&observed.world) || busy(&observed.auth) { + return Err(Error::Invalid( + "Stop the server before changing the game data folder.".into(), + )); + } + let mut cfg = Config::load(root)?; + cfg.data_dir = Some(path.to_string_lossy().into_owned()); + cfg.validate()?; + fsx::atomic_write_json(&root.join(MARKER), &cfg) +} + +/// A reason a value cannot work on a Docker server, for settings that name a path: the server runs in a container that sees only +/// its own `Core` folder (as `/srv/core`, its working directory), so a path elsewhere on this computer does not exist for it. +pub fn setting_problem(key: &str, raw: &str) -> Option<&'static str> { + let raw = raw.trim().trim_matches('"'); + let outside = Path::new(raw).is_absolute() && !raw.starts_with("/srv/core/"); + (key.eq_ignore_ascii_case("CoaBots.TalentBuildsPath") && !raw.is_empty() && outside) + .then_some("must be a path inside the server's Core folder (for example reference/ascensionsidekick-level-builds.json), because the server runs in a container that cannot see other folders") +} + /// Name of the runtime image: it follows the content of its Dockerfile, so changing the libraries builds a new image. pub(crate) fn runtime_image() -> String { format!( @@ -287,6 +387,89 @@ mod tests { use super::*; use std::fs; + #[cfg(unix)] + #[test] + fn the_game_data_folder_can_be_changed_when_the_servers_are_stopped() { + let d = tempfile::tempdir().unwrap(); + let root = d.path().join("srv"); + std::fs::create_dir_all(root.join("Settings")).unwrap(); + std::fs::write( + root.join(MARKER), + r#"{"project":"t1","dataDir":"/old/data"}"#, + ) + .unwrap(); + let good = d.path().join("data"); + std::fs::create_dir_all(good.join("dbc")).unwrap(); + std::fs::create_dir_all(good.join("maps")).unwrap(); + assert_eq!( + game_data_dir(&root).unwrap(), + std::path::PathBuf::from("/old/data") + ); + set_game_data(&root, &good).unwrap(); + assert_eq!(game_data_dir(&root).unwrap(), good); + assert_eq!( + Config::load(&root).unwrap().project, + "t1", + "the other settings are kept" + ); + // A folder that does not look like game data is refused and nothing changes. + let empty = d.path().join("empty"); + std::fs::create_dir_all(&empty).unwrap(); + assert!(set_game_data(&root, &empty).is_err()); + assert_eq!(game_data_dir(&root).unwrap(), good); + assert!(set_game_data(&root, std::path::Path::new("relative/dir")).is_err()); + } + + #[cfg(unix)] + #[test] + fn a_path_outside_the_core_folder_is_refused_for_a_docker_server() { + let key = "CoaBots.TalentBuildsPath"; + assert!(setting_problem(key, "reference/builds.json").is_none()); + assert!(setting_problem(key, "").is_none()); + assert!(setting_problem(key, "\"/srv/core/reference/builds.json\"").is_none()); + assert!(setting_problem(key, "/home/ana/builds.json").is_some()); + assert!( + setting_problem("Rate.XP.Kill", "/home/ana/x").is_none(), + "only settings that name a path" + ); + } + + #[test] + fn setting_names_become_the_variables_the_core_reads() { + for (key, var) in [ + ("BindIP", "AC_BIND_IP"), + ("Ra.Enable", "AC_RA_ENABLE"), + ("Ra.IP", "AC_RA_IP"), + ("Updates.EnableDatabases", "AC_UPDATES_ENABLE_DATABASES"), + ("WorldServerPort", "AC_WORLD_SERVER_PORT"), + ("LoginDatabaseInfo", "AC_LOGIN_DATABASE_INFO"), + ("DataDir", "AC_DATA_DIR"), + ( + "Dynamic.XP.Reminder.Interval", + "AC_DYNAMIC_XP_REMINDER_INTERVAL", + ), + ("EtherealBazaar.Enable", "AC_ETHEREAL_BAZAAR_ENABLE"), + ] { + assert_eq!(env_name(key), var, "{key}"); + } + } + + #[test] + fn the_settings_called_managed_are_exactly_the_variables_the_containers_get() { + // If a variable is added to the containers without being listed here (or the other way round), a screen would + // offer a setting that does nothing, or hide one that works. + let given: std::collections::BTreeSet = + lifecycle::forced_variables().into_iter().collect(); + let listed: std::collections::BTreeSet = + FORCED_KEYS.iter().map(|k| env_name(k)).collect(); + assert_eq!(given, listed); + assert!( + managed_setting("ra.enable") + && managed_setting("Updates.EnableDatabases") + && !managed_setting("PlayerLimit") + ); + } + #[test] fn the_main_configs_are_created_from_their_templates_and_never_overwritten() { let d = tempfile::tempdir().unwrap(); diff --git a/crates/coa-core/src/docker/sharing.rs b/crates/coa-core/src/docker/sharing.rs new file mode 100644 index 0000000..1c0d120 --- /dev/null +++ b/crates/coa-core/src/docker/sharing.rs @@ -0,0 +1,149 @@ +//! Sharing a Docker server with friends. Who can reach the game is decided by the address the container ports are +//! published on (`bindAddress` in `Settings/docker.json`): `127.0.0.1` is this computer only, anything else is the network. +//! The `BindIP` of the configuration files is forced to `0.0.0.0` inside the containers and changes nothing, so the +//! repack's way of opening the servers (editing `BindIP`) is replaced here. The database is never published and the server +//! console is only ever published on the loopback address. + +use std::fs; +use std::net::IpAddr; +use std::path::{Path, PathBuf}; + +use super::{Config, MARKER}; +use crate::config::parser::ConfFile; +use crate::config::{take_snapshot, Scope}; +use crate::error::Result; +use crate::fsx; +use crate::layout::Ports; +use crate::net::Exposure; +use crate::process::{ServiceState, ServiceStatus}; + +fn is_open(cfg: &Config) -> bool { + cfg.bind_address.parse::().map(|ip| !ip.is_loopback()).unwrap_or(false) +} + +/// Whether the login and world servers are published beyond this computer. +pub fn bind_is_open(root: &Path) -> bool { + Config::load(root).map(|c| is_open(&c)).unwrap_or(false) +} + +/// Publish the login and world servers beyond this computer (`0.0.0.0`) or only on it (`127.0.0.1`). An address the owner chose +/// by hand (for example one network card) is kept when opening. Returns true if anything changed, which needs a restart of +/// the containers. The old files are snapshotted. +pub fn set_open(root: &Path, meta: &Path, open: bool) -> Result { + let mut cfg = Config::load(root)?; + let mut originals: Vec<(PathBuf, Vec)> = Vec::new(); + let mut changed = false; + if is_open(&cfg) != open { + originals.push((root.join(MARKER), fs::read(root.join(MARKER))?)); + cfg.bind_address = if open { "0.0.0.0" } else { "127.0.0.1" }.into(); + changed = true; + } + // Friends connect from other addresses, and the server only applies the CoA client protocol to loopback unless remote clients + // are allowed; opening switches that on (closing leaves it, it only matters while the servers listen beyond this computer). + let coa_conf = root.join("Core/configs/modules/coa.conf"); + let mut coa_edit = None; + if open { + if let Ok(bytes) = fs::read(&coa_conf) { + let mut conf = ConfFile::parse_bytes(&bytes)?; + if conf.get("CoA.AllowRemoteClients").map(str::trim) != Some("1") { + conf.set("CoA.AllowRemoteClients", "1", &["Set by CoA Server Manager together with the friends mode"]); + originals.push((coa_conf.clone(), bytes)); + coa_edit = Some(conf.to_text()); + } + } + } + if !changed && coa_edit.is_none() { + return Ok(false); + } + take_snapshot(meta, Scope::Server, if open { "before opening the servers to friends" } else { "before closing the servers to friends" }, &originals)?; + if changed { + fsx::atomic_write_json(&root.join(MARKER), &cfg)?; + } + let remote_changed = coa_edit.is_some(); + if let Some(text) = coa_edit { + fsx::atomic_write(&coa_conf, text.as_bytes())?; + } + Ok(changed || remote_changed) +} + +/// What each service can be reached from, computed from how the containers publish their ports: the database is never published, +/// the console only on the loopback address, the game servers on `bindAddress`. +pub fn exposure(root: &Path, ports: &Ports) -> Vec { + let open = bind_is_open(root); + let observed = crate::process::observe(root, ports); + let up = |s: &ServiceStatus| matches!(s.state, ServiceState::Running | ServiceState::Starting); + vec![ + Exposure { port: ports.mysql, what: "database", listening: up(&observed.mysql), reachable_from_network: false }, + Exposure { port: ports.ra, what: "server console", listening: up(&observed.world), reachable_from_network: false }, + Exposure { port: ports.auth, what: "login server", listening: up(&observed.auth), reachable_from_network: open && up(&observed.auth) }, + Exposure { port: ports.world, what: "game world", listening: up(&observed.world), reachable_from_network: open && up(&observed.world) }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn server(bind: Option<&str>) -> (tempfile::TempDir, PathBuf, PathBuf) { + let d = tempfile::tempdir().unwrap(); + let root = d.path().join("srv"); + let meta = d.path().join("srv.manager"); + fs::create_dir_all(root.join("Settings")).unwrap(); + fs::create_dir_all(root.join("Core/configs/modules")).unwrap(); + fs::create_dir_all(&meta).unwrap(); + let extra = bind.map(|b| format!(r#","bindAddress":"{b}""#)).unwrap_or_default(); + fs::write(root.join(MARKER), format!(r#"{{"project":"t1"{extra}}}"#)).unwrap(); + fs::write(root.join("Core/configs/modules/coa.conf"), "CoA.Enable = 1\n").unwrap(); + (d, root, meta) + } + + #[test] + fn opening_and_closing_changes_the_published_address_and_allows_remote_clients() { + let (_d, root, meta) = server(None); + assert!(!bind_is_open(&root)); + assert!(set_open(&root, &meta, true).unwrap(), "a restart is needed"); + assert!(bind_is_open(&root)); + assert_eq!(Config::load(&root).unwrap().bind_address, "0.0.0.0"); + assert!(fs::read_to_string(root.join("Core/configs/modules/coa.conf")).unwrap().contains("CoA.AllowRemoteClients = 1")); + assert!(!set_open(&root, &meta, true).unwrap(), "nothing left to change"); + assert!(set_open(&root, &meta, false).unwrap()); + assert!(!bind_is_open(&root)); + assert_eq!(Config::load(&root).unwrap().bind_address, "127.0.0.1"); + } + + #[test] + fn enabling_remote_client_protocol_requires_restart_even_when_ports_are_open() { + let (_d, root, meta) = server(Some("0.0.0.0")); + fs::write(root.join("Core/configs/modules/coa.conf"), "CoA.Enable = 1\nCoA.AllowRemoteClients = 0\n").unwrap(); + assert!(set_open(&root, &meta, true).unwrap()); + assert_eq!(Config::load(&root).unwrap().bind_address, "0.0.0.0"); + assert!(!set_open(&root, &meta, true).unwrap()); + } + + #[test] + fn an_address_chosen_by_hand_is_kept_when_opening_and_other_settings_survive() { + let (_d, root, meta) = server(Some("192.168.1.20")); + assert!(bind_is_open(&root), "any address but the loopback is the network"); + set_open(&root, &meta, true).unwrap(); + let cfg = Config::load(&root).unwrap(); + assert_eq!(cfg.bind_address, "192.168.1.20"); + assert_eq!(cfg.project, "t1"); + } + + #[test] + fn the_old_settings_are_snapshotted_before_a_change() { + let (_d, root, meta) = server(None); + set_open(&root, &meta, true).unwrap(); + assert_eq!(crate::config::list_snapshots(&meta).len(), 1); + } + + #[test] + fn the_database_and_the_console_are_never_reported_reachable() { + let (_d, root, _meta) = server(Some("0.0.0.0")); + let rows = exposure(&root, &Ports::default()); + let by = |what: &str| rows.iter().find(|e| e.what == what).unwrap(); + assert!(!by("database").reachable_from_network && !by("server console").reachable_from_network); + // Nothing runs in this test (docker is not asked to start anything), so nothing is reachable yet. + assert!(rows.iter().all(|e| !e.reachable_from_network)); + } +} diff --git a/crates/coa-core/src/driver.rs b/crates/coa-core/src/driver.rs index 7065d05..711d218 100644 --- a/crates/coa-core/src/driver.rs +++ b/crates/coa-core/src/driver.rs @@ -110,7 +110,12 @@ fn launcher(root: &Path) -> Result<(PathBuf, PathBuf)> { /// Run a launcher verb to completion (blocking; call from a worker thread). pub fn run(root: &Path, verb: Verb) -> Result { - run_inner(root, verb, false) + tracing::info!(?verb, root = %root.display(), "driver: launcher requested"); + let result = run_inner(root, verb, false); + if let Err(error) = &result { + tracing::warn!(?verb, root = %root.display(), %error, "driver: launcher request failed before completion"); + } + result } pub(crate) fn validate_update(root: &Path) -> Result { @@ -120,8 +125,10 @@ pub(crate) fn validate_update(root: &Path) -> Result { fn run_inner(root: &Path, verb: Verb, validating_update: bool) -> Result { let root = fsx::canonicalize_lenient(root)?; let _update_lock = if !validating_update && matches!(verb, Verb::StartAll | Verb::StartWorld) { - Some(crate::update::operation_lock( + Some(crate::update::operation_lock_for( &crate::registry::metadata_dir_for(&root)?, + "starting server", + true, )?) } else { None @@ -176,6 +183,9 @@ fn run_inner(root: &Path, verb: Verb, validating_update: bool) -> Result Result (st.success(), st.code()), None => (false, None), @@ -240,6 +250,22 @@ fn run_inner(root: &Path, verb: Verb, validating_update: bool) -> Result Some(other), } }; + if code == Some(ErrorCode::PortInUse) { + let observed = crate::process::observe(&root, &crate::layout::read_ports(&root)); + for service in [&observed.mysql, &observed.auth, &observed.world] + .into_iter() + .chain(observed.secondary_world.iter()) + { + if let Some(conflict) = &service.conflict { + output.push_str(&format!( + "\nPort {} is used by process {} ({}).", + conflict.port, + conflict.pid, + conflict.exe.as_deref().unwrap_or("executable unavailable") + )); + } + } + } tracing::info!(?verb, ok, ?code, "driver: launcher verb finished"); if !ok && !output.is_empty() { let redacted = crate::diag::redact(&output); @@ -317,7 +343,57 @@ pub(crate) fn discard_damaged_launcher_state(root: &Path) -> Vec { removed } -const LAUNCH_SCRIPT: &str = "import runpy,sys;from pathlib import Path;script=sys.argv[1];sys.path.insert(0,str(Path(script).resolve().parent));sys.argv=sys.argv[1:];runpy.run_path(script,run_name='__main__')"; +/// A lost launcher record must not hide this installation's running MySQL from +/// graceful shutdown. Adopt only the exact executable owning its configured port. +fn recover_mysql_launcher_state(root: &Path, prefer_listener: bool) -> Result<()> { + let observed = crate::process::observe(root, &crate::layout::read_ports(root)); + if observed.mysql.state != crate::process::ServiceState::Running || !observed.mysql.port_ready { + return Ok(()); + } + let Some(pid) = observed.mysql.pid else { + return Ok(()); + }; + let Some(identity) = crate::process::identity(pid) else { + return Ok(()); + }; + let (_, _, executable) = crate::layout::executables(root); + if !crate::process::find_by_exe(&executable) + .iter() + .any(|candidate| candidate.same_as(&identity)) + || !crate::process::listeners().contains(&(observed.mysql.port, pid)) + { + return Ok(()); + } + if let Some(saved) = crate::process::read_state_record(root, "mysql") { + if saved.same_as(&identity) { + return Ok(()); + } + if crate::process::is_alive(&saved) { + // A valid record may describe the MySQL supervisor rather than its + // listening child. The launcher can gracefully stop either record. + if crate::process::find_by_exe(&executable) + .iter() + .any(|candidate| candidate.same_as(&saved)) + { + if !prefer_listener { + return Ok(()); + } + } else { + return Err(Error::Invalid("The MySQL launcher record identifies a different live process. No database action was requested.".into())); + } + } + } + let state = root.join(".state/mysql.json"); + let previous = root.join(".state/mysql-before-shutdown-recovery.json"); + if state.is_file() && !previous.exists() { + fsx::atomic_write(&previous, &std::fs::read(&state)?)?; + } + fsx::atomic_write_json(&state, &identity)?; + tracing::warn!(pid, root = %root.display(), "Recovered MySQL launcher identity from the installed executable owning its port"); + Ok(()) +} + +const LAUNCH_SCRIPT: &str = "import runpy,sys\nfrom pathlib import Path\nscript=sys.argv[1]\nsys.path.insert(0,str(Path(script).resolve().parent))\nsys.argv=sys.argv[1:]\nif sys.argv[1]=='stop-all':\n launcher=runpy.run_path(script,run_name='coa_manager_shutdown')\n config=launcher['settings']()\n with launcher['control_lock']():\n launcher['stop_all'](config)\nelse:\n runpy.run_path(script,run_name='__main__')"; pub(crate) fn patch_launcher_imports(text: &str) -> Result { let line = "sys.path.insert(0, str(Path(__file__).resolve().parent))"; @@ -381,20 +457,100 @@ pub(crate) fn startup_failure(root: &Path, started: &DriverOutcome) -> String { .unwrap_or("The server did not become ready"); let output = crate::diag::redact(&started.output); let lines: Vec<_> = output.lines().rev().take(12).collect(); - if lines.is_empty() { + let mut reason = if lines.is_empty() { title.into() } else { format!( "{title}\n{}", lines.into_iter().rev().collect::>().join("\n") ) + }; + for relative in [ + "Core/Logs/world-console.log", + "Core/Logs/auth-console.log", + "mysql/logs/mysql-error.log", + ] { + let tail = crate::health::tail(&root.join(relative), 64 * 1024); + let failures: Vec<_> = tail + .lines() + .filter(|line| { + let lower = line.to_ascii_lowercase(); + crate::health::diagnose(line).is_some() + || lower.contains("fatal") + || lower.contains("error") + || lower.contains("aborting") + }) + .rev() + .take(8) + .collect(); + if !failures.is_empty() { + reason.push_str(&format!( + "\n{relative}:\n{}", + crate::diag::redact(&failures.into_iter().rev().collect::>().join("\n")) + )); + } } + reason } #[cfg(test)] mod tests { use super::*; + #[cfg(windows)] + #[test] + fn shutdown_recovery_never_adopts_a_foreign_port_listener() { + let root = tempfile::tempdir().unwrap(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + fsx::atomic_write_json( + &root.path().join("Settings/repack.json"), + &serde_json::json!({"mysqlPort":listener.local_addr().unwrap().port()}), + ) + .unwrap(); + recover_mysql_launcher_state(root.path(), false).unwrap(); + recover_mysql_launcher_state(root.path(), true).unwrap(); + assert!(!root.path().join(".state/mysql.json").exists()); + assert!(crate::process::identity(std::process::id()).is_some()); + } + + #[test] + fn a_blocked_start_is_logged_before_any_launcher_is_spawned() { + use std::io::Write; + use std::sync::{Arc, Mutex}; + struct Capture(Arc>>); + impl Write for Capture { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("server"); + std::fs::create_dir(&root).unwrap(); + let metadata = crate::registry::metadata_dir_for(&root).unwrap(); + let _maintenance = crate::update::operation_lock(&metadata).unwrap(); + let buffer = Arc::new(Mutex::new(Vec::new())); + let writer = Arc::clone(&buffer); + let subscriber = tracing_subscriber::fmt() + .without_time() + .with_ansi(false) + .with_writer(move || Capture(Arc::clone(&writer))) + .finish(); + let error = tracing::subscriber::with_default(subscriber, || run(&root, Verb::StartAll)) + .unwrap_err(); + assert!(error.to_string().contains("in progress")); + let text = String::from_utf8(buffer.lock().unwrap().clone()).unwrap(); + assert!(text.contains("launcher requested") && text.contains("StartAll")); + assert!( + text.contains("launcher request failed before completion") + && text.contains("in progress") + ); + assert!(!text.contains("starting launcher verb")); + } + #[test] fn background_start_disables_cli_in_template_and_rendered_config() { let folder = tempfile::tempdir().unwrap(); @@ -524,6 +680,24 @@ mod tests { assert!(run(dir.path(), Verb::StartAll).is_err()); } + #[test] + fn startup_failure_reports_late_world_errors_after_launcher_ready() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("Core/Logs")).unwrap(); + std::fs::write(dir.path().join("Core/Logs/world-console.log"), "ERROR [1146] Table 'acore_characters.playerbots_names' doesn't exist\nFATAL Could not prepare statements\n").unwrap(); + let started = DriverOutcome { + ok: true, + code: None, + exit_code: Some(0), + human: None, + output: "Worldserver and automatic bug reporting are ready.".into(), + }; + let reason = startup_failure(dir.path(), &started); + assert!(reason.contains("playerbots_names")); + assert!(reason.contains("Core/Logs/world-console.log")); + assert!(reason.contains("Could not prepare statements")); + } + #[test] fn startup_failure_keeps_the_launcher_reason_without_secrets() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/coa-core/src/error.rs b/crates/coa-core/src/error.rs index 5e12166..08769d8 100644 --- a/crates/coa-core/src/error.rs +++ b/crates/coa-core/src/error.rs @@ -59,6 +59,7 @@ pub enum ErrorCode { OperationInProgress, StartupFailed, DockerUnavailable, + GameDataMismatch, HashMismatch, PathRejected, PackageNotPublished, @@ -142,6 +143,11 @@ impl ErrorCode { "This server runs in Docker. Install Docker, start it, and make sure your user account may use it, then try again.", &[Retry, ShowDetails], ), + ErrorCode::GameDataMismatch => ( + "The game data folder is not the CoA set", + "The server stopped because the DBC files in the game data folder are not the ones of the Conquest of Azeroth client. Choose the folder that holds the CoA data (Settings > Game data folder), then start again.", + &[ShowDetails], + ), ErrorCode::HashMismatch => ( "A downloaded file is damaged", "The file did not match its checksum and was not used.", diff --git a/crates/coa-core/src/friends.rs b/crates/coa-core/src/friends.rs index 0ead9f8..6a6e08e 100644 --- a/crates/coa-core/src/friends.rs +++ b/crates/coa-core/src/friends.rs @@ -93,6 +93,9 @@ fn bind_targets(root: &Path) -> Vec { /// Whether the configuration currently lets other computers reach the login and world servers. pub fn bind_is_open(root: &Path) -> bool { + if crate::docker::is_docker(root) { + return crate::docker::bind_is_open(root); + } let files = bind_targets(root); !files.is_empty() && files.iter().all(|p| { @@ -107,6 +110,9 @@ pub fn bind_is_open(root: &Path) -> bool { /// Open (`0.0.0.0`) or close (`127.0.0.1`) the login and world servers to other computers. The database and the server /// console are never touched. Returns true if anything changed (a restart is then needed). The old files are snapshotted. pub fn set_open(root: &Path, meta: &Path, open: bool) -> Result { + if crate::docker::is_docker(root) { + return crate::docker::set_open(root, meta, open); + } let want = if open { "\"0.0.0.0\"" } else { "\"127.0.0.1\"" }; let mut originals = Vec::new(); let mut edits = Vec::new(); @@ -155,7 +161,8 @@ pub fn set_open(root: &Path, meta: &Path, open: bool) -> Result { Ok(true) } -/// Make the realm list advertise `host` (the launcher resets it to 127.0.0.1 at every start, so this runs after each start). +/// Make the realm list advertise `host` to remote clients (the launcher resets it to 127.0.0.1 at every start, so this +/// runs after each start). pub fn apply_realm_address(root: &Path, host: &str) -> Result<()> { if !host_ok(host) { return Err(Error::Invalid("That address is not valid.".into())); @@ -167,7 +174,9 @@ pub fn apply_realm_address(root: &Path, host: &str) -> Result<()> { } else { format!("id={realm}") }; - db.query(&format!("UPDATE acore_auth.realmlist SET address='{host}', localAddress='{host}' WHERE {where_realms};"))?; + db.query(&format!( + "UPDATE acore_auth.realmlist SET address='{host}' WHERE {where_realms};" + ))?; Ok(()) } diff --git a/crates/coa-core/src/fsx.rs b/crates/coa-core/src/fsx.rs index 9300ee5..40614b2 100644 --- a/crates/coa-core/src/fsx.rs +++ b/crates/coa-core/src/fsx.rs @@ -28,6 +28,19 @@ pub fn normalize_lexical(path: &Path) -> PathBuf { fn key(path: &Path) -> Vec { path.components() .map(|c| { + #[cfg(windows)] + if let Component::Prefix(prefix) = c { + use std::path::Prefix; + match prefix.kind() { + Prefix::Disk(drive) | Prefix::VerbatimDisk(drive) => { + return format!("{}:", char::from(drive).to_ascii_lowercase()); + } + Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => { + return format!("\\\\{}\\{}", server.to_string_lossy(), share.to_string_lossy()).to_lowercase(); + } + _ => {} + } + } let c = c.as_os_str().to_string_lossy(); if cfg!(windows) { c.to_lowercase() @@ -321,6 +334,35 @@ mod tests { } } + #[cfg(windows)] + #[test] + fn containment_matches_verbatim_drive_and_unc_prefixes() { + assert!(starts_with_ci(Path::new(r"\\?\C:\server\Extras\file.sql"), Path::new(r"C:\server"))); + assert!(starts_with_ci(Path::new(r"C:\server\Extras\file.sql"), Path::new(r"\\?\c:\server"))); + assert!(starts_with_ci(Path::new(r"\\?\UNC\host\share\server\file.sql"), Path::new(r"\\host\share\server"))); + assert!(!starts_with_ci(Path::new(r"\\?\D:\server\file.sql"), Path::new(r"C:\server"))); + assert!(!starts_with_ci(Path::new(r"\\?\C:\server-other\file.sql"), Path::new(r"C:\server"))); + assert!(!starts_with_ci(Path::new(r"\\?\UNC\host\other\server\file.sql"), Path::new(r"\\host\share\server"))); + } + + #[cfg(windows)] + #[test] + fn ensure_within_accepts_long_rehearsal_sql_path() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("server"); + fs::create_dir_all(&root).unwrap(); + let mut candidate = root.clone(); + for _ in 0..5 { + candidate.push("long-private-rehearsal-directory-name-20261013"); + } + fs::create_dir_all(&candidate).unwrap(); + candidate.push("2026_06_13_00_ai_playerbot_missing_group_buff_reagent.sql"); + fs::write(&candidate, b"SELECT 1;").unwrap(); + assert!(candidate.as_os_str().len() > 260); + assert!(ensure_within(&root, &candidate).is_ok()); + assert!(ensure_within(&root, &std::fs::canonicalize(candidate).unwrap()).is_ok()); + } + #[test] fn ensure_within_blocks_junction_escape() { if !cfg!(windows) { diff --git a/crates/coa-core/src/health.rs b/crates/coa-core/src/health.rs index 3e1c60a..4c952dc 100644 --- a/crates/coa-core/src/health.rs +++ b/crates/coa-core/src/health.rs @@ -28,6 +28,10 @@ pub fn diagnose(text: &str) -> Option { || l.contains("only one usage of each socket") { Some(ErrorCode::PortInUse) + } else if l.contains("does not hold the coa client dbc set") + || l.contains("_outdated_ dbc data") + { + Some(ErrorCode::GameDataMismatch) } else if l.contains("config::loadfile") && l.contains("failed open file") && l.contains("worldserver.conf") @@ -71,6 +75,13 @@ pub fn diagnose_installation(root: &Path) -> Option { mod tests { use super::*; + #[test] + fn a_game_data_folder_without_the_coa_dbc_set_is_named_as_such() { + let log = "Using DataDir /srv/data/\nSpellAffect.dbc not found in /srv/data/dbc/\nDataDir does not hold the CoA client DBC set. Install it with apps/coa-dbc/client_dbc.py."; + assert_eq!(diagnose(log), Some(ErrorCode::GameDataMismatch)); + assert_eq!(diagnose("You have _outdated_ DBC data. Please extract correct versions from current using client."), Some(ErrorCode::GameDataMismatch)); + } + #[test] fn recognises_missing_migrations() { let log = "ERROR [sql.sql] [1146] Table 'acore_world.coa_boss' doesn't exist\nFATAL [sql.sql] Your database structure is not up to date."; diff --git a/crates/coa-core/src/install.rs b/crates/coa-core/src/install.rs index 8481c1e..4b43da8 100644 --- a/crates/coa-core/src/install.rs +++ b/crates/coa-core/src/install.rs @@ -274,6 +274,10 @@ fn bootstrap_database_inner(root: &Path) -> Result<()> { &target, &serde_json::json!({ "rootPassword": root_pw, "appPassword": app_pw }), )?; + fsx::atomic_write(&root.join("mysql/admin-client.ini"), format!( + "[client]\nuser=root\npassword={root_pw}\nhost=127.0.0.1\nport={}\nprotocol=tcp\ndefault-character-set=utf8mb4\n", + crate::layout::read_ports(root).mysql, + ).as_bytes())?; // The server console gets its own account with a random password instead of a shared default. let console_pw = Db::from_repack(root, Account::Admin)?.provision_service_account()?; // new root password db::write_console_credentials(root, &console_pw)?; diff --git a/crates/coa-core/src/lib.rs b/crates/coa-core/src/lib.rs index a6cf015..e6db8d7 100644 --- a/crates/coa-core/src/lib.rs +++ b/crates/coa-core/src/lib.rs @@ -10,6 +10,7 @@ pub mod accounts; pub mod allsettings; pub mod backup; +pub mod channels; pub mod cleanbase; pub mod client; pub mod clientdl; @@ -38,6 +39,7 @@ pub mod manifest; pub mod migrations; pub mod modules; pub mod multiworld; +mod mysql_snapshot; pub mod net; pub mod package; pub mod pkgsource; @@ -60,6 +62,8 @@ pub mod signing; pub mod squid; pub mod srp6; pub mod update; +mod update_isolation; +pub mod update_route; pub mod upnp; pub mod wine; diff --git a/crates/coa-core/src/manifest.rs b/crates/coa-core/src/manifest.rs index 4033210..0edf72f 100644 --- a/crates/coa-core/src/manifest.rs +++ b/crates/coa-core/src/manifest.rs @@ -78,9 +78,32 @@ 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, + /// Sources exercised during release qualification, not an exclusive updater allowlist. + pub source_versions: Vec, + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub source_databases: std::collections::BTreeMap, +} + #[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, @@ -140,6 +163,26 @@ impl Manifest { 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 @@ -261,6 +304,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/migrations.rs b/crates/coa-core/src/migrations.rs index 124a0ce..f297b79 100644 --- a/crates/coa-core/src/migrations.rs +++ b/crates/coa-core/src/migrations.rs @@ -11,6 +11,7 @@ use crate::fsx; use crate::manifest::Migration; const LEDGER_SCHEMA: &str = "acore_world"; +const AUTH_LEDGER_SCHEMA: &str = "acore_auth"; const LEDGER_TABLE: &str = "coa_manager_migrations"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] @@ -39,6 +40,12 @@ pub trait Store { self.load() } fn put(&self, row: &LedgerRow) -> Result<()>; + fn put_batch(&self, rows: &[LedgerRow]) -> Result<()> { + for row in rows { + self.put(row)?; + } + Ok(()) + } fn run_file(&self, schema: &str, file: &Path) -> Result<()>; } @@ -53,102 +60,206 @@ fn ident_ok(s: &str) -> bool { .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')) } +pub(crate) fn shared_auth(kind: &str) -> bool { + matches!(db::schema_of(kind), Ok("acore_auth")) +} + +fn create_ledger(db: &Db, schema: &str) -> Result<()> { + db.query(&format!("CREATE TABLE IF NOT EXISTS `{schema}`.`{LEDGER_TABLE}` (`db` VARCHAR(32) NOT NULL, `id` VARCHAR(190) NOT NULL, `sha256` CHAR(64) NOT NULL, `status` VARCHAR(16) NOT NULL, `applied_at` DATETIME NOT NULL, `error` TEXT NULL, `baseline` TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (`db`,`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"))?; + Ok(()) +} + impl Store for Db { fn load(&self) -> Result> { - self.query(&format!( - "CREATE TABLE IF NOT EXISTS `{LEDGER_SCHEMA}`.`{LEDGER_TABLE}` (`db` VARCHAR(32) NOT NULL, `id` VARCHAR(190) NOT NULL, `sha256` CHAR(64) NOT NULL, `status` VARCHAR(16) NOT NULL, `applied_at` DATETIME NOT NULL, `error` TEXT NULL, `baseline` TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (`db`,`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;" - ))?; - self.load_readonly() + create_ledger(self, LEDGER_SCHEMA)?; + create_ledger(self, AUTH_LEDGER_SCHEMA)?; + let rows = self.load_readonly()?; + // Preserve already-recorded core history even if a module later replaces its updates table. + // A zero checksum remains unknown; importing it does not invent a checksum for old SQL. + let selected: std::collections::BTreeMap<_, _> = rows + .iter() + .map(|row| ((row.db.clone(), row.id.to_ascii_lowercase()), row)) + .collect(); + let core: Vec<_> = selected + .values() + .filter(|r| { + r.status == Status::Applied + && r.baseline + && r.sha256 == "0".repeat(64) + && matches!(r.db.as_str(), "world" | "characters") + && ident_ok(&r.id) + }) + .map(|r| (*r).clone()) + .collect(); + self.put_batch(&core)?; + Ok(rows) } fn load_readonly(&self) -> Result> { - let out = if self - .tables(LEDGER_SCHEMA)? - .iter() - .any(|name| name == LEDGER_TABLE) - { - self.query(&format!("SELECT `db`,`id`,`sha256`,`status`,`baseline`,COALESCE(HEX(`error`),'') FROM `{LEDGER_SCHEMA}`.`{LEDGER_TABLE}` ORDER BY `db`,`id`;"))? + // Resolve physical schemas once, then read all history in two client calls. + // Using a neutral connection prevents a Wildcard query from rerouting the other realm. + let connection = self.clone().for_realm(crate::realms::Mode::Coa); + let world = self.realm_schema("acore_world"); + let characters = self.realm_schema("acore_characters"); + let other = if self.realm() == crate::realms::Mode::Coa { + "acore_world_wildcard" } else { - String::new() + "acore_world" }; - let mut rows = Vec::new(); - // Files the core's own updater (or whoever prepared this database) already recorded count as applied. - for (kind, schema) in db::SCHEMAS { - if self.tables(schema)?.iter().any(|name| name == "updates") { - let names = self.query(&format!("SELECT name FROM `{schema}`.`updates`;"))?; - for name in names.lines().filter(|n| n.ends_with(".sql")) { - rows.push(LedgerRow { - db: kind.into(), - id: name.trim_end_matches(".sql").into(), - sha256: "0".repeat(64), - status: Status::Applied, - error: None, - baseline: true, - }); - } + let schemas: std::collections::BTreeSet<_> = [world, characters, other, AUTH_LEDGER_SCHEMA] + .into_iter() + .collect(); + let catalog = connection.query(&format!("SELECT table_schema,table_name FROM information_schema.tables WHERE table_schema IN ('{}') AND table_name IN ('updates','{LEDGER_TABLE}') AND table_type='BASE TABLE';", schemas.into_iter().collect::>().join("','")))?; + let available: std::collections::BTreeSet<_> = catalog + .lines() + .filter_map(|line| line.split_once('\t')) + .collect(); + let text = + |expression: &str| format!("CONVERT({expression} USING utf8mb4) COLLATE utf8mb4_bin"); + let mut queries = Vec::new(); + for (kind, schema) in [ + ("characters", characters), + ("auth", AUTH_LEDGER_SCHEMA), + ("world", world), + ] { + if available.contains(&(schema, "updates")) { + queries.push(format!("SELECT 'core','{kind}',{},REPEAT('0',64),'applied',1,'' FROM `{schema}`.`updates` WHERE BINARY RIGHT(name,4)='.sql'",text("LEFT(name,CHAR_LENGTH(name)-4)"))); } } + for (origin, schema, filter) in [ + ("local", world, ""), + ( + "other", + other, + " WHERE `db` IN ('auth','coa-auth','wildcard-auth')", + ), + ("shared", AUTH_LEDGER_SCHEMA, ""), + ] { + if available.contains(&(schema, LEDGER_TABLE)) { + queries.push(format!("SELECT '{origin}',{},{},{},{},`baseline`,{} FROM `{schema}`.`{LEDGER_TABLE}`{filter}",text("`db`"),text("`id`"),text("`sha256`"),text("`status`"),text("COALESCE(HEX(`error`),'')"))); + } + } + if queries.is_empty() { + return Ok(vec![]); + } + let out = connection.query(&queries.join(" UNION ALL "))?; + let (mut rows, mut local, mut auth, mut shared) = + (Vec::new(), Vec::new(), Vec::new(), Vec::new()); for line in out.lines().filter(|l| !l.is_empty()) { - let f: Vec<&str> = line.split('\t').collect(); - if f.len() < 5 { + let f: Vec<_> = line.split('\t').collect(); + if f.len() < 6 { return Err(Error::Invalid( "The database migration history is malformed; no SQL was replayed.".into(), )); } - if !matches!(f[3], "applied" | "failed" | "running") - || !ident_ok(f[1]) - || f[2].len() != 64 - || !f[2].chars().all(|c| c.is_ascii_hexdigit()) + // Unrelated legacy filenames cannot match a valid manifest migration ID. + if f[0] == "core" && !ident_ok(f[2]) { + continue; + } + if !matches!(f[4], "applied" | "failed" | "running") + || !ident_ok(f[2]) + || f[3].len() != 64 + || !f[3].chars().all(|c| c.is_ascii_hexdigit()) { return Err(Error::Invalid( "The database migration history is invalid; no SQL was replayed.".into(), )); } - let err = f - .get(5) + let error = f + .get(6) .filter(|h| !h.is_empty()) .and_then(|h| hex::decode(h).ok()) .map(|b| String::from_utf8_lossy(&b).into_owned()); - rows.push(LedgerRow { - db: f[0].into(), - id: f[1].into(), - sha256: f[2].into(), - status: match f[3] { + let row = LedgerRow { + db: f[1].into(), + id: f[2].into(), + sha256: f[3].into(), + status: match f[4] { "applied" => Status::Applied, "running" => Status::Running, _ => Status::Failed, }, - baseline: f[4] == "1", - error: err, - }); + baseline: f[5] == "1", + error, + }; + match f[0] { + "core" => rows.push(row), + "local" if !shared_auth(&row.db) => local.push(row), + "local" | "other" => auth.push(row), + "shared" => shared.push(row), + _ => return Err(Error::Invalid("Unknown migration history source.".into())), + } + } + rows.extend(local); + // An interrupted legacy auth execution must not be hidden by another realm's applied row. + auth.sort_by_key(|r| { + ( + match r.status { + Status::Applied => 0, + Status::Running => 1, + Status::Failed => 2, + Status::Pending => 3, + }, + r.sha256 != "0".repeat(64), + ) + }); + for mut row in auth { + row.db = "auth".into(); + rows.push(row); + } + // New auth records live with the shared account database and are authoritative. + for mut row in shared { + if !shared_auth(&row.db) { + return Err(Error::Invalid( + "The shared authentication migration history is invalid.".into(), + )); + } + rows.retain(|r| !(shared_auth(&r.db) && r.id.eq_ignore_ascii_case(&row.id))); + row.db = "auth".into(); + rows.push(row); } Ok(rows) } - fn put(&self, r: &LedgerRow) -> Result<()> { - if !ident_ok(&r.id) - || !ident_ok(&r.db) - || r.sha256.len() != 64 - || !r.sha256.chars().all(|c| c.is_ascii_hexdigit()) - { - return Err(Error::Invalid( - "migration record has an invalid identifier".into(), + fn put(&self, row: &LedgerRow) -> Result<()> { + self.put_batch(std::slice::from_ref(row)) + } + + fn put_batch(&self, rows: &[LedgerRow]) -> Result<()> { + let mut groups = std::collections::BTreeMap::<&str, Vec>::new(); + for r in rows { + if !ident_ok(&r.id) + || !ident_ok(&r.db) + || r.sha256.len() != 64 + || !r.sha256.chars().all(|c| c.is_ascii_hexdigit()) + { + return Err(Error::Invalid( + "migration record has an invalid identifier".into(), + )); + } + let status = match r.status { + Status::Applied => "applied", + Status::Running => "running", + _ => "failed", + }; + let error = r + .error + .as_deref() + .map(|e| format!("UNHEX('{}')", hex_of(e))) + .unwrap_or_else(|| "NULL".into()); + let (schema, kind) = if shared_auth(&r.db) { + (AUTH_LEDGER_SCHEMA, "auth") + } else { + (LEDGER_SCHEMA, r.db.as_str()) + }; + groups.entry(schema).or_default().push(format!( + "('{}','{}','{}','{status}',NOW(),{error},{})", + kind, r.id, r.sha256, r.baseline as u8 )); } - let status = match r.status { - Status::Applied => "applied", - Status::Running => "running", - _ => "failed", - }; - let err = r - .error - .as_deref() - .map(|e| format!("UNHEX('{}')", hex_of(e))) - .unwrap_or_else(|| "NULL".into()); - self.query(&format!( - "REPLACE INTO `{LEDGER_SCHEMA}`.`{LEDGER_TABLE}` (`db`,`id`,`sha256`,`status`,`applied_at`,`error`,`baseline`) VALUES ('{}','{}','{}','{status}',NOW(),{err},{});", - r.db, r.id, r.sha256, r.baseline as u8 - ))?; + for (schema, values) in groups { + self.query(&format!("REPLACE INTO `{schema}`.`{LEDGER_TABLE}` (`db`,`id`,`sha256`,`status`,`applied_at`,`error`,`baseline`) VALUES {};",values.join(",")))?; + } Ok(()) } @@ -197,21 +308,47 @@ pub fn status(store: &dyn Store, list: &[Migration]) -> Result> { .map(|m| { // Manager records are appended after the core's updates table and are authoritative. let row = recorded(&rows, m); - let changed = row.is_some_and(|r| hash_changed(r, m)); + let history = effective_history(&rows, m); + let changed = history.iter().any(|r| hash_changed(r,m)); + let interrupted = history.iter().find(|r| matches!(r.status, Status::Running | Status::Failed)); Item { id: m.id.clone(), db: m.db.clone(), - status: if changed || row.is_some_and(|r| r.status == Status::Running) { Status::Failed } else { row.map(|r| r.status).unwrap_or(Status::Pending) }, + status: if changed || interrupted.is_some() { Status::Failed } else { row.map(|r| r.status).unwrap_or(Status::Pending) }, destructive: m.destructive, baseline: row.map(|r| r.baseline).unwrap_or(false), - error: if changed { Some("An already applied SQL file changed. Publish a new migration instead of replaying it.".into()) } else { row.and_then(|r| r.error.clone()) }, + error: if changed { Some("An already applied SQL file changed. Publish a new migration instead of replaying it.".into()) } else { interrupted.and_then(|r| r.error.clone()).or_else(|| row.and_then(|r| r.error.clone())) }, } }) .collect()) } fn recorded<'a>(rows: &'a [LedgerRow], m: &Migration) -> Option<&'a LedgerRow> { - rows.iter().rev().find(|r| r.db == m.db && r.id == m.id) + rows.iter().rev().find(|r| { + (r.db == m.db || shared_auth(&r.db) && shared_auth(&m.db)) + && r.id.eq_ignore_ascii_case(&m.id) + }) +} + +fn effective_history<'a>(rows: &'a [LedgerRow], m: &Migration) -> Vec<&'a LedgerRow> { + if shared_auth(&m.db) { + rows.iter() + .filter(|r| shared_auth(&r.db) && r.id.eq_ignore_ascii_case(&m.id)) + .collect() + } else { + recorded(rows, m).into_iter().collect() + } +} + +fn preserve_auth_history(store: &dyn Store, rows: &[LedgerRow], list: &[Migration]) -> Result<()> { + let applied: Vec<_> = list + .iter() + .filter(|m| shared_auth(&m.db)) + .filter_map(|m| recorded(rows, m)) + .filter(|r| r.status == Status::Applied) + .cloned() + .collect(); + store.put_batch(&applied) } /// Migrations that were published with different bytes but have the same effect, so a server that applied either @@ -227,7 +364,7 @@ const SAME_EFFECT: &[(&str, &[&str])] = &[( fn same_effect(id: &str, a: &str, b: &str) -> bool { SAME_EFFECT.iter().any(|(known, hashes)| { - *known == id + known.eq_ignore_ascii_case(id) && hashes.iter().any(|h| h.eq_ignore_ascii_case(a)) && hashes.iter().any(|h| h.eq_ignore_ascii_case(b)) }) @@ -251,11 +388,7 @@ pub fn preflight(store: &dyn Store, list: &[Migration]) -> Result<()> { pub fn pending_count(store: &dyn Store, list: &[Migration]) -> Result { let rows = store.load_readonly()?; - check_history(&rows, list)?; - Ok(list - .iter() - .filter(|m| recorded(&rows, m).is_none_or(|r| r.status != Status::Applied)) - .count()) + Ok(pending_migrations(&rows, list)?.len()) } fn verified_file(m: &Migration, dir: &Path) -> Result<(&'static str, std::path::PathBuf, bool)> { @@ -286,26 +419,51 @@ fn verified_file(m: &Migration, dir: &Path) -> Result<(&'static str, std::path:: /// Check every migration artifact, including applied history, before replacing server files. pub fn verify_files(list: &[Migration], dir: &Path) -> Result<()> { - let mut seen = std::collections::BTreeSet::new(); + validate_list(list)?; for m in list { - if !seen.insert((&m.db, &m.id)) { - return Err(Error::InvalidManifest(format!( - "duplicate migration {} ({})", - m.id, m.db - ))); - } verified_file(m, dir)?; } Ok(()) } -fn check_history(rows: &[LedgerRow], list: &[Migration]) -> Result<()> { +fn validate_list(list: &[Migration]) -> Result<()> { + let mut seen = std::collections::BTreeSet::new(); for m in list { - if recorded(rows, m).is_some_and(|r| matches!(r.status, Status::Running | Status::Failed)) { - return Err(Error::Invalid(format!("Database update {} ({}) failed or was interrupted and may be partially applied. Restore its recovery point before retrying.", m.id, m.db))); + if !ident_ok(&m.id) { + return Err(Error::InvalidManifest(format!( + "migration id {:?} is not allowed", + m.id + ))); } - if recorded(rows, m).is_some_and(|r| hash_changed(r, m)) { - return Err(Error::Invalid(format!("Applied database update {} ({}) has a different checksum. A new migration is required; it was not replayed.", m.id, m.db))); + let schema = db::schema_of(&m.db)?; + if !seen.insert((schema, m.id.to_ascii_lowercase())) { + return Err(Error::InvalidManifest(format!( + "duplicate migration {} ({})", + m.id, m.db + ))); + } + } + Ok(()) +} + +fn pending_migrations<'a>(rows: &[LedgerRow], list: &'a [Migration]) -> Result> { + check_history(rows, list)?; + Ok(list + .iter() + .filter(|m| recorded(rows, m).is_none_or(|r| r.status != Status::Applied)) + .collect()) +} + +fn check_history(rows: &[LedgerRow], list: &[Migration]) -> Result<()> { + validate_list(list)?; + for m in list { + for row in effective_history(rows, m) { + if matches!(row.status, Status::Running | Status::Failed) { + return Err(Error::Invalid(format!("Database update {} ({}) failed or was interrupted and may be partially applied. Restore its recovery point before retrying.", m.id, m.db))); + } + if hash_changed(row, m) { + return Err(Error::Invalid(format!("Applied database update {} ({}) has a different checksum. A new migration is required; it was not replayed.", m.id, m.db))); + } } } Ok(()) @@ -315,12 +473,11 @@ fn check_history(rows: &[LedgerRow], list: &[Migration]) -> Result<()> { /// was brought up to date by hand). pub fn baseline(store: &dyn Store, list: &[Migration]) -> Result { let rows = store.load()?; + check_history(&rows, list)?; + preserve_auth_history(store, &rows, list)?; let mut n = 0; for m in list { - if rows - .iter() - .any(|r| r.db == m.db && r.id == m.id && r.status == Status::Applied) - { + if recorded(&rows, m).is_some_and(|r| r.status == Status::Applied) { continue; } store.put(&LedgerRow { @@ -353,11 +510,7 @@ pub fn apply_pending( ) -> Result { let rows = store.load()?; // The list order is the release's order of application (released updates, then pending, then modules). - check_history(&rows, list)?; - let todo: Vec<&Migration> = list - .iter() - .filter(|m| recorded(&rows, m).is_none_or(|r| r.status != Status::Applied)) - .collect(); + let todo = pending_migrations(&rows, list)?; tracing::info!( total = list.len(), pending = todo.len(), @@ -380,6 +533,7 @@ pub fn apply_pending( snapshot: None, }; if files.is_empty() { + preserve_auth_history(store, &rows, list)?; return Ok(report); } if any_destructive { @@ -389,6 +543,7 @@ pub fn apply_pending( )) })?); } + preserve_auth_history(store, &rows, list)?; for (m, schema, path) in files { tracing::info!(migration = %m.id, database = %m.db, "database migration starting"); store.put(&LedgerRow { @@ -572,6 +727,23 @@ mod tests { assert!(store.ran.borrow().is_empty()); } + #[test] + fn every_entry_point_rejects_duplicate_sql_before_execution() { + let (dir, original) = setup(&[("world", "once", "SELECT 1;\n")]); + for alias in ["world", "coa-world"] { + let mut list = original.clone(); + let mut duplicate = original[0].clone(); + duplicate.db = alias.into(); + list.push(duplicate); + let store = Mem::default(); + assert!(preflight(&store, &list).is_err()); + assert!(pending_count(&store, &list).is_err()); + assert!(apply_pending(&store, &list, dir.path(), &no_snapshot).is_err()); + assert!(store.ran.borrow().is_empty()); + assert!(store.rows.borrow().is_empty()); + } + } + #[test] fn staged_files_reject_missing_corrupt_duplicate_and_invalid_migrations() { let (dir, list) = setup(&[("characters", "repair", "SELECT 1;\n")]); diff --git a/crates/coa-core/src/modules.rs b/crates/coa-core/src/modules.rs index 96ce4f6..26fdab5 100644 --- a/crates/coa-core/src/modules.rs +++ b/crates/coa-core/src/modules.rs @@ -130,7 +130,49 @@ fn conf_name(root: &Path, e: &Entry) -> String { } } +fn installed_module_commits(root: &Path) -> BTreeMap { + let Ok(directory) = crate::registry::metadata_dir_for(root) else { + return Default::default(); + }; + let Ok((_, meta)) = crate::registry::MetaDir::open(&directory) else { + return Default::default(); + }; + let Some(version) = meta.core.version else { + return Default::default(); + }; + let candidates = [ + directory + .join("manifests") + .join(format!("update-{version}.json")), + directory.join("manifests/base.json"), + ]; + for path in candidates { + let Ok(manifest) = crate::fsx::read_json::(&path) else { + continue; + }; + if manifest.get("version").and_then(|value| value.as_str()) != Some(&version) { + continue; + } + if let Some(commits) = manifest + .get("compatibility") + .and_then(|value| value.get("moduleCommits")) + .and_then(|value| value.as_object()) + { + return commits + .iter() + .filter_map(|(name, commit)| { + let commit = commit.as_str()?; + (commit.len() == 40 && commit.bytes().all(|byte| byte.is_ascii_hexdigit())) + .then(|| (name.clone(), commit.chars().take(8).collect())) + }) + .collect(); + } + } + Default::default() +} + fn list_in(cat: &[Entry], root: &Path) -> Vec { + let installed_commits = installed_module_commits(root); let wildcard = crate::realms::state(root) .map(|s| s.active == crate::realms::Mode::Wildcard) .unwrap_or(true); @@ -172,6 +214,18 @@ fn list_in(cat: &[Entry], root: &Path) -> Vec { } else { None }; + let key = match e.id.as_str() { + "companions" => "bots", + "playerbots" => "squid", + "content-scaling" => "scaling", + "custom-races" => "races", + id => id, + }; + let version = if installed { + version.or_else(|| installed_commits.get(key).cloned()) + } else { + None + }; ModuleView { version, compatibility: if blocked { @@ -277,6 +331,17 @@ pub fn set_enabled(root: &Path, meta: &Path, id: &str, on: bool) -> Result<()> { } } } + if !on { + let _ = crate::custom_races::sync_bot_races_config(root, false); + } else { + let allow = + crate::custom_races::is_bot_custom_races_allowed(root).unwrap_or_else(|| { + read(&dir(root).join("coa_custom_races.conf")) + .and_then(|c| c.get("CoACustomRaces.BotsEnable").map(truthy)) + .unwrap_or(false) + }); + let _ = crate::custom_races::sync_bot_races_config(root, allow); + } } set_enabled_in(&catalog(), root, meta, id, on) } @@ -383,27 +448,40 @@ fn settings_in(cat: &[Entry], root: &Path, id: &str) -> Result> { } } } + let bot_custom_races_allowed = if id == "custom-races" { + crate::custom_races::is_bot_custom_races_allowed(root) + } else { + None + }; Ok(effective .entries() - .map(|(k, v)| Setting { - key: k.to_string(), - value: fields + .map(|(k, v)| { + let mut val = fields .get(k) .and_then(|field| field.default_if_missing.as_ref()) .filter(|_| active.get(k).is_none()) .and_then(crate::squid::scalar) - .unwrap_or_else(|| v.to_string()), - default: fields - .get(k) - .and_then(|field| crate::squid::scalar(&field.default)) - .or_else(|| dist.as_ref().and_then(|d| d.get(k)).map(str::to_string)), - field: fields.get(k).cloned(), - doc: dist - .as_ref() - .map(|d| d.doc_for(k)) - .filter(|d| !d.is_empty()) - .unwrap_or_else(|| active.doc_for(k)) - .join(" "), + .unwrap_or_else(|| v.to_string()); + if k == "CoACustomRaces.BotsEnable" { + if let Some(allowed) = bot_custom_races_allowed { + val = if allowed { "1".into() } else { "0".into() }; + } + } + Setting { + key: k.to_string(), + value: val, + default: fields + .get(k) + .and_then(|field| crate::squid::scalar(&field.default)) + .or_else(|| dist.as_ref().and_then(|d| d.get(k)).map(str::to_string)), + field: fields.get(k).cloned(), + doc: dist + .as_ref() + .map(|d| d.doc_for(k)) + .filter(|d| !d.is_empty()) + .unwrap_or_else(|| active.doc_for(k)) + .join(" "), + } }) .collect()) } @@ -508,6 +586,12 @@ fn save_settings_in( changed.push((key.clone(), value.to_string())); } } + if id == "custom-races" { + if let Some(bots_enable) = changes.get("CoACustomRaces.BotsEnable") { + let allow = truthy(bots_enable); + crate::custom_races::sync_bot_races_config(root, allow)?; + } + } if changed.is_empty() { return Ok(Vec::new()); } @@ -567,6 +651,30 @@ mod tests { (root, meta) } + #[test] + fn module_revisions_come_from_the_installed_release_only() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("server"); + fs::create_dir_all(&root).unwrap(); + let mut meta = + crate::registry::InstallMeta::new(crate::registry::InstallKind::Imported, &root); + meta.core.version = Some("2.0.0".into()); + let directory = crate::registry::MetaDir::create(&root, &meta).unwrap(); + let value = serde_json::json!({"version": "2.0.0", "compatibility": {"moduleCommits": {"races": "a".repeat(40), "bots": "b".repeat(40)}}}); + fsx::atomic_write_json(&directory.root.join("manifests/update-2.0.0.json"), &value) + .unwrap(); + assert_eq!( + installed_module_commits(&root) + .get("races") + .map(String::as_str), + Some("aaaaaaaa") + ); + let stale = serde_json::json!({"version": "1.0.0", "compatibility": {"moduleCommits": {"races": "c".repeat(40)}}}); + fsx::atomic_write_json(&directory.root.join("manifests/update-2.0.0.json"), &stale) + .unwrap(); + assert!(installed_module_commits(&root).is_empty()); + } + #[test] fn squid_missing_keys_use_code_defaults_and_saves_enforce_json_ranges() { let d = tempfile::tempdir().unwrap(); @@ -1148,4 +1256,74 @@ mod tests { .enabled ); } + #[test] + fn custom_races_bot_settings_synchronize_playerbots_conf() { + let d = tempfile::tempdir().unwrap(); + let (root, meta) = server(d.path()); + let cat = catalog(); + fs::write( + dir(&root).join("coa_custom_races.conf.dist"), + "CoACustomRaces.Enable = 1\nCoACustomRaces.BotsEnable = 0\n", + ) + .unwrap(); + fs::write( + dir(&root).join("coa_custom_races.conf"), + "CoACustomRaces.Enable = 1\nCoACustomRaces.BotsEnable = 0\n", + ) + .unwrap(); + fs::write( + dir(&root).join("playerbots.conf"), + "AiPlayerbot.Enabled = 1\n", + ) + .unwrap(); + + // 1. Initially CoACustomRaces.BotsEnable is 0 + let s = settings_in(&cat, &root, "custom-races").unwrap(); + assert_eq!( + s.iter() + .find(|s| s.key == "CoACustomRaces.BotsEnable") + .unwrap() + .value, + "0" + ); + + // 2. Enable bots for custom races: should write experimental exclusions to playerbots.conf + let change_on = BTreeMap::from([("CoACustomRaces.BotsEnable".into(), "1".into())]); + assert_eq!( + save_settings_in(&cat, &root, &meta, "custom-races", &change_on).unwrap(), + ["CoACustomRaces.BotsEnable"] + ); + let pb = fs::read_to_string(dir(&root).join("playerbots.conf")).unwrap(); + assert!(pb.contains("AiPlayerbot.ExcludedBotRaces")); + assert!(pb.contains(crate::custom_races::EXCLUDED_BOT_RACES_EXPERIMENTAL)); + + // Settings view now sees "1" + let s = settings_in(&cat, &root, "custom-races").unwrap(); + assert_eq!( + s.iter() + .find(|s| s.key == "CoACustomRaces.BotsEnable") + .unwrap() + .value, + "1" + ); + + // 3. Disable bots for custom races: should write recommended exclusions to playerbots.conf + let change_off = BTreeMap::from([("CoACustomRaces.BotsEnable".into(), "0".into())]); + assert_eq!( + save_settings_in(&cat, &root, &meta, "custom-races", &change_off).unwrap(), + ["CoACustomRaces.BotsEnable"] + ); + let pb = fs::read_to_string(dir(&root).join("playerbots.conf")).unwrap(); + assert!(pb.contains(crate::custom_races::EXCLUDED_BOT_RACES_RECOMMENDED)); + + // Settings view now sees "0" + let s = settings_in(&cat, &root, "custom-races").unwrap(); + assert_eq!( + s.iter() + .find(|s| s.key == "CoACustomRaces.BotsEnable") + .unwrap() + .value, + "0" + ); + } } diff --git a/crates/coa-core/src/mysql_snapshot.rs b/crates/coa-core/src/mysql_snapshot.rs new file mode 100644 index 0000000..2ec2fee --- /dev/null +++ b/crates/coa-core/src/mysql_snapshot.rs @@ -0,0 +1,610 @@ +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) + .map_err(|error| Error::Invalid(format!("Could not stop server {} before copying or restoring MySQL: {error}", root.display())))?; + let status = crate::process::observe(root, &crate::layout::read_ports(root)); + if !outcome.ok || [status.mysql.state, status.auth.state, status.world.state].iter().any(|state| *state != crate::process::ServiceState::Stopped) + || crate::multiworld::is_running(root) { + return Err(Error::Invalid(stop_failure(root, &status, &outcome))); + } + Ok(()) +} + +fn stop_failure(root: &Path, status: &crate::process::Observed, outcome: &crate::driver::DriverOutcome) -> String { + let mut details = vec![format!("The complete server must be stopped before copying or restoring MySQL. Server: {}.", root.display())]; + for service in [&status.mysql, &status.auth, &status.world].into_iter().chain(status.secondary_world.iter()) { + if service.state != crate::process::ServiceState::Stopped { + details.push(format!("{}: {:?}; PID: {}; port: {}.", service.name, service.state, service.pid.map(|pid| pid.to_string()).unwrap_or_else(|| "unknown".into()), service.port)); + } + if let Some(conflict) = &service.conflict { + details.push(format!("Port {} is used by process {} ({}).", conflict.port, conflict.pid, conflict.exe.as_deref().unwrap_or("executable unavailable"))); + } + } + let output = crate::diag::redact(&outcome.output); + let lines: Vec<_> = output.lines().rev().take(12).collect(); + if !lines.is_empty() { details.push(lines.into_iter().rev().collect::>().join("\n")); } + else if !outcome.ok { details.push(format!("The stop command failed (exit code: {:?}).", outcome.exit_code)); } + details.join("\n") +} + +pub(crate) fn stop_previous_rehearsals(meta: &Path) -> Result<()> { + let location = meta.join("rehearsals"); + if !location.exists() { return Ok(()); } + 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(()) +} + +// 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::*; + #[test] + fn failed_stop_identifies_the_server_process_and_retains_redacted_launcher_reason() { + use crate::process::{Observed, ServiceStatus, ServiceState}; + let stopped = |name| ServiceStatus { name, state: ServiceState::Stopped, pid: None, port: 0, port_ready: false, conflict: None, uptime_secs: None }; + let status = Observed { mysql: ServiceStatus { state: ServiceState::Stopping, pid: Some(42), port: 3307, ..stopped("mysql") }, auth: stopped("auth"), world: stopped("world"), secondary_world: None }; + let outcome = crate::driver::DriverOutcome { ok: false, exit_code: Some(1), code: None, human: None, output: "MySQL is still stopping; wait before copying the repack.\nrootPassword=hunter2".into() }; + let message = stop_failure(Path::new("private-rehearsal/server"), &status, &outcome); + assert!(message.contains("private-rehearsal") && message.contains("PID: 42") && message.contains("3307")); + assert!(message.contains("MySQL is still stopping")); + assert!(!message.contains("hunter2")); + } + #[cfg(windows)] + #[test] + #[ignore = "requires COA_MYSQL_TEST_BIN pointing to a repack with Runtime/python and Scripts/manage.py"] + fn real_shutdown_with_broken_templates_preserves_database_rows() { + use std::process::{Child, Command, Stdio}; + use std::os::windows::process::CommandExt; + struct Server(Child); + impl Drop for Server { fn drop(&mut self) { let _ = self.0.kill(); let _ = self.0.wait(); } } + let bin = PathBuf::from(std::env::var("COA_MYSQL_TEST_BIN").unwrap()); + let source = bin.parent().unwrap().parent().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("shutdown-fixture"); + copy_verified(&bin, &root.join("mysql/bin"), &inventory(&bin).unwrap()).unwrap(); + copy_verified(&source.join("Runtime/python"), &root.join("Runtime/python"), &inventory(&source.join("Runtime/python")).unwrap()).unwrap(); + fsx::atomic_write(&root.join("Scripts/manage.py"), &fs::read(source.join("Scripts/manage.py")).unwrap()).unwrap(); + let data = root.join("mysql/data"); + let executable = root.join("mysql/bin/mysqld.exe"); + let initialized = Command::new(&executable).args(["--no-defaults", "--initialize-insecure"]) + .arg(format!("--datadir={}", data.display())).creation_flags(0x0800_0000).output().unwrap(); + assert!(initialized.status.success(), "{}", String::from_utf8_lossy(&initialized.stderr)); + let sockets: Vec<_> = (0..4).map(|_| std::net::TcpListener::bind("127.0.0.1:0").unwrap()).collect(); + let ports: Vec<_> = sockets.iter().map(|socket| socket.local_addr().unwrap().port()).collect(); + fsx::atomic_write_json(&root.join("Settings/repack.json"), &serde_json::json!({"mysqlPort":ports[0],"authPort":ports[1],"worldPort":ports[2],"raPort":ports[3]})).unwrap(); + drop(sockets); + fsx::atomic_write_json(&root.join("Settings/database.json"), &serde_json::json!({"rootPassword":"","appPassword":""})).unwrap(); + fsx::atomic_write(&root.join("mysql/admin-client.ini"), format!("[client]\nuser=root\nhost=127.0.0.1\nport={}\nprotocol=tcp\n", ports[0]).as_bytes()).unwrap(); + fsx::atomic_write(&root.join("Settings/worldserver.conf.template"), b"WorldDatabaseInfo = @UNKNOWN_DATABASE@\n").unwrap(); + let original = fs::read(root.join("Settings/worldserver.conf.template")).unwrap(); + let db = crate::db::Db::from_repack(&root, crate::db::Account::Admin).unwrap(); + let start = || { + let server = Server(Command::new(&executable).args(["--no-defaults", "--bind-address=127.0.0.1", "--mysqlx=0", "--skip-log-bin"]) + .arg(format!("--datadir={}", data.display())).arg(format!("--port={}", ports[0])) + .arg(format!("--log-error={}", root.join("mysql-test.log").display())) + .stdout(Stdio::null()).stderr(Stdio::null()).creation_flags(0x0800_0000).spawn().unwrap()); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while !db.ping() { assert!(std::time::Instant::now() < deadline); std::thread::sleep(std::time::Duration::from_millis(200)); } + fsx::atomic_write_json(&root.join(".state/mysql.json"), &crate::process::identity(server.0.id()).unwrap()).unwrap(); + server + }; + let mut server = start(); + db.query("CREATE DATABASE acore_characters; CREATE TABLE acore_characters.shutdown_probe(id INT PRIMARY KEY); INSERT INTO acore_characters.shutdown_probe VALUES(73);").unwrap(); + db.query("CREATE DATABASE acore_auth; CREATE DATABASE acore_world; CREATE VIEW acore_characters.shutdown_view AS SELECT id FROM acore_characters.shutdown_probe;").unwrap(); + let copied_configuration = serde_json::json!({"root":temp.path().join("original-installation"),"ports":{"mysqlPort":ports[0],"authPort":ports[1],"worldPort":ports[2],"raPort":ports[3]}}); + fsx::atomic_write_json(&root.join(".state/configuration.json"), &copied_configuration).unwrap(); + let meta = crate::registry::metadata_dir_for(&root).unwrap(); + crate::registry::MetaDir::create(&root, &crate::registry::InstallMeta::new(crate::registry::InstallKind::New, &root)).unwrap(); + let point = crate::backup::create(&root, &meta, crate::backup::Kind::Full, crate::backup::Trigger::BeforeRepair, None, &|_| {}).unwrap(); + assert!(point.mysql_snapshot.is_some()); + assert!(crate::backup::verify(&meta, &point.id).unwrap().ok); + crate::update::RepackEnv { root: &root, meta_dir: &meta }.verify_recovery_point(&point).unwrap(); + assert!(server.0.wait().unwrap().success()); + assert_eq!(fs::read(root.join("Settings/worldserver.conf.template")).unwrap(), original); + assert_eq!(fsx::read_json::(&root.join(".state/configuration.json")).unwrap(), copied_configuration); + fs::remove_file(root.join(".state/configuration.json")).unwrap(); + let mut restarted = start(); + assert_eq!(db.query("SELECT id FROM acore_characters.shutdown_probe;").unwrap(), "73"); + fsx::atomic_write_json(&root.join(".state/mysql.json"), &crate::process::identity(std::process::id()).unwrap()).unwrap(); + assert!(stop(&root).unwrap_err().to_string().contains("different live process")); + assert_eq!(db.query("SELECT id FROM acore_characters.shutdown_probe;").unwrap(), "73"); + fs::remove_file(root.join("Settings/worldserver.conf.template")).unwrap(); + fs::remove_file(root.join(".state/mysql.json")).unwrap(); + let observed = crate::process::observe(&root, &crate::layout::read_ports(&root)); + assert_eq!(observed.mysql.state, crate::process::ServiceState::Running); + assert!(observed.mysql.port_ready); + assert!(crate::process::listeners().contains(&(ports[0], observed.mysql.pid.unwrap()))); + stop(&root).unwrap(); + assert!(restarted.0.wait().unwrap().success()); + let mut third = start(); + assert_eq!(db.query("SELECT id FROM acore_characters.shutdown_view;").unwrap(), "73"); + db.query("UPDATE acore_characters.shutdown_probe SET id=74;").unwrap(); + fsx::atomic_write(&root.join(".state/mysql.json"), b"{truncated").unwrap(); + stop(&root).unwrap(); + assert!(third.0.wait().unwrap().success()); + assert_eq!(fs::read(root.join(".state/mysql-before-shutdown-recovery.json")).unwrap(), b"{truncated"); + let point_dir = crate::backup::point_json(&meta, &point.id).unwrap().parent().unwrap().to_path_buf(); + let retained = swap(&root, &meta, &point_dir, point.mysql_snapshot.as_ref().unwrap(), false).unwrap(); + assert!(retained.is_dir()); + let mut recovered = start(); + assert_eq!(db.query("SELECT id FROM acore_characters.shutdown_view;").unwrap(), "73"); + stop(&root).unwrap(); + assert!(recovered.0.wait().unwrap().success()); + // Exercise the public Repair pipeline with real signed packages and SQL, + // rather than only the snapshot and journal helpers. + let mut package_server = start(); + db.query("CREATE TABLE acore_auth.account(id INT PRIMARY KEY, username VARCHAR(40)); INSERT INTO acore_auth.account VALUES(73,'FIXTURE'); CREATE TABLE acore_auth.realmlist(id INT PRIMARY KEY, name VARCHAR(40), address VARCHAR(40), localAddress VARCHAR(40), port INT, flag INT); INSERT INTO acore_auth.realmlist VALUES(1,'AzerothCore','127.0.0.1','127.0.0.1',18085,0); CREATE TABLE acore_characters.characters(guid INT PRIMARY KEY, account INT, name VARCHAR(40), race INT, class INT); INSERT INTO acore_characters.characters VALUES(73,73,'Fixture',7,14); CREATE TABLE acore_world.playercreateinfo(race INT, class INT, PRIMARY KEY(race,class));").unwrap(); + let starts = [1,2,3,4,5,6,7,8,10,11].into_iter().flat_map(|race| (12..=32).map(move |class| format!("({race},{class})"))).collect::>().join(","); + db.query(&format!("INSERT INTO acore_world.playercreateinfo VALUES {starts};")).unwrap(); + let password = "a".repeat(48); + db.query(&format!("ALTER USER 'root'@'localhost' IDENTIFIED BY '{password}';")).unwrap(); + fsx::atomic_write_json(&root.join("Settings/database.json"), &serde_json::json!({"rootPassword":password,"appPassword":"b".repeat(48)})).unwrap(); + fsx::atomic_write(&root.join("Settings/worldserver.conf.template"), b"WorldDatabaseInfo = @WorldDatabaseInfo@\nCharacterDatabaseInfo = @CharacterDatabaseInfo@\nLoginDatabaseInfo = @LoginDatabaseInfo@\n").unwrap(); + fsx::atomic_write(&root.join("Settings/authserver.conf.template"), b"LoginDatabaseInfo = @LoginDatabaseInfo@\n").unwrap(); + fsx::atomic_write(&root.join("Settings/mod_ascension_compat.conf.template"), b"Ascension.DbcPath = @ASCENSION_DBC@\n").unwrap(); + let startup = crate::driver::run(&root, crate::driver::Verb::StartMysql).unwrap(); + assert!(startup.ok, "{}", crate::diag::redact(&startup.output)); + let db = crate::db::Db::from_repack(&root, crate::db::Account::Admin).unwrap(); + crate::schema_check::capture(&db, &root).unwrap(); + assert!(crate::schema_check::check(&db, &root).unwrap().is_empty()); + stop(&root).unwrap(); + assert!(package_server.0.wait().unwrap().success()); + struct StopFixture(PathBuf); + impl Drop for StopFixture { fn drop(&mut self) { let _ = stop(&self.0); } } + let _stop_fixture = StopFixture(root.clone()); + use base64::{engine::general_purpose::STANDARD, Engine}; + use ed25519_dalek::{Signer, SigningKey}; + let key = SigningKey::from_bytes(&[7;32]); + let trusted = STANDARD.encode(key.verifying_key().to_bytes()); + let source = temp.path().join("repair-package-source"); + fsx::atomic_write(&source.join("Core/reference/repair-probe.dll"), b"official").unwrap(); + fsx::atomic_write(&source.join(crate::schema_check::CONTRACT), &fs::read(root.join(crate::schema_check::CONTRACT)).unwrap()).unwrap(); + let build = |kind, version: &str, destination: &Path, migrations| { + let manifest = crate::package::build(&source, destination, &crate::package::BuildOptions { kind, version: version.into(), core_commit: None, built_at: "fixture".into(), part_size: 1024*1024, bots_commit: None, migrations }, &|_| {}).unwrap(); + let bytes = fs::read(destination.join("manifest.json")).unwrap(); + fsx::atomic_write(&destination.join("manifest.json.sig"), STANDARD.encode(key.sign(&bytes).to_bytes()).as_bytes()).unwrap(); + manifest + }; + let base = temp.path().join("repair-base"); + let base_manifest = build(crate::manifest::Kind::Base, "1.0.0", &base, vec![]); + let update = temp.path().join("repair-update"); + let update_manifest = build(crate::manifest::Kind::Update, "2.0.0", &update, vec![]); + fsx::atomic_write_json(&meta.join("manifests/base.json"), &base_manifest).unwrap(); + fsx::atomic_write_json(&meta.join("manifests/update-2.0.0.json"), &update_manifest).unwrap(); + let (_, mut installed) = crate::registry::MetaDir::open(&meta).unwrap(); + installed.core.version = Some("2.0.0".into()); + fsx::atomic_write_json(&meta.join("install.json"), &installed).unwrap(); + fsx::atomic_write(&root.join("Core/reference/repair-probe.dll"), b"broken").unwrap(); + let base_source = crate::pkgsource::Source::Dir(base); + let update_source = crate::pkgsource::Source::Dir(update); + let report = crate::repair::run(&root, &meta, &base_source, &update_source, &trusted, &|_,_| {}).unwrap(); + assert!(report.error.is_none()); + assert!(report.database.iter().all(|check| check.problems.is_empty())); + assert_eq!(fs::read(root.join("Core/reference/repair-probe.dll")).unwrap(), b"official"); + crate::update::ensure_recovered(&meta).unwrap(); + fsx::atomic_write(&root.join("Core/reference/repair-probe.dll"), b"before verification failure").unwrap(); + let error = crate::repair::run(&root, &meta, &base_source, &update_source, &trusted, &|phase,_| { + if phase == "Checking database after repair" { fs::remove_file(root.join("Core/reference/repair-probe.dll")).unwrap(); } + }).unwrap_err(); + assert!(error.to_string().contains("repair")); + assert_eq!(fs::read(root.join("Core/reference/repair-probe.dll")).unwrap(), b"before verification failure"); + crate::update::ensure_recovered(&meta).unwrap(); + let failed_sql = "UPDATE shutdown_probe SET id=75; CREATE TABLE repair_partial(id INT); INVALID SQL;"; + fsx::atomic_write(&source.join("_migrations/characters/repair-failure.sql"), failed_sql.as_bytes()).unwrap(); + let failing = temp.path().join("repair-failing-update"); + let manifest = build(crate::manifest::Kind::Update, "2.0.0", &failing, vec![crate::manifest::Migration { id:"repair-failure".into(), db:"characters".into(), sha256:fsx::sha256_bytes(failed_sql.as_bytes()), compatible_sha256:vec![], destructive:false }]); + fsx::atomic_write_json(&meta.join("manifests/update-2.0.0.json"), &manifest).unwrap(); + fsx::atomic_write(&root.join("Core/reference/repair-probe.dll"), b"before failed repair").unwrap(); + let report = crate::repair::run(&root, &meta, &base_source, &crate::pkgsource::Source::Dir(failing), &trusted, &|_,_| {}).unwrap(); + assert!(report.error.is_some()); + assert_eq!(fs::read(root.join("Core/reference/repair-probe.dll")).unwrap(), b"before failed repair"); + crate::update::ensure_recovered(&meta).unwrap(); + crate::backup::with_database(&root, |db| { + assert_eq!(db.query("SELECT id FROM acore_characters.shutdown_view;")?, "73"); + assert_eq!(db.query("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='acore_characters' AND TABLE_NAME='repair_partial';")?, "0"); + assert_eq!(db.query("SELECT username FROM acore_auth.account WHERE id=73;")?, "FIXTURE"); + Ok(()) + }).unwrap(); + } + + #[cfg(windows)] + #[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(); + // Shared auth must not run again when the active realm changes. + use crate::migrations::Store; + db.query("CREATE DATABASE acore_world_wildcard;").unwrap(); + let migration_dir = root.join("migration-fixture"); + fs::create_dir_all(migration_dir.join("auth")).unwrap(); + fs::create_dir_all(migration_dir.join("world")).unwrap(); + let auth_sql = b"INSERT INTO acore_auth.probe VALUES(45);"; + fs::write(migration_dir.join("auth/shared_once.sql"), auth_sql).unwrap(); + let auth_list = vec![crate::manifest::Migration { id:"shared_once".into(), db:"auth".into(), sha256:fsx::sha256_bytes(auth_sql), destructive:false, compatible_sha256:vec![] }]; + assert_eq!(crate::migrations::apply_pending(&db, &auth_list, &migration_dir, &|| Ok("fixture".into())).unwrap().applied.len(), 1); + let wildcard = db.clone().for_realm(crate::realms::Mode::Wildcard); + assert_eq!(crate::migrations::pending_count(&wildcard, &auth_list).unwrap(), 0, "auth is shared by both realms"); + assert!(crate::migrations::apply_pending(&wildcard, &auth_list, &migration_dir, &|| Ok("fixture".into())).unwrap().applied.is_empty()); + assert_eq!(db.query("SELECT COUNT(*) FROM acore_auth.probe WHERE id=45;").unwrap(), "1"); + let mut failed = crate::migrations::LedgerRow { db:"auth".into(), id:"shared_once".into(), sha256:auth_list[0].sha256.clone(), status:crate::migrations::Status::Failed, error:Some("interrupted fixture".into()), baseline:false }; + db.put(&failed).unwrap(); + assert!(crate::migrations::pending_count(&wildcard, &auth_list).is_err()); + failed.status = crate::migrations::Status::Applied; failed.error = None; db.put(&failed).unwrap(); + + + // Legacy per-world auth records are adopted without replaying their SQL. + let legacy_sql = b"INSERT INTO acore_auth.probe VALUES(46);"; + fs::write(migration_dir.join("auth/legacy_auth.sql"), legacy_sql).unwrap(); + db.query(&format!("INSERT INTO acore_auth.probe VALUES(46); REPLACE INTO acore_world.coa_manager_migrations VALUES('auth','legacy_auth','{}','applied',NOW(),NULL,0);", fsx::sha256_bytes(legacy_sql))).unwrap(); + let legacy_auth = vec![crate::manifest::Migration { id:"legacy_auth".into(), db:"auth".into(), sha256:fsx::sha256_bytes(legacy_sql), destructive:false, compatible_sha256:vec![] }]; + assert_eq!(crate::migrations::pending_count(&wildcard, &legacy_auth).unwrap(), 0); + assert!(crate::migrations::apply_pending(&wildcard, &legacy_auth, &migration_dir, &|| Ok("fixture".into())).unwrap().applied.is_empty()); + assert_eq!(db.query("SELECT COUNT(*) FROM acore_auth.probe WHERE id=46;").unwrap(), "1"); + + // A core history cleanup cannot make previously recorded SQL pending again. + db.query("CREATE TABLE acore_world.updates(name VARCHAR(150) PRIMARY KEY) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; INSERT INTO acore_world.updates VALUES('LEGACY_WORLD.sql'); INSERT INTO acore_world.probe VALUES(99);").unwrap(); + db.load().unwrap(); + db.query("DROP TABLE acore_world.updates;").unwrap(); + let legacy_world_sql = b"INSERT INTO acore_world.probe VALUES(99);"; + fs::write(migration_dir.join("world/legacy_world.sql"), legacy_world_sql).unwrap(); + let legacy_world = vec![crate::manifest::Migration { id:"legacy_world".into(), db:"world".into(), sha256:fsx::sha256_bytes(legacy_world_sql), destructive:false, compatible_sha256:vec![] }]; + assert_eq!(crate::migrations::pending_count(&db, &legacy_world).unwrap(), 0); + assert!(crate::migrations::apply_pending(&db, &legacy_world, &migration_dir, &|| Ok("fixture".into())).unwrap().applied.is_empty()); + assert_eq!(db.query("SELECT COUNT(*) FROM acore_world.probe WHERE id=99;").unwrap(), "1"); + db.query("DELETE FROM acore_world.probe WHERE id=99;").unwrap(); + assert_eq!(db.recovery_objects("acore_characters").unwrap().len(), 4); + db.query("CREATE TABLE acore_characters.coa_squid_pending(path VARCHAR(255), sha256 CHAR(64)); INSERT INTO acore_characters.coa_squid_pending VALUES('playerbots/base/ai_playerbot_texts.sql', REPEAT('a',64));").unwrap(); + let squid_problems = crate::squid::database_problems(&root, crate::realms::Mode::Coa, &|sql| { + assert!(sql.starts_with("SELECT ")); + db.query(sql) + }).unwrap(); + assert_eq!(squid_problems.len(), 1); + assert!(squid_problems[0].detail.contains("interrupted")); + assert_eq!(db.query("SELECT COUNT(*) FROM acore_characters.coa_squid_pending;").unwrap(), "1"); + crate::schema_check::capture(&db, &root).unwrap(); + let hash = fsx::sha256_file(&root.join(crate::schema_check::CONTRACT)).unwrap(); + db.query("ALTER TABLE acore_world.probe ALTER COLUMN id DROP DEFAULT;").unwrap(); + 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()); + } + + + + + + +} diff --git a/crates/coa-core/src/package.rs b/crates/coa-core/src/package.rs index a4369f6..b09ee4b 100644 --- a/crates/coa-core/src/package.rs +++ b/crates/coa-core/src/package.rs @@ -264,6 +264,7 @@ pub fn build( split.close_current()?; let manifest = Manifest { + compatibility: None, schema: crate::manifest::SCHEMA, kind: opts.kind, version: opts.version.clone(), @@ -723,6 +724,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/pkgsource.rs b/crates/coa-core/src/pkgsource.rs index 56d4a09..8cc0501 100644 --- a/crates/coa-core/src/pkgsource.rs +++ b/crates/coa-core/src/pkgsource.rs @@ -14,6 +14,7 @@ use crate::signing; /// Where a package comes from. #[derive(Debug, Clone)] pub enum Source { + Installation(Box), /// `https://.../` base URL holding manifest.json, manifest.json.sig and the parts. Url(String), /// A folder produced by `package::build` plus its `manifest.json.sig` (offline / local installs). @@ -23,6 +24,14 @@ pub enum Source { /// Read a small file (manifest, signature) from the source. pub fn fetch_small(source: &Source, name: &str) -> Result> { match source { + Source::Installation(source) => fetch_small( + source, + &if matches!(name, "manifest.json" | "manifest.json.sig") { + format!("install-{name}") + } else { + name.into() + }, + ), Source::Dir(d) => Ok(fs::read(fsx::safe_join(d, name)?) .map_err(|_| Error::Invalid(format!("{name} was not found in the package folder.")))?), Source::Url(base) => { @@ -56,6 +65,23 @@ pub fn fetch_manifest(source: &Source, trusted_key: &str) -> Result<(Manifest, V let sig = String::from_utf8_lossy(&fetch_small(source, "manifest.json.sig")?).into_owned(); signing::verify(&bytes, &sig, trusted_key)?; let manifest = Manifest::parse(&bytes)?; + if let Source::Installation(release) = source { + let (update, _) = fetch_manifest(release, trusted_key)?; + if manifest.kind != crate::manifest::Kind::Base + || update.kind != crate::manifest::Kind::Update + || manifest.version != update.version + || manifest.core.commit != update.core.commit + || manifest + .bots + .as_ref() + .and_then(|value| value.commit.as_ref()) + != update.bots.as_ref().and_then(|value| value.commit.as_ref()) + { + return Err(Error::InvalidManifest( + "The full installation package does not match the selected server release.".into(), + )); + } + } Ok((manifest, bytes)) } @@ -89,6 +115,7 @@ pub fn fetch_parts( .as_ref() .ok_or_else(|| Error::InvalidManifest("manifest has no archive".into()))?; match source { + Source::Installation(source) => fetch_parts(source, manifest, download_dir, cancel, report), Source::Dir(d) => Ok(d.clone()), Source::Url(base) => { let all: u64 = archive.parts.iter().map(|p| p.size).sum::().max(1); @@ -124,6 +151,48 @@ pub fn fetch_parts( mod tests { use super::*; + #[test] + fn clean_installation_requires_the_signed_full_image_of_the_selected_release() { + use base64::{engine::general_purpose::STANDARD, Engine}; + use ed25519_dalek::{Signer, SigningKey}; + let folder = tempfile::tempdir().unwrap(); + let signing_key = SigningKey::from_bytes(&[23; 32]); + let key = STANDARD.encode(signing_key.verifying_key().to_bytes()); + let source = Source::Installation(Box::new(Source::Dir(folder.path().into()))); + let mut manifest = serde_json::json!({"schema": 1, "kind": "update", "version": "0.261014.1", + "core": {"commit": "a".repeat(40)}, "builtAt": "test", "minManagerVersion": "0.6.16", + "files": [], "migrations": [], "archive": null}); + let write = |name: &str, value: &serde_json::Value| { + let bytes = serde_json::to_vec(value).unwrap(); + fs::write(folder.path().join(name), &bytes).unwrap(); + fs::write( + folder.path().join(format!("{name}.sig")), + STANDARD.encode(signing_key.sign(&bytes).to_bytes()), + ) + .unwrap(); + }; + write("manifest.json", &manifest); + assert!(fetch_manifest(&source, &key).is_err()); + manifest["kind"] = "base".into(); + write("install-manifest.json", &manifest); + assert_eq!( + fetch_manifest(&source, &key).unwrap().0.version, + "0.261014.1" + ); + manifest["version"] = "0.2.0".into(); + write("install-manifest.json", &manifest); + assert!(fetch_manifest(&source, &key).is_err()); + manifest["version"] = "0.261014.1".into(); + manifest["core"]["commit"] = "b".repeat(40).into(); + write("install-manifest.json", &manifest); + assert!(fetch_manifest(&source, &key).is_err()); + fs::write(folder.path().join("install-manifest.json"), b"tampered").unwrap(); + assert!(fetch_manifest(&source, &key) + .unwrap_err() + .to_string() + .contains("signature")); + } + #[test] fn sizes_are_shown_in_binary_units() { assert_eq!(human_bytes(0), "0 B"); diff --git a/crates/coa-core/src/population.rs b/crates/coa-core/src/population.rs index 50efdd2..c796a9b 100644 --- a/crates/coa-core/src/population.rs +++ b/crates/coa-core/src/population.rs @@ -96,15 +96,38 @@ pub fn hardware() -> Hardware { #[cfg(not(windows))] pub fn hardware() -> Hardware { + let (total, free) = std::fs::read_to_string("/proc/meminfo") + .ok() + .and_then(|t| parse_meminfo(&t)) + .unwrap_or((8.0, 4.0)); Hardware { cores: std::thread::available_parallelism() .map(|n| n.get() as u32) .unwrap_or(2), - ram_gb: 8.0, - free_ram_gb: 4.0, + ram_gb: total, + free_ram_gb: free, } } +/// (total, available) memory in GB from the text of `/proc/meminfo`. +#[cfg(not(windows))] +fn parse_meminfo(text: &str) -> Option<(f64, f64)> { + let kb = |key: &str| -> Option { + text.lines().find_map(|l| { + l.strip_prefix(key)? + .trim() + .strip_suffix("kB")? + .trim() + .parse::() + .ok() + }) + }; + Some(( + kb("MemTotal:")? / (1u64 << 20) as f64, + kb("MemAvailable:")? / (1u64 << 20) as f64, + )) +} + #[derive(Debug, Clone, Serialize)] pub struct SizeOption { pub id: &'static str, @@ -151,6 +174,19 @@ pub fn sizes(h: &Hardware) -> Vec { mod tests { use super::*; + #[cfg(not(windows))] + #[test] + fn memory_is_read_from_proc_meminfo() { + let t = "MemTotal: 65536000 kB\nMemFree: 1000000 kB\nMemAvailable: 16777216 kB\nBuffers: 1 kB\n"; + let (total, free) = parse_meminfo(t).unwrap(); + assert!( + (total - 62.5).abs() < 0.01 && (free - 16.0).abs() < 0.01, + "{total} {free}" + ); + assert!(parse_meminfo("nothing useful").is_none()); + assert!(hardware().ram_gb > 0.0); + } + #[test] fn parses_counts_and_never_goes_negative() { let p = parse_counts("12\t9\t400").unwrap(); diff --git a/crates/coa-core/src/process.rs b/crates/coa-core/src/process.rs index 1f5c7f8..bdac5b8 100644 --- a/crates/coa-core/src/process.rs +++ b/crates/coa-core/src/process.rs @@ -334,22 +334,32 @@ fn service_with( port: u16, listen: &[(u16, u32)], ) -> ServiceStatus { - // Prefer the repack's own record when it still matches a live process; otherwise discover by exact exe path. + // Prefer the exact installed executable owning the port; otherwise use a valid record or path discovery. let norm = |p: &Path| p.to_string_lossy().replace('/', "\\"); let expected: Vec = std::iter::once(&exe) .chain(alternatives) .map(|p| norm(p)) .collect(); - let proc = read_state_record(root, name) - .filter(is_alive) - .filter(|r| expected.iter().any(|e| r.exe.eq_ignore_ascii_case(e))) + let owner = listen.iter().find(|(p, _)| *p == port).map(|(_, pid)| *pid); + // MySQL can launch a supervisor and a server with the same executable. The + // listening process is authoritative when its exact installation path matches. + let proc = owner + .and_then(identity) + .filter(|record| { + expected + .iter() + .any(|exe| record.exe.eq_ignore_ascii_case(exe)) + }) + .or_else(|| { + read_state_record(root, name) + .filter(is_alive) + .filter(|r| expected.iter().any(|e| r.exe.eq_ignore_ascii_case(e))) + }) .or_else(|| { std::iter::once(&exe) .chain(alternatives) .find_map(|p| find_by_exe(p).into_iter().next()) }); - - let owner = listen.iter().find(|(p, _)| *p == port).map(|(_, pid)| *pid); let (state, port_ready, conflict) = match (&proc, owner) { (Some(p), Some(o)) if p.pid == o => (ServiceState::Running, true, None), (Some(_), _) => (ServiceState::Starting, false, None), diff --git a/crates/coa-core/src/ra.rs b/crates/coa-core/src/ra.rs index 5068a2c..2523c04 100644 --- a/crates/coa-core/src/ra.rs +++ b/crates/coa-core/src/ra.rs @@ -524,11 +524,14 @@ AC>"; ra.set_account_password("x", "newpass9").is_err(), "the name is validated before anything is sent" ); - assert!(ra + let error = ra .set_account_password("Nobody", "newpass9") .unwrap_err() - .to_string() - .contains("not found")); + .to_string(); + assert!( + error.contains("not found"), + "Unexpected console error: {error}" + ); drop(ra); let _ = h.join(); } diff --git a/crates/coa-core/src/release.rs b/crates/coa-core/src/release.rs index 33fc88e..96010af 100644 --- a/crates/coa-core/src/release.rs +++ b/crates/coa-core/src/release.rs @@ -60,8 +60,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)?; @@ -98,7 +107,11 @@ 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/{}", @@ -199,6 +212,16 @@ 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 @@ -318,7 +341,7 @@ mod tests { "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")) @@ -342,6 +365,43 @@ mod tests { 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 2b8bd4f..aab598e 100644 --- a/crates/coa-core/src/release_schema.rs +++ b/crates/coa-core/src/release_schema.rs @@ -7,10 +7,286 @@ use crate::{ schema_check, Error, Result, }; use std::collections::HashSet; +use std::fs; use std::path::Path; const MARKER: &str = ".release-schema-fixture"; +struct QualificationEnv<'a>(crate::update::RepackEnv<'a>); + +impl crate::update::Env for QualificationEnv<'_> { + fn activate(&self) -> Result<()> { + Ok(()) + } + fn ensure_stopped(&self) -> Result<()> { + self.0.ensure_stopped() + } + fn preflight(&self, manifest: &crate::manifest::Manifest) -> Result<()> { + self.0.preflight(manifest) + } + fn snapshot(&self) -> Result { + self.0.snapshot() + } + fn verify_snapshot(&self, id: &str) -> Result<()> { + self.0.verify_snapshot(id) + } + fn restore_snapshot(&self, id: &str) -> Result<()> { + self.0.restore_snapshot(id) + } + fn migrate( + &self, + manifest: &crate::manifest::Manifest, + staged: &Path, + ) -> Result { + self.0.migrate(manifest, staged) + } + fn validate(&self) -> Result<()> { + self.0.validate() + } + fn validate_recovery(&self) -> Result<()> { + self.0.validate_recovery() + } + fn recover_validation(&self) -> Result<()> { + self.0.recover_validation() + } + fn automatic_rollback(&self) -> bool { + true + } + // Health checks use the normal private validation; qualification never opens game ports after commit. +} + +pub fn qualify_upgrade( + base: &Path, + source: &Path, + candidate: &Path, + fixture: &Path, + trusted_key: &str, +) -> Result { + use crate::update::Env; + if !cfg!(windows) { + return Err(Error::Invalid( + "Manager upgrade qualification requires a Windows fixture.".into(), + )); + } + if fixture.exists() + || !fixture + .file_name() + .is_some_and(|name| name.to_string_lossy().starts_with("coa-schema-fixture-")) + { + return Err(Error::Invalid( + "Upgrade qualification requires a new disposable coa-schema-fixture-* directory." + .into(), + )); + } + let fixture_root = std::path::absolute(fixture)?; + let fixture = fixture_root.as_path(); + let base_root = fsx::canonicalize_lenient(base)?; + let base = base_root.as_path(); + let source_root = fsx::canonicalize_lenient(source)?; + let source = source_root.as_path(); + let candidate_root = fsx::canonicalize_lenient(candidate)?; + let candidate = candidate_root.as_path(); + let metadata = crate::registry::metadata_dir_for(fixture)?; + if metadata.exists() { + return Err(Error::Invalid( + "Upgrade fixture metadata already exists.".into(), + )); + } + let (base_manifest, base_bytes) = + pkgsource::fetch_manifest(&pkgsource::Source::Dir(base.into()), trusted_key)?; + 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", "recovery-backed-database-update", "database-schema", "private-auth-world-startup", "existing-identities", "user-file-preservation"], + })) +} + +pub fn compose_install( + base: &Path, + candidate: &Path, + fixture: &Path, + trusted_key: &str, +) -> Result<()> { + qualify_upgrade(base, base, candidate, fixture, trusted_key)?; + let fixture = fsx::canonicalize_lenient(fixture)?; + let (_, bytes) = + pkgsource::fetch_manifest(&pkgsource::Source::Dir(candidate.into()), trusted_key)?; + let manifest = crate::manifest::Manifest::parse(&bytes)?; + install::with_scratch_ports(&fixture, || { + backup::with_database(&fixture, |db| { + let players = db.query(&format!( + "SELECT COUNT(*) FROM acore_characters.characters WHERE account<>{};", + crate::companions::TEMPLATE_ACCOUNT + ))?; + if players.trim() != "0" || crate::companions::template_count(db)? != 21 { + return Err(Error::Invalid( + "The installation image contains unexpected player characters.".into(), + )); + } + for table in crate::cleanbase::AUTH_PLAYER_TABLES { + if db.tables("acore_auth")?.iter().any(|name| name == table) { + db.query(&format!("DELETE FROM acore_auth.`{table}`;"))?; + } + } + if let Some(problem) = schema_check::check(db, &fixture)?.first() { + return Err(Error::Invalid(format!( + "Installation image schema mismatch: {}.{}", + problem.table, problem.column + ))); + } + Ok(()) + }) + })?; + fs::remove_file(fixture.join("Core/configs/coa-qualification-user.conf"))?; + let path = fixture.join("Settings/repack.json"); + let mut settings: serde_json::Value = fsx::read_json(&path)?; + settings["raPassword"] = "".into(); + fsx::atomic_write_json(&path, &settings)?; + fsx::atomic_write_json( + &fixture.join("Settings/install-source.json"), + &serde_json::json!({ + "version": manifest.version, "core": manifest.core, "bots": manifest.bots, + "updateManifestSha256": fsx::sha256_bytes(&bytes), + }), + )?; + crate::mysql_snapshot::stop(&fixture)?; + fs::remove_file(fixture.join(MARKER))?; + Ok(()) +} + +pub fn qualify_install( + package: &Path, + fixture: &Path, + trusted_key: &str, +) -> Result { + use crate::update::Env; + if !cfg!(windows) + || fixture.exists() + || !fixture + .file_name() + .is_some_and(|name| name.to_string_lossy().starts_with("coa-schema-fixture-")) + { + return Err(Error::Invalid( + "Clean installation qualification requires a new disposable Windows fixture.".into(), + )); + } + let fixture = std::path::absolute(fixture)?; + let package = fsx::canonicalize_lenient(package)?; + let (manifest, bytes) = + pkgsource::fetch_manifest(&pkgsource::Source::Dir(package.clone()), trusted_key)?; + let registry_dir = tempfile::tempdir()?; + let registry = crate::registry::Registry::at(registry_dir.path().join("registry.json")); + let installed = install::install_base( + &install::Params { + source: pkgsource::Source::Dir(package), + dest: fixture.clone(), + trusted_key, + registry: ®istry, + cancel: crate::download::Cancel::default(), + }, + &|_| {}, + )?; + let metadata = crate::registry::metadata_dir_for(&fixture)?; + let env = QualificationEnv(crate::update::RepackEnv { + root: &fixture, + meta_dir: &metadata, + }); + env.validate()?; + env.ensure_stopped()?; + crate::mysql_snapshot::stop(&fixture)?; + if installed.version != manifest.version || crate::update::pending_checked(&metadata)?.is_some() + { + return Err(Error::Invalid( + "Clean installation did not finish at the packaged release.".into(), + )); + } + Ok( + serde_json::json!({ "schema": 1, "result": "passed", "version": installed.version, + "manifestSha256": fsx::sha256_bytes(&bytes), + "checks": ["clean-install", "signed-full-package", "credential-rotation", "schema-contract", "private-auth-world-startup"] }), + ) +} + pub fn collect(core: &Path, bots: Option<&Path>, repairs: Option<&Path>) -> Result> { let mut files = release::collect_core_sql(core)?; if let Some(bots) = bots { @@ -154,9 +430,139 @@ pub fn capture_release(fixture: &Path, tree: &Path, sql: &[SqlFile]) -> Result<( }) } -pub fn verify_package(dir: &Path, trusted_key: &str) -> 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 fixture_root = fsx::canonicalize_lenient(fixture)?; + let fixture = fixture_root.as_path(); + let tree_root = fsx::canonicalize_lenient(tree)?; + let tree = tree_root.as_path(); + let metadata = crate::registry::metadata_dir_for(fixture)?; + if metadata.exists() { + return Err(Error::Invalid( + "Startup fixture already has Manager metadata; use a fresh fixture.".into(), + )); + } + schema_check::require_release_contract(tree)?; + 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(dir.into()), trusted_key)?; + 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 expected = fsx::sha256_file(&tree.join(relative))?; + if fsx::sha256_file(&target)? != expected { + return Err(Error::Invalid( + "Startup candidate copy failed verification.".into(), + )); + } + } + let meta = crate::registry::InstallMeta::new(crate::registry::InstallKind::New, fixture); + crate::registry::MetaDir::create(fixture, &meta)?; + 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<()> { + verify_package_source(dir, &pkgsource::Source::Dir(dir.into()), trusted_key) +} + +pub fn verify_install_package(dir: &Path, trusted_key: &str) -> Result<()> { + verify_package_source( + dir, + &pkgsource::Source::Installation(Box::new(pkgsource::Source::Dir(dir.into()))), + trusted_key, + ) +} + +fn verify_package_source(dir: &Path, source: &pkgsource::Source, trusted_key: &str) -> Result<()> { + let (manifest, _) = pkgsource::fetch_manifest(source, trusted_key)?; if !manifest .files .iter() @@ -178,12 +584,116 @@ 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] + #[ignore = "Requires a composed disposable installation image"] + fn real_full_package_installs_the_current_version_without_an_update_transaction() { + let image = std::path::PathBuf::from( + std::env::var_os("COA_INSTALL_IMAGE_DIR").expect("COA_INSTALL_IMAGE_DIR"), + ); + let provenance: serde_json::Value = + fsx::read_json(&image.join("Settings/install-source.json")).unwrap(); + let root = tempfile::tempdir().unwrap(); + let package = root.path().join("package"); + let options = package::BuildOptions { + kind: Kind::Base, + version: provenance["version"].as_str().unwrap().into(), + core_commit: provenance["core"]["commit"].as_str().map(String::from), + bots_commit: provenance["bots"]["commit"].as_str().map(String::from), + built_at: "qualification".into(), + part_size: package::DEFAULT_PART_SIZE, + migrations: vec![], + }; + package::build(&image, &package, &options, &|_| {}).unwrap(); + let signing_key = SigningKey::generate(&mut rand_core::OsRng); + let bytes = fs::read(package.join("manifest.json")).unwrap(); + fs::write( + package.join("manifest.json.sig"), + STANDARD.encode(signing_key.sign(&bytes).to_bytes()), + ) + .unwrap(); + let trusted_key = STANDARD.encode(signing_key.verifying_key().to_bytes()); + let fixture = root.path().join("coa-schema-fixture-clean-install"); + let report = qualify_install(&package, &fixture, &trusted_key).unwrap(); + assert_eq!(report["version"], provenance["version"]); + assert_eq!(report["result"], "passed"); + let metadata = crate::registry::metadata_dir_for(&fixture).unwrap(); + let (_, installed) = crate::registry::MetaDir::open(&metadata).unwrap(); + assert_eq!( + installed.core.version.as_deref(), + provenance["version"].as_str() + ); + assert!(!metadata + .join("updates") + .read_dir() + .is_ok_and(|mut entries| entries.next().is_some())); + } + + #[test] + fn relative_startup_fixture_targets_replace_the_original_location() { + let directory = tempfile::Builder::new() + .prefix("coa-schema-fixture-path-test-") + .tempdir_in(".") + .unwrap(); + let current = std::env::current_dir().unwrap(); + let relative = directory.path().strip_prefix(¤t).unwrap(); + fsx::atomic_write(&relative.join("Core/worldserver.exe"), b"old").unwrap(); + let root = fsx::canonicalize_lenient(relative).unwrap(); + let target = fsx::ensure_within(&root, &root.join("Core/worldserver.exe")).unwrap(); + fsx::atomic_write(&target, b"candidate").unwrap(); + assert_eq!( + std::fs::read(relative.join("Core/worldserver.exe")).unwrap(), + b"candidate" + ); + assert!(!root.join(relative).join("Core/worldserver.exe").exists()); + } + + #[test] + fn startup_qualification_refuses_real_server_folders_before_writes() { + let directory = tempfile::tempdir().unwrap(); + 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(); @@ -252,6 +762,45 @@ 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/repair.rs b/crates/coa-core/src/repair.rs index a2fed18..83097f8 100644 --- a/crates/coa-core/src/repair.rs +++ b/crates/coa-core/src/repair.rs @@ -51,10 +51,14 @@ pub fn check(root: &Path, dir: &Path) -> Result> { .filter(|m| mode == crate::realms::Mode::Coa || m.db != "auth") .cloned() .collect(); + let mut problems = crate::schema_check::check(&db, root)?; + problems.extend(crate::squid::database_problems(root, mode, &|sql| { + db.query(sql) + })?); checks.push(DatabaseCheck { realm: mode, migrations: crate::migrations::status(&db, &migrations)?, - problems: crate::schema_check::check(&db, root)?, + problems, full_schema: root.join(crate::schema_check::CONTRACT).exists(), }); } @@ -222,59 +226,93 @@ pub fn run( root, dir, crate::backup::Kind::Full, - crate::backup::Trigger::Manual, + crate::backup::Trigger::BeforeRepair, Some("before repair".into()), - &|_| {}, + &|phase| progress(phase, 65), )? .id; env.verify_recovery_point(&crate::backup::get(dir, &backup)?)?; let repair_files: Vec<_> = broken.iter().map(|(file, _)| (*file).clone()).collect(); let mut txn = crate::update::begin_repair(root, dir, &update.version, &backup, &repair_files)?; - let before = dir.join("repairs").join(&backup); - fs::create_dir_all(&before)?; - fsx::require_space( - root, - broken.iter().map(|(e, _)| e.size.saturating_mul(2)).sum(), - )?; - let restored = restore_files(root, &work, &before, &broken)?; - for (entry, _) in &broken { - meta.original_hashes - .insert(entry.path.clone(), entry.sha256.clone()); - } - fsx::atomic_write_json(&dir.join("install.json"), &meta)?; - progress("Repairing pending database updates", 80); - crate::update::repair_migrating(dir, &mut txn)?; - let (applied, error) = match env.migrate(&update, &work.join("update/_migrations")) { - Ok(r) => (r.applied, r.failed.map(|(id, why)| format!("{id}: {why}"))), - Err(e) => (Vec::new(), Some(e.to_string())), - }; - if error.is_some() { - crate::update::finish_repair(root, dir, &mut txn, false, &env)?; + let attempt = (|| -> Result { + let before = dir.join("repairs").join(&backup); + fs::create_dir_all(&before)?; + fsx::require_space( + root, + broken.iter().map(|(e, _)| e.size.saturating_mul(2)).sum(), + )?; + let restored = restore_files(root, &work, &before, &broken)?; + for (entry, _) in &broken { + meta.original_hashes + .insert(entry.path.clone(), entry.sha256.clone()); + } + fsx::atomic_write_json(&dir.join("install.json"), &meta)?; + progress("Repairing pending database updates", 80); + crate::update::repair_migrating(dir, &mut txn)?; + let (applied, error) = match env.migrate(&update, &work.join("update/_migrations")) { + Ok(r) => (r.applied, r.failed.map(|(id, why)| format!("{id}: {why}"))), + Err(e) => (Vec::new(), Some(e.to_string())), + }; + if error.is_some() { + crate::update::finish_repair(root, dir, &mut txn, false, &env)?; + let report = Report { + restored: Vec::new(), + applied: Vec::new(), + backup: backup.clone(), + database: check(root, dir)?, + error, + }; + fsx::atomic_write_json(&before.join("report.json"), &report)?; + return Ok(report); + } + crate::config::create_missing_module_configs(root)?; + progress("Checking database after repair", 95); + let database = check(root, dir)?; let report = Report { - restored: Vec::new(), - applied: Vec::new(), - backup, - database: check(root, dir)?, + restored, + applied, + backup: backup.clone(), + database, error, }; fsx::atomic_write_json(&before.join("report.json"), &report)?; - return Ok(report); + crate::update::finish_repair(root, dir, &mut txn, true, &env)?; + progress("Done", 100); + Ok(report) + })(); + match attempt { + Ok(report) => Ok(report), + Err(error) + if matches!( + txn.state, + crate::update::State::RolledBack | crate::update::State::Failed + ) => + { + Err(error) + } + Err(error) => { + progress("Undoing the failed repair", 95); + let message = match crate::update::finish_repair(root, dir, &mut txn, false, &env) { + Ok(()) => format!("Repair failed: {error}. The original server files and databases were restored from recovery point {backup}."), + Err(recovery) => format!("Repair failed: {error}. Recovery failed: {recovery}. Recovery point {backup} was preserved; resolve the unfinished repair before starting the server."), + }; + let failed = Report { + restored: Vec::new(), + applied: Vec::new(), + backup: backup.clone(), + database: Vec::new(), + error: Some(message.clone()), + }; + if let Err(report_error) = fsx::atomic_write_json( + &dir.join("repairs").join(&backup).join("report.json"), + &failed, + ) { + tracing::warn!(%report_error, "Could not persist failed repair report"); + } + Err(Error::Invalid(message)) + } } - crate::config::create_missing_module_configs(root)?; - progress("Checking database after repair", 95); - let database = check(root, dir)?; - let report = Report { - restored, - applied, - backup, - database, - error, - }; - fsx::atomic_write_json(&before.join("report.json"), &report)?; - crate::update::finish_repair(root, dir, &mut txn, true, &env)?; - progress("Done", 100); - Ok(report) })(); // Only the UUID staging directory created above is discarded; backups are retained. let _ = fs::remove_dir_all(work); diff --git a/crates/coa-core/src/schema_check.rs b/crates/coa-core/src/schema_check.rs index eb724e2..e3cd053 100644 --- a/crates/coa-core/src/schema_check.rs +++ b/crates/coa-core/src/schema_check.rs @@ -93,6 +93,57 @@ pub struct Problem { 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;"))?; @@ -117,6 +168,82 @@ 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))?; @@ -200,7 +327,13 @@ pub fn check(db: &Db, root: &Path) -> Result> { table: table.clone(), column: column.clone(), detail: found - .map(|s| format!("Expected {want}, found {s}")) + .map(|s| { + format!( + "Expected {}, found {}", + describe_signature(want), + describe_signature(s) + ) + }) .unwrap_or_else(|| "Missing column".into()), }); } @@ -269,6 +402,38 @@ 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/squid.rs b/crates/coa-core/src/squid.rs index 874d1f4..f6cbb6d 100644 --- a/crates/coa-core/src/squid.rs +++ b/crates/coa-core/src/squid.rs @@ -1,6 +1,35 @@ use serde::{Deserialize, Serialize}; use std::path::Path; +/// SQUID has its own import ledger, outside the signed package migration ledger. +/// Keep this read-only: a pending base import can contain player data and must not +/// be replayed or cleared merely because the normal schema contract passes. +pub(crate) fn database_problems(root: &Path, mode: crate::realms::Mode, query: &impl Fn(&str) -> crate::Result) -> crate::Result> { + let active = root.join("Core/configs/modules/playerbots.conf"); + let config = if active.is_file() { active } else { root.join("Core/configs/modules/playerbots.conf.dist") }; + let enabled = if config.is_file() { + let conf = crate::config::parser::ConfFile::parse_bytes(&std::fs::read(config)?)?; + !matches!(conf.get("AiPlayerbot.Enabled").unwrap_or("1").trim().trim_matches('"').to_ascii_lowercase().as_str(), "0" | "false" | "no" | "off") + } else { false }; + let mut problems = Vec::new(); + for schema in [mode.schema("characters")?, mode.schema("world")?, "acore_playerbots"] { + let tables: std::collections::BTreeSet<_> = query(&format!("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA='{schema}';"))?.lines().map(str::to_owned).collect(); + if tables.contains("coa_squid_pending") { + for pending in query(&format!("SELECT path FROM `{schema}`.coa_squid_pending;"))?.lines().filter(|path| !path.is_empty()) { + problems.push(crate::schema_check::Problem { database: schema.into(), table: "coa_squid_pending".into(), column: String::new(), detail: format!("SQUID base import was interrupted: {pending}. File repair and package SQL updates cannot complete this import. Restore a verified database backup from before the import; do not delete the pending record or replay base SQL.") }); + } + } + if enabled && schema == mode.schema("characters")? { + for table in ["playerbots_names", "playerbots_guild_names", "playerbots_arena_team_names"] { + if !tables.contains(table) { + problems.push(crate::schema_check::Problem { database: schema.into(), table: table.into(), column: String::new(), detail: "Required SQUID character table is missing. A normal server start provisions it when no interrupted import is present; an interrupted import requires database recovery first.".into() }); + } + } + } + } + Ok(problems) +} + #[derive(Debug, Serialize, PartialEq, Eq)] pub struct Release { pub tag: Option, @@ -203,6 +232,33 @@ pub fn validate(field: &Field, value: &str) -> crate::Result<()> { mod tests { use super::*; #[test] + fn database_check_reports_pending_squid_imports_in_each_realm_without_writes() { + let root = tempfile::tempdir().unwrap(); + for mode in [crate::realms::Mode::Coa, crate::realms::Mode::Wildcard] { + let problems = database_problems(root.path(), mode, &|sql| { + assert!(sql.starts_with("SELECT ")); + if sql.contains("information_schema.TABLES") { Ok("coa_squid_pending".into()) } + else { Ok("playerbots/base/ai_playerbot_texts.sql".into()) } + }).unwrap(); + assert_eq!(problems.len(), 3); + assert_eq!(problems[0].database, mode.schema("characters").unwrap()); + assert!(problems.iter().all(|p| p.detail.contains("interrupted"))); + } + } + + #[test] + fn enabled_squid_requires_character_tables_but_disabled_squid_does_not() { + let root = tempfile::tempdir().unwrap(); + let config = root.path().join("Core/configs/modules/playerbots.conf"); + crate::fsx::atomic_write(&config, b"AiPlayerbot.Enabled = 1\n").unwrap(); + let query = |_: &str| Ok(String::new()); + assert_eq!(database_problems(root.path(), crate::realms::Mode::Wildcard, &query).unwrap().len(), 3); + crate::fsx::atomic_write(&config, b"AiPlayerbot.Enabled = 0\n").unwrap(); + assert!(database_problems(root.path(), crate::realms::Mode::Wildcard, &query).unwrap().is_empty()); + crate::fsx::atomic_write(&config, b"AiPlayerbot.Enabled = 1\n").unwrap(); + assert!(database_problems(root.path(), crate::realms::Mode::Coa, &|_| Ok("playerbots_names\nplayerbots_guild_names\nplayerbots_arena_team_names".into())).unwrap().is_empty()); + } + #[test] fn a_setting_with_choices_only_accepts_one_of_them() { let root = tempfile::tempdir().unwrap(); crate::fsx::atomic_write(&root.path().join("Core/configs/modules/playerbots.conf.settings.json"), br#"{"format":1,"settings":[ diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index 7c0c326..aa702a6 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -87,6 +87,18 @@ fn legacy_databases_started() -> bool { /// 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(()) @@ -94,7 +106,6 @@ pub trait Env { fn verify_snapshot(&self, _id: &str) -> Result<()> { Ok(()) } - /// Create a recovery point for the databases and configuration; returns its id. fn snapshot(&self) -> Result; fn restore_snapshot(&self, id: &str) -> Result<()>; fn migrate(&self, manifest: &Manifest, staged_migrations: &Path) -> Result; @@ -126,7 +137,7 @@ pub fn load(meta: &Path, id: &str) -> Result { } fn read_journal(path: &Path) -> Result { - let txn: Txn = fsx::read_json(path).map_err(|e| { + let mut txn: Txn = fsx::read_json(path).map_err(|e| { Error::Invalid(format!( "Update journal {} cannot be read: {e}. Available recovery copies were preserved.", path.display() @@ -156,6 +167,43 @@ fn read_journal(path: &Path) -> Result { } } let valid_hash = |hash: &str| hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()); + let mut completed_legacy_repair = false; + // Older successful repairs saved Committed without setting done. Recover only that + // specific shape, and only if every repaired file still has its expected bytes. + if txn.state == State::Committed + && txn.id.contains("-repair-") + && txn.message.as_deref() == Some("Repair is in progress.") + && txn.from_version.as_deref() == Some(txn.to_version.as_str()) + && txn.databases_started + && txn.recovery_point.is_some() + && txn.recovery_hashes.contains_key("@install") + && txn.ops.iter().any(|op| !op.done) + && txn.ops.iter().all(|op| { + op.started + && matches!( + (op.action, op.had_previous), + (Action::Create, false) | (Action::Replace, true) + ) + && valid_hash(&op.new_sha256) + }) + { + let meta_dir = path + .parent() + .and_then(Path::parent) + .and_then(Path::parent) + .ok_or_else(|| Error::Invalid("Invalid repair journal location.".into()))?; + let (_, installed) = MetaDir::open(meta_dir)?; + let root = Path::new(&installed.server_path); + if fsx::canonicalize_lenient(&crate::registry::metadata_dir_for(root)?)? + != fsx::canonicalize_lenient(meta_dir)? + { + return Err(Error::Invalid( + "The repair journal does not belong to its installed server.".into(), + )); + } + complete_repair_ops(root, &mut txn)?; + completed_legacy_repair = true; + } let mut seen = std::collections::BTreeSet::new(); for op in &txn.ops { fsx::safe_join(path.parent().unwrap(), &op.path)?; @@ -182,6 +230,15 @@ fn read_journal(path: &Path) -> Result { { fsx::safe_join(path.parent().unwrap(), key)?; } + if completed_legacy_repair { + // Persist the verified completion once: later legitimate updates can change + // these files. Retain the original record for diagnostics and crash recovery. + let original = path.with_file_name("txn-before-repair-completion.json"); + if !original.exists() { + fsx::atomic_write(&original, &fs::read(path)?)?; + } + fsx::atomic_write_json(path, &txn)?; + } Ok(txn) } @@ -246,6 +303,11 @@ 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.", @@ -402,6 +464,8 @@ pub fn plan( pub struct Preview { pub from_version: Option, pub to_version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub latest_version: Option, pub items: Vec, pub conflicts: Vec, pub migrations: usize, @@ -463,16 +527,23 @@ pub fn preview_with( ) -> Result { refuse_for_docker(root)?; let meta_dir = crate::registry::metadata_dir_for(root)?; - let _lock = operation_lock(&meta_dir)?; - ensure_recovered(&meta_dir)?; + let observed_version = MetaDir::open(&meta_dir)?.1.core.version; let (m, _) = fetch_manifest(source, trusted_key)?; + let _lock = operation_lock_for(&meta_dir, "checking updates", true)?; + ensure_recovered(&meta_dir)?; + let (_, current_meta) = MetaDir::open(&meta_dir)?; + let meta = if current_meta.core.version != observed_version { + ¤t_meta + } else { + meta + }; check_manifest(&m)?; reject_downgrade(meta, &m)?; let mut items = plan(root, meta, &m, resolutions, None)?; if items .iter() .any(|item| item.path == "Scripts/manage.py" && item.action != Action::Skip) - && launcher_already_integrated(root, &meta_dir, meta, &m, source)? + && launcher_already_integrated(root, meta, &m)? { if let Some(item) = items .iter_mut() @@ -500,6 +571,7 @@ pub fn preview_with( Ok(Preview { from_version: meta.core.version.clone(), to_version: m.version.clone(), + latest_version: None, conflicts: items .iter() .filter(|i| i.action == Action::Conflict) @@ -520,10 +592,8 @@ pub fn preview_with( fn launcher_already_integrated( root: &Path, - meta_dir: &Path, meta: &InstallMeta, manifest: &Manifest, - source: &Source, ) -> Result { let Some(file) = manifest .files @@ -532,44 +602,43 @@ fn launcher_already_integrated( else { return Ok(false); }; - let actual = sha_if_exists(&root.join(&file.path)); - if actual.as_ref() != meta.original_hashes.get(&file.path) || actual.is_none() { + let Ok(actual) = fs::read(root.join(&file.path)) else { + return Ok(false); + }; + if meta.original_hashes.get(&file.path) != Some(&fsx::sha256_bytes(&actual)) { return Ok(false); } - let cache = meta_dir - .join("manifests") - .join(format!("launcher-{}.py", file.sha256)); - let signed = match fs::read(&cache) { - Ok(bytes) if fsx::sha256_bytes(&bytes) == file.sha256 => bytes, - _ => { - let temp = tempfile::tempdir()?; - let parts = fetch_parts( - source, - manifest, - &temp.path().join("download"), - &Cancel::default(), - &|_, _| {}, - )?; - package::extract(&parts, manifest, &temp.path().join("tree"), &|_, _| {})?; - let bytes = fs::read(temp.path().join("tree").join(&file.path))?; - if fsx::sha256_bytes(&bytes) != file.sha256 { - return Err(Error::Invalid( - "The signed launcher failed verification.".into(), - )); - } - // This derived cache is optional; failure to save it does not change the server. - let _ = fsx::atomic_write(&cache, &bytes); - bytes - } - }; - Ok(std::str::from_utf8(&signed).ok().is_some_and(|signed| { - fs::read(root.join(&file.path)) - .ok() - .is_some_and(|actual| crate::driver::launcher_matches(signed, &actual)) - })) + Ok(crate::driver::launcher_matches_recorded( + &file.sha256, + &actual, + )) } 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 + ))); + } + // The source list records release qualification, not an exclusive upgrade route. + // Database history and target validation decide whether the update can complete. + if meta + .core + .version + .as_deref() + .and_then(crate::manifest::parse_version) + .is_none() + { + return Err(Error::Invalid("The installed server version is unknown. An imported repack needs a verified starting state before updating.".into())); + } + } if let Some(installed) = meta.core.version.as_deref() { if let (Some(have), Some(candidate)) = ( crate::manifest::parse_version(installed), @@ -595,6 +664,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, @@ -741,7 +815,7 @@ pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result { txn.recovery_hashes .insert("@point".into(), fsx::sha256_file(&point_meta)?); } - + save(meta_dir, &txn)?; txn.ops = items .iter() .filter(|i| i.action != Action::Skip) @@ -786,7 +860,11 @@ 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)?; @@ -812,7 +890,20 @@ 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)?; @@ -823,6 +914,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 { @@ -964,6 +1056,7 @@ pub fn retry_validation( &dir.join("tree"), &mut txn, )?; + env.activate()?; Ok(txn) } @@ -976,7 +1069,16 @@ fn fail_after_apply( 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 @@ -990,6 +1092,9 @@ fn fail_after_apply( save(p.meta_dir, txn)?; 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 { @@ -1044,6 +1149,7 @@ fn restore_transaction( )); } env.ensure_stopped()?; + env.recover_validation()?; if txn.databases_started { let id = txn .recovery_point @@ -1069,6 +1175,31 @@ fn apply_ops( meta_dir: &Path, fail_after: Option, ) -> Result<()> { + if fail_after == Some(0) && !txn.ops.is_empty() { + return Err(Error::Invalid("simulated failure".into())); + } + let entries: BTreeMap<_, _> = files + .iter() + .map(|entry| (entry.path.as_str(), entry)) + .collect(); + // Prepare every recovery copy before changing any destination. One durable journal + // can then cover the entire file phase: recovery restores old files even if their + // replacement was not reached, and removes newly created files only by exact hash. + // Rewriting the full journal twice per file makes large navigation updates quadratic. + for op in &txn.ops { + let target = fsx::ensure_within(root, &fsx::safe_join(root, &op.path)?)?; + if op.had_previous { + let keep = fsx::safe_join(before, &op.path)?; + fs::create_dir_all(keep.parent().unwrap())?; + fsx::atomic_write(&keep, &fs::read(&target)?)?; + txn.recovery_hashes + .insert(op.path.clone(), fsx::sha256_file(&keep)?); + } + } + for op in &mut txn.ops { + op.started = true; + } + save(meta_dir, txn)?; let mut completed = 0usize; for i in 0..txn.ops.len() { if fail_after == Some(completed) { @@ -1077,18 +1208,7 @@ fn apply_ops( let (path, action) = (txn.ops[i].path.clone(), txn.ops[i].action); let target = fsx::ensure_within(root, &fsx::safe_join(root, &path)?)?; let staged = fsx::safe_join(tree, &path)?; - let entry = files.iter().find(|f| f.path == path).expect("listed"); - if target.is_file() { - let keep = fsx::safe_join(before, &path)?; - fs::create_dir_all(keep.parent().unwrap())?; - fsx::atomic_write(&keep, &fs::read(&target)?)?; - txn.recovery_hashes - .insert(path.clone(), fsx::sha256_file(&keep)?); - } - // Journal only after the old bytes are safely saved; a crash during backup must not - // turn an untouched file into a rollback operation with a missing saved copy. - txn.ops[i].started = true; - save(meta_dir, txn)?; + let entry = entries.get(path.as_str()).expect("listed"); fs::create_dir_all(target.parent().unwrap())?; match action { Action::Create | Action::Replace => { @@ -1120,10 +1240,9 @@ fn apply_ops( Action::Skip | Action::Conflict => {} } txn.ops[i].done = true; - save(meta_dir, txn)?; completed += 1; } - Ok(()) + save(meta_dir, txn) } /// Put every touched file back the way it was. Files created by the update (no previous version) are removed only if @@ -1177,6 +1296,14 @@ pub fn rollback(root: &Path, meta_dir: &Path, id: &str, env: &dyn Env) -> Result } pub(crate) fn operation_lock(meta_dir: &Path) -> Result { + operation_lock_for(meta_dir, "server maintenance", false) +} + +pub(crate) fn operation_lock_for( + meta_dir: &Path, + operation: &str, + wait_for_check: bool, +) -> Result { fs::create_dir_all(meta_dir)?; let lock = fs::OpenOptions::new() .read(true) @@ -1184,11 +1311,37 @@ pub(crate) fn operation_lock(meta_dir: &Path) -> Result { .create(true) .truncate(false) .open(meta_dir.join("update.lock"))?; - if !fs4::fs_std::FileExt::try_lock_exclusive(&lock)? { - return Err(Error::Invalid( - "Another update, repair or recovery is in progress.".into(), - )); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + if fs4::fs_std::FileExt::try_lock_exclusive(&lock)? { + break; + } + let owner = + fsx::read_json::(&meta_dir.join("update-lock-owner.json")).ok(); + let checking = owner + .as_ref() + .and_then(|owner| owner.get("operation")) + .and_then(|value| value.as_str()) + == Some("checking updates"); + if wait_for_check && checking && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(100)); + continue; + } + let details = owner + .as_ref() + .and_then(|owner| { + Some(format!( + " Operation: {}; Manager process: {}; started: {}.", + owner.get("operation")?.as_str()?, + owner.get("pid")?.as_u64()?, + owner.get("started")?.as_str()? + )) + }) + .unwrap_or_default(); + return Err(Error::Invalid(format!("Another update, repair or recovery is in progress.{details} Wait for that operation to finish, then retry."))); } + let record = serde_json::json!({"operation": operation, "pid": std::process::id(), "started": chrono::Utc::now().to_rfc3339()}); + fsx::atomic_write_json(&meta_dir.join("update-lock-owner.json"), &record)?; Ok(lock) } @@ -1236,6 +1389,10 @@ pub(crate) fn begin_repair( "@install".into(), fsx::sha256_file(&before.join("manager-install.json"))?, ); + let point = crate::backup::point_json(meta_dir, backup)?; + if point.is_file() { + recovery_hashes.insert("@point".into(), fsx::sha256_file(&point)?); + } for op in ops.iter().filter(|op| op.had_previous) { recovery_hashes.insert( op.path.clone(), @@ -1271,13 +1428,23 @@ pub(crate) fn finish_repair( env: &dyn Env, ) -> Result<()> { if !success { - restore_transaction( + if let Err(error) = restore_transaction( root, meta_dir, &txn_dir(meta_dir, &txn.id)?.join("before"), txn, env, - )?; + ) { + txn.state = State::Failed; + txn.message = Some(format!( + "Repair recovery failed: {error}. Its recovery point was preserved." + )); + save(meta_dir, txn)?; + return Err(error); + } + } + if success { + complete_repair_ops(root, txn)?; } txn.state = if success { State::Committed @@ -1287,6 +1454,22 @@ pub(crate) fn finish_repair( save(meta_dir, txn) } +fn complete_repair_ops(root: &Path, txn: &mut Txn) -> Result<()> { + for op in &txn.ops { + let target = fsx::ensure_within(root, &fsx::safe_join(root, &op.path)?)?; + if fsx::sha256_file(&target)? != op.new_sha256 { + return Err(Error::Invalid(format!( + "Repaired file {} failed verification. Its recovery record was preserved.", + op.path + ))); + } + } + for op in &mut txn.ops { + op.done = true; + } + Ok(()) +} + fn prune(meta_dir: &Path, keep: usize) { let Ok(rd) = fs::read_dir(updates_dir(meta_dir)) else { return; @@ -1358,7 +1541,7 @@ impl RepackEnv<'_> { let list: Vec<_> = manifest .migrations .iter() - .filter(|m| index == 0 || m.db != "auth") + .filter(|m| index == 0 || !crate::migrations::shared_auth(&m.db)) .cloned() .collect(); count += crate::migrations::pending_count(&db.clone().for_realm(mode), &list)?; @@ -1383,17 +1566,49 @@ impl RepackEnv<'_> { "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(()) }) @@ -1423,7 +1638,64 @@ impl RepackEnv<'_> { } impl Env for RepackEnv<'_> { + fn recover_validation(&self) -> Result<()> { + crate::update_isolation::restore(self.root, self.meta_dir) + } + + fn activate(&self) -> Result<()> { + ensure_recovered(self.meta_dir)?; + let outcome = crate::driver::validate_update(self.root)?; + if outcome.ok { + Ok(()) + } else { + Err(Error::Invalid(format!( + "The update transaction finished, but the server could not start: {}", + crate::driver::startup_failure(self.root, &outcome) + ))) + } + } + + fn automatic_rollback(&self) -> bool { + true + } fn preflight(&self, manifest: &Manifest) -> Result<()> { + 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; pending migrations and target validation will run with a full recovery point"); + } + } + Ok(()) + })?; + } + } self.pending_migrations(manifest).map(|_| ()) } @@ -1490,6 +1762,16 @@ impl Env for RepackEnv<'_> { "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)?; } @@ -1498,142 +1780,237 @@ impl Env for RepackEnv<'_> { } fn migrate(&self, manifest: &Manifest, staged: &Path) -> Result { - let root = self.root; - crate::backup::with_database(root, |db| { - let realms = crate::realms::state(root)?; - 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); - let shared_snapshot = result.snapshot.clone(); - let migrations: Vec<_> = manifest - .migrations - .iter() - .filter(|m| m.db != "auth") - .cloned() - .collect(); - let extra = - crate::migrations::apply_pending(&other_db, &migrations, staged, &|| { - if let Some(id) = &shared_snapshot { - return Ok(id.clone()); - } - self.migration_snapshot() - })?; - result.applied.extend(extra.applied); - result.failed = extra.failed; - if result.snapshot.is_none() { - result.snapshot = extra.snapshot; - } - if result.failed.is_none() { - if let Some(p) = crate::schema_check::check(&other_db, root)?.first() { - return Err(Error::Invalid(format!( - "Database validation failed on {}: {}.{}: {}", - other.name(), - p.table, - p.column, - p.detail - ))); - } - } - } + crate::backup::with_database(self.root, |db| { + let identities = database_identities(db)?; + let result = migrate_databases(db, self.root, manifest, staged, &|| { + self.migration_snapshot() + })?; if result.failed.is_none() { - let problems = crate::schema_check::check(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() - ))); - } + verify_database_identities(db, &identities)?; } Ok(result) }) } 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<()> { + crate::mysql_snapshot::stop(self.root)?; + self.ensure_stopped()?; + self.recover_validation()?; + let result = (|| -> Result<()> { + crate::update_isolation::begin(self.root, self.meta_dir)?; + use crate::process::{observe, ServiceState}; + let identities = crate::backup::with_database(self.root, |db| { + if check_schema { + let mut modes = vec![crate::realms::Mode::Coa]; + if crate::realms::state(self.root)?.wildcard_created { + modes.push(crate::realms::Mode::Wildcard); + } + for mode in modes { + if let Some(p) = crate::schema_check::check_with_report( + &db.clone().for_realm(mode), + self.root, + )? + .first() + { + return Err(Error::Invalid(format!( + "Database validation failed on {}: {}.{}: {}", + mode.name(), + p.database, + p.table, + p.detail + ))); + } + } + } + database_identities(db) + })?; + // The check start is the first start of the new build: give the module configs the settings the update added + // (as a normal start does), or the server logs a "missing property" line for every one of them. + match crate::registry::MetaDir::open(self.meta_dir) { + 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 { + crate::backup::with_database(self.root, |db| { + verify_database_identities(db, &identities) + })?; + 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)) + })(); + crate::mysql_snapshot::stop(self.root)?; + self.ensure_stopped()?; + self.recover_validation()?; + result + } +} + +/// One migration pass for both realms, with shared history checks and one recovery point. +fn migrate_databases( + db: &crate::db::Db, + root: &Path, + manifest: &Manifest, + staged: &Path, + snapshot: &dyn Fn() -> Result, +) -> Result { + let realms = crate::realms::state(root)?; + let mut modes = vec![realms.active]; + if realms.wildcard_created { + modes.push(if realms.active == crate::realms::Mode::Coa { + crate::realms::Mode::Wildcard + } else { + crate::realms::Mode::Coa + }); + } + // Reject a broken history in either realm before changing the first one. + for (index, mode) in modes.iter().enumerate() { + let list: Vec<_> = manifest + .migrations + .iter() + .filter(|m| index == 0 || !crate::migrations::shared_auth(&m.db)) + .cloned() + .collect(); + crate::migrations::preflight(&db.clone().for_realm(*mode), &list)?; + } + let contract = manifest + .files + .iter() + .find(|file| file.path == crate::schema_check::CONTRACT) + .map(|file| file.sha256.as_str()); + let mut result = ApplyReport { + applied: vec![], + failed: None, + snapshot: None, + }; + if contract.is_some() { + result.snapshot = Some(snapshot()?); + } + for (index, mode) in modes.into_iter().enumerate() { + let db = db.clone().for_realm(mode); + let list: Vec<_> = manifest + .migrations + .iter() + .filter(|m| index == 0 || !crate::migrations::shared_auth(&m.db)) + .cloned() + .collect(); + crate::schema_check::repair_missing_defaults(&db, root, contract)?; + let extra = crate::migrations::apply_pending(&db, &list, staged, &|| { + if let Some(id) = &result.snapshot { + Ok(id.clone()) + } else { + snapshot() + } + })?; + result.applied.extend(extra.applied); + result.failed = extra.failed; + if result.snapshot.is_none() { + result.snapshot = extra.snapshot; + } + if result.failed.is_some() { + return Ok(result); + } + crate::schema_check::repair_missing_defaults(&db, root, contract)?; + if let Some(p) = crate::schema_check::check_with_report(&db, root)?.first() { + return Err(Error::Invalid(format!( + "Database validation failed on {}: {}.{}: {}", + mode.name(), + p.table, + p.column, + p.detail + ))); + } + } + Ok(result) +} + +fn database_identities( + db: &crate::db::Db, +) -> Result>> { + let db = db.clone().for_realm(crate::realms::Mode::Coa); + let mut identities = BTreeMap::new(); + 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 update changed or removed existing character or account identities in {schema}."))); + } + } + Ok(()) } #[cfg(test)] @@ -1646,6 +2023,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, @@ -1658,6 +2040,11 @@ mod tests { impl Fake { fn ok() -> Fake { Fake { + activation_expected: RefCell::new(None), + activation_failed: Cell::new(false), + activated: Cell::new(false), + automatic_rollback: Cell::new(false), + recovery_healthy: Cell::new(true), stopped: Cell::new(true), healthy: Cell::new(true), snapshot_ok: Cell::new(true), @@ -1670,6 +2057,29 @@ mod tests { } impl Env for Fake { + fn activate(&self) -> Result<()> { + if let Some((meta, version)) = self.activation_expected.borrow().as_ref() { + ensure_recovered(meta)?; + assert_eq!(MetaDir::open(meta)?.1.core.version.as_ref(), Some(version)); + } + self.activated.set(true); + if self.activation_failed.get() { + Err(Error::Invalid("Public startup failed".into())) + } else { + Ok(()) + } + } + fn automatic_rollback(&self) -> bool { + self.automatic_rollback.get() + } + fn validate_recovery(&self) -> Result<()> { + self.calls.borrow_mut().push("validate-recovery"); + 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())) @@ -1841,6 +2251,25 @@ mod tests { w } + #[test] + fn startup_waits_for_an_update_check_but_not_for_active_maintenance() { + let temp = tempfile::tempdir().unwrap(); + let checker = operation_lock_for(temp.path(), "checking updates", false).unwrap(); + let root = temp.path().to_path_buf(); + let release = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(150)); + drop(checker); + }); + let start = operation_lock_for(&root, "starting server", true).unwrap(); + release.join().unwrap(); + let error = operation_lock(&root).unwrap_err().to_string(); + assert!(error.contains("starting server")); + assert!(error.contains(&std::process::id().to_string())); + drop(start); + // Stale owner metadata is harmless once the OS lock is released. + operation_lock(&root).unwrap(); + } + #[test] fn legacy_manager_realm_launcher_is_replaced_during_update() { let w = legacy_launcher_world(false); @@ -2007,6 +2436,29 @@ 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"; @@ -2021,6 +2473,12 @@ mod tests { fsx::sha256_bytes(integrated.as_bytes()), ); fsx::atomic_write_json(&w.meta.join("install.json"), &meta).unwrap(); + let original = fetch_manifest(&Source::Dir(w.pkg.clone()), &w.key) + .unwrap() + .0; + for part in &original.archive.as_ref().unwrap().parts { + fs::remove_file(w.pkg.join(&part.name)).unwrap(); + } let p = preview( &w.root, &meta, @@ -2065,14 +2523,7 @@ mod tests { .find(|file| file.path == "Scripts/manage.py") .unwrap() .sha256 = fsx::sha256_bytes(b"new launcher"); - assert!(launcher_already_integrated( - &w.root, - &w.meta, - &meta, - &changed, - &Source::Dir(w.pkg.clone()) - ) - .is_err()); + assert!(!launcher_already_integrated(&w.root, &meta, &changed).unwrap()); write( &w.root, "Scripts/manage.py", @@ -2204,6 +2655,85 @@ mod tests { ); } + #[test] + fn power_loss_during_bulk_files_restores_written_and_unwritten_files() { + let d = tempfile::tempdir().unwrap(); + let root = d.path().join("root"); + let meta = d.path().join("meta"); + let tree = d.path().join("tree"); + let before = meta.join("updates/bulk/before"); + let mut files = Vec::new(); + let mut ops = Vec::new(); + for (path, previous) in [ + ("Data/maps/first.map", true), + ("Data/maps/new.map", false), + ("Data/maps/last.map", true), + ] { + if previous { + write(&root, path, b"old"); + } + write(&tree, path, b"new"); + let hash = fsx::sha256_file(&tree.join(path)).unwrap(); + files.push(FileEntry { + path: path.into(), + sha256: hash.clone(), + size: 3, + policy: ReplacePolicy::Replace, + owner: crate::manifest::Owner::Core, + }); + ops.push(Op { + path: path.into(), + action: if previous { + Action::Replace + } else { + Action::Create + }, + reason: None, + new_sha256: hash, + had_previous: previous, + started: false, + done: false, + }); + } + let mut txn = Txn { + id: "bulk".into(), + state: State::Applying, + from_version: Some("1".into()), + to_version: "2".into(), + recovery_point: None, + databases_started: false, + ops, + message: None, + recovery_hashes: BTreeMap::new(), + }; + fs::create_dir_all(before.parent().unwrap()).unwrap(); + assert!(apply_ops(&root, &tree, &before, &files, &mut txn, &meta, Some(2)).is_err()); + // A power loss sees the pre-write checkpoint, not the in-memory completed flags. + let mut durable = load(&meta, "bulk").unwrap(); + assert!(durable.ops.iter().all(|op| op.started && !op.done)); + assert_eq!(fs::read(root.join("Data/maps/last.map")).unwrap(), b"old"); + restore_files(&root, &before, &mut durable).unwrap(); + assert_eq!(fs::read(root.join("Data/maps/first.map")).unwrap(), b"old"); + assert_eq!(fs::read(root.join("Data/maps/last.map")).unwrap(), b"old"); + assert!(!root.join("Data/maps/new.map").exists()); + // If preparing any recovery copy fails, no destination may be changed. + for op in &mut durable.ops { + op.started = false; + op.done = false; + } + durable.recovery_hashes.clear(); + save(&meta, &durable).unwrap(); + fs::remove_file(root.join("Data/maps/last.map")).unwrap(); + assert!(apply_ops(&root, &tree, &before, &files, &mut durable, &meta, None).is_err()); + assert_eq!(fs::read(root.join("Data/maps/first.map")).unwrap(), b"old"); + assert!(!root.join("Data/maps/new.map").exists()); + assert!(load(&meta, "bulk") + .unwrap() + .ops + .iter() + .all(|op| !op.started)); + } + #[test] fn failure_in_the_middle_of_applying_restores_every_file_byte_for_byte() { for fail_after in [0usize, 1, 2] { @@ -2261,6 +2791,136 @@ mod tests { ); } + #[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 unlisted_source_is_allowed_but_unknown_versions_and_other_platforms_are_rejected() { + let w = world(&[], false); + let (_, meta) = MetaDir::open(&w.meta).unwrap(); + let mut manifest = + Manifest::parse(&fs::read(w.pkg.join("manifest.json")).unwrap()).unwrap(); + 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()]; + validate_candidate(&meta, &manifest).unwrap(); + let mut unknown = meta.clone(); + unknown.core.version = None; + assert!(validate_candidate(&unknown, &manifest) + .unwrap_err() + .to_string() + .contains("unknown")); + unknown.core.version = Some("unverified-repack".into()); + assert!(validate_candidate(&unknown, &manifest).is_err()); + let mut already_updated = meta.clone(); + already_updated.core.version = Some(manifest.version.clone()); + validate_candidate(&already_updated, &manifest).unwrap(); + manifest.compatibility.as_mut().unwrap().source_versions = vec!["1.0.0".into()]; + manifest.compatibility.as_mut().unwrap().platform = if cfg!(windows) { + "linux-x86_64" + } else { + "windows-x86_64" + } + .into(); + assert!(validate_candidate(&meta, &manifest) + .unwrap_err() + .to_string() + .contains("targets")); + assert!(validate_candidate(&already_updated, &manifest) + .unwrap_err() + .to_string() + .contains("targets")); + assert_eq!(read(&w, "Core/worldserver.exe"), b"world-v1"); + assert!(unfinished(&w.meta).is_none()); + } + + #[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); @@ -2481,6 +3141,130 @@ mod tests { assert_eq!(read(&w, "Core/worldserver.exe"), b"world-v1"); } + #[test] + fn legacy_completed_repair_is_accepted_only_with_verified_repaired_files() { + let w = world(&[], false); + let files = vec![FileEntry { + path: "Core/worldserver.exe".into(), + sha256: fsx::sha256_bytes(b"repaired"), + size: 8, + owner: crate::manifest::Owner::Core, + policy: ReplacePolicy::Replace, + }]; + let mut txn = begin_repair(&w.root, &w.meta, "1.0.0", "full-backup", &files).unwrap(); + write(&w.root, "Core/worldserver.exe", b"repaired"); + repair_migrating(&w.meta, &mut txn).unwrap(); + txn.state = State::Committed; + save(&w.meta, &txn).unwrap(); + let original = fs::read(txn_dir(&w.meta, &txn.id).unwrap().join("txn.json")).unwrap(); + write(&w.root, "Core/worldserver.exe", b"changed"); + assert!(ensure_recovered(&w.meta).is_err()); + write(&w.root, "Core/worldserver.exe", b"repaired"); + ensure_recovered(&w.meta).unwrap(); + assert!(load(&w.meta, &txn.id).unwrap().ops[0].done); + assert_eq!( + fs::read( + txn_dir(&w.meta, &txn.id) + .unwrap() + .join("txn-before-repair-completion.json") + ) + .unwrap(), + original + ); + write( + &w.root, + "Core/worldserver.exe", + b"a later legitimate update", + ); + ensure_recovered(&w.meta).unwrap(); + txn.ops[0].started = false; + save(&w.meta, &txn).unwrap(); + assert!(ensure_recovered(&w.meta).is_err()); + } + + #[test] + fn changed_repair_backup_inventory_blocks_recovery_before_stopping_or_restoring() { + let w = world(&[], false); + let point = crate::backup::point_json(&w.meta, "full-backup").unwrap(); + fsx::atomic_write(&point, b"original recovery inventory").unwrap(); + let files = vec![FileEntry { + path: "Core/worldserver.exe".into(), + sha256: fsx::sha256_bytes(b"repaired"), + size: 8, + owner: crate::manifest::Owner::Core, + policy: ReplacePolicy::Replace, + }]; + let mut txn = begin_repair(&w.root, &w.meta, "1.0.0", "full-backup", &files).unwrap(); + write(&w.root, "Core/worldserver.exe", b"repaired"); + repair_migrating(&w.meta, &mut txn).unwrap(); + fsx::atomic_write(&point, b"changed recovery inventory").unwrap(); + let env = Fake::ok(); + assert!(finish_repair(&w.root, &w.meta, &mut txn, false, &env).is_err()); + assert!(env.calls.borrow().is_empty()); + assert_eq!(read(&w, "Core/worldserver.exe"), b"repaired"); + assert!(ensure_recovered(&w.meta).is_err()); + } + + #[test] + fn failed_repair_recovery_keeps_a_failed_journal_and_its_reason() { + let w = world(&[], false); + let files = vec![FileEntry { + path: "Core/worldserver.exe".into(), + sha256: fsx::sha256_bytes(b"repaired"), + size: 8, + owner: crate::manifest::Owner::Core, + policy: ReplacePolicy::Replace, + }]; + let mut txn = begin_repair(&w.root, &w.meta, "1.0.0", "full-backup", &files).unwrap(); + write(&w.root, "Core/worldserver.exe", b"repaired"); + repair_migrating(&w.meta, &mut txn).unwrap(); + let env = Fake::ok(); + env.restore_fail.set(true); + assert!(finish_repair(&w.root, &w.meta, &mut txn, false, &env).is_err()); + let saved = load(&w.meta, &txn.id).unwrap(); + assert_eq!(saved.state, State::Failed); + assert!(saved.message.unwrap().contains("Repair recovery failed")); + assert_eq!(saved.recovery_point.as_deref(), Some("full-backup")); + assert!(ensure_recovered(&w.meta).is_err()); + } + + #[test] + fn repair_cannot_commit_unverified_files() { + let w = world(&[], false); + let files = vec![FileEntry { + path: "Core/worldserver.exe".into(), + sha256: fsx::sha256_bytes(b"repaired"), + size: 8, + owner: crate::manifest::Owner::Core, + policy: ReplacePolicy::Replace, + }]; + let mut txn = begin_repair(&w.root, &w.meta, "1.0.0", "full-backup", &files).unwrap(); + assert!(finish_repair(&w.root, &w.meta, &mut txn, true, &Fake::ok()).is_err()); + assert_eq!(load(&w.meta, &txn.id).unwrap().state, State::Applying); + assert_eq!(pending_checked(&w.meta).unwrap().unwrap().id, txn.id); + } + + #[test] + fn successful_repair_leaves_a_readable_journal_and_allows_next_operation() { + let w = world(&[], false); + let files = vec![FileEntry { + path: "Core/worldserver.exe".into(), + sha256: fsx::sha256_bytes(b"repaired"), + size: 8, + owner: crate::manifest::Owner::Core, + policy: ReplacePolicy::Replace, + }]; + let mut txn = begin_repair(&w.root, &w.meta, "1.0.0", "full-backup", &files).unwrap(); + write(&w.root, "Core/worldserver.exe", b"repaired"); + repair_migrating(&w.meta, &mut txn).unwrap(); + finish_repair(&w.root, &w.meta, &mut txn, true, &Fake::ok()).unwrap(); + let saved = load(&w.meta, &txn.id).unwrap(); + assert_eq!(saved.state, State::Committed); + assert!(saved.ops.iter().all(|op| op.started && op.done)); + ensure_recovered(&w.meta).unwrap(); + assert_eq!(read(&w, "Core/worldserver.exe"), b"repaired"); + } + #[test] fn interrupted_repair_restores_files_databases_and_ownership_metadata() { let w = world(&[], false); @@ -2576,6 +3360,23 @@ mod tests { .is_none()); } + #[test] + fn update_saves_recovery_then_migrates_and_validates_once() { + let w = world(&[], true); + let env = Fake::ok(); + let result = run(&w, &env, BTreeMap::new(), None).unwrap(); + assert_eq!(result.txn.state, State::Committed); + let calls = env.calls.borrow(); + assert_eq!(calls.iter().filter(|call| **call == "snapshot").count(), 1); + assert_eq!(calls.iter().filter(|call| **call == "migrate").count(), 1); + assert_eq!(calls.iter().filter(|call| **call == "validate").count(), 1); + assert!( + calls.iter().position(|call| *call == "snapshot") + < calls.iter().position(|call| *call == "migrate") + ); + assert!(!w.meta.join("rehearsals").exists()); + } + #[test] 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-core/src/update_route.rs b/crates/coa-core/src/update_route.rs new file mode 100644 index 0000000..5cabaac --- /dev/null +++ b/crates/coa-core/src/update_route.rs @@ -0,0 +1,93 @@ +use crate::{manifest::Manifest, pkgsource::{self, Source}, registry::InstallMeta, update, Result}; + +#[derive(Debug)] +pub struct Selection { + pub source: Source, + // Retained for the frontend response shape; direct updates never offer a bridge. + pub latest_version: Option, +} + +pub fn select(meta: &InstallMeta, source: Source, key: &str) -> Result { + select_with(meta, source, &mut |source| pkgsource::fetch_manifest(source, key)) +} + +fn select_with(meta: &InstallMeta, source: Source, fetch: &mut impl FnMut(&Source) -> Result<(Manifest, Vec)>) -> Result { + let (manifest, _) = fetch(&source)?; + update::validate_candidate(meta, &manifest)?; + Ok(Selection { source, latest_version: None }) +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::{engine::general_purpose::STANDARD, Engine}; + use ed25519_dalek::{Signer, SigningKey}; + use std::{fs, path::Path}; + + fn package() -> serde_json::Value { + serde_json::json!({"schema":1,"kind":"update","version":"0.261015.58","core":{"commit":"a".repeat(40)}, + "builtAt":"test","minManagerVersion":"0.6.16","files":[],"migrations":[],"archive":null, + "compatibility":{"schema":1,"platform":if cfg!(windows) {"windows-x86_64"} else {"linux-x86_64"}, + "coreCommit":"a".repeat(40),"moduleCommits":{"squid":"b".repeat(40)},"clientPatchVersion":"1.7", + "sourceVersions":["0.261013.54"],"sourceDatabases":{"0.261013.54":{"manifestSha256":"c".repeat(64),"schemaSha256":null}}}}) + } + + fn meta(version: &str) -> InstallMeta { + let mut meta = InstallMeta::new(crate::registry::InstallKind::New, Path::new("unused")); + meta.core.version = Some(version.into()); + meta + } + + fn fixture(value: &serde_json::Value, tamper: bool) -> (tempfile::TempDir, String) { + let folder = tempfile::tempdir().unwrap(); + let signing = SigningKey::from_bytes(&[37; 32]); + let bytes = serde_json::to_vec(value).unwrap(); + fs::write(folder.path().join("manifest.json.sig"), STANDARD.encode(signing.sign(&bytes).to_bytes())).unwrap(); + fs::write(folder.path().join("manifest.json"), if tamper { b"tampered".as_slice() } else { &bytes }).unwrap(); + (folder, STANDARD.encode(signing.verifying_key().to_bytes())) + } + + #[test] + fn every_known_older_version_selects_the_latest_signed_package_once() { + for installed in ["0.2.0", "0.261004.20", "0.261005.0", "0.261007.24", "0.261011.800006", "0.261013.54", "0.261015.58"] { + let (folder, key) = fixture(&package(), false); + for kind in [crate::registry::InstallKind::New, crate::registry::InstallKind::Imported] { + let mut installation = meta(installed); + installation.kind = kind; + let mut calls = 0; + let selected = select_with(&installation, Source::Dir(folder.path().into()), &mut |source| { + calls += 1; + pkgsource::fetch_manifest(source, &key) + }).unwrap(); + assert!(matches!(selected.source, Source::Dir(path) if path == folder.path())); + assert!(selected.latest_version.is_none()); + assert_eq!(calls, 1, "no intermediate package fetches for {installed}"); + } + } + } + + #[test] + fn unknown_versions_and_downgrades_remain_blocked() { + let (folder, key) = fixture(&package(), false); + for installed in ["unknown", "a65be06ec62f4", "0.261016.59"] { + assert!(select(&meta(installed), Source::Dir(folder.path().into()), &key).is_err()); + } + } + + #[test] + fn signed_target_is_required_even_for_unlisted_old_versions() { + let (folder, key) = fixture(&package(), true); + assert!(select(&meta("0.261004.20"), Source::Dir(folder.path().into()), &key).is_err()); + } + + #[test] + fn platform_and_manager_requirements_are_still_enforced() { + for (field, value) in [("minManagerVersion", "99.0.0"), ("platform", "other-platform")] { + let mut target = package(); + if field == "platform" { target["compatibility"][field] = value.into(); } + else { target[field] = value.into(); } + let (folder, key) = fixture(&target, false); + assert!(select(&meta("0.261004.20"), Source::Dir(folder.path().into()), &key).is_err()); + } + } +} diff --git a/crates/coa-release/src/main.rs b/crates/coa-release/src/main.rs index bab8cd7..922958f 100644 --- a/crates/coa-release/src/main.rs +++ b/crates/coa-release/src/main.rs @@ -55,6 +55,61 @@ fn run() -> Result<(), String> { .and_then(|v| v.parse().ok()) .unwrap_or(DEFAULT_PART_SIZE); match cmd.as_str() { + "plan-update" => { + let mut meta = coa_core::registry::InstallMeta::new(coa_core::registry::InstallKind::New, std::path::Path::new("unused")); + meta.core.version = Some(need(&a, "from")?.clone()); + let selection = coa_core::update_route::select(&meta, coa_core::pkgsource::Source::Url(need(&a, "source")?.clone()), coa_core::signing::EMBEDDED_PUBLIC_KEY).map_err(e)?; + let (manifest, _) = coa_core::pkgsource::fetch_manifest(&selection.source, coa_core::signing::EMBEDDED_PUBLIC_KEY).map_err(e)?; + println!("{}", serde_json::json!({"from":meta.core.version,"next":manifest.version,"latest":selection.latest_version})); + } + "verify-install" => { + coa_core::release_schema::verify_install_package( + &PathBuf::from(need(&a, "dir")?), coa_core::signing::EMBEDDED_PUBLIC_KEY, + ).map_err(e)?; + } + "compose-install" => { + coa_core::release_schema::compose_install( + &PathBuf::from(need(&a, "base")?), &PathBuf::from(need(&a, "candidate")?), + &PathBuf::from(need(&a, "fixture")?), coa_core::signing::EMBEDDED_PUBLIC_KEY, + ).map_err(e)?; + } + "qualify-install" => { + let report = coa_core::release_schema::qualify_install( + &PathBuf::from(need(&a, "package")?), &PathBuf::from(need(&a, "fixture")?), + coa_core::signing::EMBEDDED_PUBLIC_KEY, + ).map_err(e)?; + coa_core::fsx::atomic_write_json(&PathBuf::from(need(&a, "report")?), &report).map_err(e)?; + } + "qualify-upgrade" => { + let report = coa_core::release_schema::qualify_upgrade( + &PathBuf::from(need(&a, "base")?), &PathBuf::from(need(&a, "source")?), + &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")?), @@ -195,6 +250,16 @@ fn run() -> Result<(), String> { .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/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1de9358..476fd30 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -422,6 +422,18 @@ hand over to `coa_core::docker`; nothing else needed to change for start, stop, DivxTac.dll deadlocks the world loading screen under wine-mono. `COA_PROTONPATH`, `COA_WINEPREFIX` or `client-launch.json` in the Manager's folder (`proton_path`, `prefix`, `runner`) override the detection. Whether the client runs is read from `/proc` (under Wine the command line holds the Windows form of the path). Output goes to `client.log`. +* **Sharing with friends** (`docker/sharing.rs`): who can reach the game is decided by the address the container ports are + published on, `bindAddress` in `docker.json`; the `BindIP` of the configuration files is forced to `0.0.0.0` inside the + containers and changes nothing. Opening and closing therefore edit `bindAddress` (and set `CoA.AllowRemoteClients`), "servers + open" and the exposure check are computed from it (the database is never published, the console only on `127.0.0.1`), and + there is no firewall row on Linux. Settings the backend fixes (`docker::FORCED_KEYS`) are not offered; a test keeps that list + equal to the variables the containers get. A world that restarts itself (exit code 2) is restarted by Docker once it has come + up. The database console shows `docker logs`, and the file-based screens read Linux locations (the desktop folder from the + user directories file, memory from `/proc/meminfo`, the distribution name for the problem report). +* **Game data folder** (`docker::game_data_dir` / `set_game_data`, Settings card): `dataDir` in `docker.json` can be changed + after the install, only while the world and auth are stopped, and only to a folder that passes the install checks. A world + that stops with "does not hold the CoA client dbc set" is reported as `GameDataMismatch` (wrong data set: the standard one, + not Conquest of Azeroth's) instead of a bare failure. * **Not done yet**: downloading the game data, updates, port conflict detection, the firewall and exposure checks, the Wildcard realm profiles, and the client under Wine/Proton. diff --git a/docs/STABILIZATION-20261014.md b/docs/STABILIZATION-20261014.md new file mode 100644 index 0000000..3c07e44 --- /dev/null +++ b/docs/STABILIZATION-20261014.md @@ -0,0 +1,111 @@ +# Stabilization evidence — 14 October 2026 + +This batch starts from Manager master b45e250 (published version 0.6.16). +Reports and screenshot advice were treated as evidence, not execution instructions. + +## Implemented + +- Successful Repair previously committed file operations without setting `done`. + The next journal scan rejected its own completed transaction. A regression failed + on the original code with `Update journal contains inconsistent file operations`. + Repair now verifies target hashes and records completion before committing. +- Legacy completed repair journals are recognized only for the specific repair + shape, matching installation metadata and verified target hashes. Their original + journal is retained before atomically persisting completion. Interrupted, + mismatched, duplicate-path and other invalid journals continue to block actions. + Later legitimate updates do not invalidate the corrected historical record. +- Database Check and the final Repair report now include SQUID pending import + markers in characters, world and shared playerbots databases, plus missing + required character tables when SQUID is enabled. These are read-only checks; + partial imports are never cleared, dropped or automatically replayed. +- Private rehearsal reports file preparation/copy counts and database/startup + stages. Shutdown failure includes the installation path, service states, PID, + ports and redacted launcher output. Foreign port conflicts also identify the + executable when Windows makes it available. + +## Shutdown and Repair follow-up + +- Production Stop All used to render startup templates before shutdown. Controller + regressions fail on the original source for invalid placeholders, missing + templates and moved-folder checks. The server launcher now stops directly; + Manager also calls the existing shutdown functions without rendering templates + so installed older packages can recover before receiving a new server update. +- Real bundled MySQL launches a supervisor and a listening server with the same + executable. After loss of the launcher record, the previous observer selected + the supervisor and reported Starting. Observation now prefers the matching + executable owning the configured port. Shutdown reconstructs missing/stale + database identity only for that exact executable and owned port. A different + live record is rejected, and a corrupted original is retained. +- Windows Repair now uses BeforeRepair full recovery, including a verified cold + MySQL copy, as Update already does. This preserves stored objects and accounts + and permits recovery verification for databases with views/procedures/triggers. +- Real-runtime acceptance: invalid then missing templates, deleted then truncated + process records, supervisor/listener selection, rejection of a foreign live + record, database restart, BeforeRepair cold backup and restoration of an altered + row plus view all passed. Replaced database files remain available for recovery. + +## Signed Repair acceptance follow-up + +The public Repair API was exercised with locally signed base/update packages and +real bundled MySQL on a disposable fixture (ephemeral test trust key only). +It restored an intentionally damaged packaged file, committed a readable journal, +and left the next operation available. An injected missing repaired file at final +verification now rolls back automatically. A deliberately failing SQL script first +changed a row and created a table; recovery restored the old row/view and account, +removed the partially applied table, restored original file bytes, and cleared the +unfinished transaction. This full acceptance passed in 142.92 seconds. + +Other post-snapshot failures now use the same rollback path. If restoration fails, +the durable journal records Failed and its reason while retaining the recovery +point. Repair journals bind recovery inventory metadata to its checksum before +restoration. Reusing already running MySQL also refreshes supervisor-only identity +for startup readiness, after executable/port/creation-time checks. + +These are package/SQL/recovery tests on representative minimal database tables; +they do not claim successful client login, combat or an affected user's installation. + +## Overview update failure at 2% + +The additional screenshot reports a failed StopAll inside a private rehearsal, +with the launcher's moved-folder/changed-port guard, followed by a disappearing +progress bar on Overview. The shutdown adapter already bypasses startup preparation. +The native recovery test now also supplies a copied configuration signature with +an old installation path, verifies shutdown preserves it, and retains its database +row/view recovery assertions. + +Overview previously rendered only its local start/stop failure, ignoring stored +update and recovery-state errors. It now displays those errors with the existing +technical-details and copy controls, even when the update offer and progress bar +are gone. A new update attempt clears an older local action error. Render tests +reproduced the missing alerts before the fix; all 12 frontend tests pass afterward. + +## Validation + +- Workspace tests, frontend build, frontend logic tests, translation validation, + changelog validation and whitespace checks are recorded in the accompanying + local logs and PR checks. +- Real bundled MySQL regression validates restored rows, users, procedures, + functions, views and triggers, and preservation of custom defaults. It also + confirms SQUID pending imports are reported without modifying their records. +- Published Content Scaling revision e5d8c675 passes the production-budget + regression for 40/100 HP, core multipliers, rank rates, groups and repeated + calculation. This is a code regression, not an in-game Wildcard/PTR comparison. + +## Outstanding evidence and release gates + +| Report | Current evidence | Next verification | +| --- | --- | --- | +| Manager #98 | Repair journal defect reproduced; report itself has no diagnostic ZIP | Reproduce the complete Custom Races workflow and verify existing affected journal recovery | +| Screenshot: SQUID Start fails but Repair reports zero | Separate SQUID ledger was absent from Repair checks | Retest with a copy of the affected database; recover from a verified pre-import backup | +| Manager #82 / screenshot lock messages | #90 and #91 are already in published 0.6.16; lock regressions pass | Distinguish an active slow check from abandoned maintenance using diagnostics | +| Manager #100 / 54% | Attached archive ends immediately after entering private rehearsal; no failure is captured | Measure the full private-copy run on the affected hardware; new progress identifies its stage | +| Screenshot: stopped-MySQL error | Broken-template shutdown and lost-record/supervisor failures reproduced and fixed on disposable real MySQL | Verify the affected installation has one of these causes; other failures remain diagnostic-specific | +| Manager #25 | Missing defaults have targeted repair; start-data incompatibilities remain separate | Retest imported schema and affected race/class data without suppressing incompatibility checks | +| Manager #54 | Schedule fix is published; Stop now tolerates broken templates and Repair has complete MySQL recovery | Verify the exact unresolved placeholder and original recovery transaction; automatic placeholder guesses are not introduced | +| Scaling screenshot | 2.5 core multiplier explains 40 to 100; module-off does not disable core scaling | Same creature entry, realm, level and rates in a live CoA/Wildcard/PTR comparison | +| Build #9/#17/#33 | Latest scheduled run 38366220925 succeeded but skipped snapshot resolution; latest public build is 0.261013.54 | Qualify a new integrated candidate; old issue state is not current release evidence | + +This batch does not publish a Manager installer, promote a server package, +close user reports or claim the complete fresh-install/update/gameplay matrix. + +Clean Windows installs now select the full image attached to the same signed stable release used by updates. They never fall back to the legacy base when the current full image is absent. Explicit offline/custom full packages remain supported, while Repair and release upgrade fixtures retain the legacy base source. The release worker composes and validates the complete current database image before publication; users do not run an intermediate base server or an update transaction. diff --git a/docs/UPDATE-WORKFLOW.md b/docs/UPDATE-WORKFLOW.md new file mode 100644 index 0000000..be829da --- /dev/null +++ b/docs/UPDATE-WORKFLOW.md @@ -0,0 +1,19 @@ +# Update workflow + +The Manager selects the latest signed package directly for any known older server version. The source-version list records release qualification; it is not an exclusive allowlist and does not prove that a particular installed database can apply the package. Unknown imported versions, downgrades, incompatible platforms and newer Manager requirements remain blocked. + +The updater verifies the signed manifest and archive, checks file changes and database history, stops the server, and saves one complete recovery point. It then replaces files, applies pending SQL once, verifies the target schema and starts the updated server once for validation. Shared authentication migrations run once; both realm histories are checked before changing either. Account and character identities are compared before and after migration and startup. + +Account migration history is stored in the shared account database. Read-only checks recognize legacy auth records from both realms. Write operations adopt existing records and preserve already-applied core history; unknown legacy checksums stay unknown. History is read in two batched client calls, with explicit physical schemas and consistent text collations. Use the current Manager for subsequent database operations. + +Applied migrations are skipped; changed checksums and interrupted/failed records stop the update. Duplicate SQL identifiers for the same schema are rejected before execution. SQL or startup failure restores files and databases together. If recovery fails, startup remains blocked and the recovery artifacts remain available. + +The per-update nested server rehearsal and intermediate-package routing have been removed. Full-copy upgrade/startup testing belongs to release qualification, on independent disposable installations. Cleanup of interrupted rehearsals from older Manager versions remains for compatibility. Signed packages, recovery verification, configuration merge and startup validation stay enabled. No migration guard is bypassed and no unexecuted SQL is marked applied. Retired source archives do not prevent a direct update, but the database history must still be consistent and target checks must pass. + +## Verify with an actual installation copy + +`cargo run -p coa-core --example upgrade_fixture -- ` exercises the production updater, including recovery copies and startup. It refuses mutation unless the directory name starts with `coa-schema-fixture-real` and contains `.coa-update-fixture`. Prepare a disposable copy from a verified recovery point, with separate ports, metadata and no client path. Never point it at a working installation. + +`migration_plan ` reads database history without creating a migration ledger. On a separate marked `update-database-diagnosis-*` copy, `--apply ` exercises the production database phase. `--sql-only ` verifies and applies the signed SQL list for both realms and compares account/character identities. SQL-only success is not full package/startup qualification and does not verify the target schema contract. + +Record package signatures, exact source recovery point, actual result, repeat-run behavior and unavailable stages. Do not label a route universally tested based on a version list or SQL-only success. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 72ae5e2..d217268 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -256,14 +256,18 @@ async fn portable_launch( 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), + }, } } @@ -281,6 +285,18 @@ fn package_source(custom: Option) -> Source { } } +fn installation_source(custom: Option) -> Result { + let custom = custom.filter(|value| !value.trim().is_empty()).or_else(|| { + std::env::var("COA_PACKAGE_SOURCE") + .ok() + .filter(|value| !value.trim().is_empty()) + }); + if custom.is_some() { + return Ok(package_source(custom)); + } + Ok(Source::Installation(Box::new(update_source(None)?))) +} + #[derive(Serialize)] struct ServerSummary { id: String, @@ -344,6 +360,44 @@ fn summary(id: String, path: PathBuf) -> ServerSummary { } } +/// The game data folder of a Docker server; none for a repack, whose data is inside its own folder. +#[tauri::command] +fn game_data_folder( + state: State<'_, AppState>, + id: String, +) -> std::result::Result, UiError> { + let root = path_of(&state, &id)?; + if !coa_core::docker::is_docker(&root) { + return Ok(None); + } + Ok(Some( + coa_core::docker::game_data_dir(&root)? + .to_string_lossy() + .into_owned(), + )) +} + +/// Point a Docker server at another game data folder. The servers must be stopped. +#[tauri::command] +async fn set_game_data_folder( + state: State<'_, AppState>, + id: String, + path: String, +) -> std::result::Result { + let root = path_of(&state, &id)?; + let _guard = BusyGuard::acquire(&state, &id)?; + blocking(move || { + if !coa_core::docker::is_docker(&root) { + return Err(Error::Invalid( + "The game data of this server is inside its folder.".into(), + )); + } + coa_core::docker::set_game_data(&root, std::path::Path::new(&path))?; + Ok(path) + }) + .await +} + #[tauri::command] fn default_install_dir() -> String { if platform::flavor() == Flavor::Docker { @@ -402,9 +456,15 @@ async fn add_server( meta.core.version = report.banner_revision.clone(); meta.database.port = Some(report.ports.mysql); meta.database.schemas = report.database_schemas.clone(); + // The executables are `worldserver.exe` in a repack and `worldserver` in a Docker server; the file check reads these names. + let exe_names = if coa_core::docker::is_docker(std::path::Path::new(&report.path)) { + ("Core/worldserver", "Core/authserver") + } else { + ("Core/worldserver.exe", "Core/authserver.exe") + }; for (rel, exe) in [ - ("Core/worldserver.exe", &report.worldserver), - ("Core/authserver.exe", &report.authserver), + (exe_names.0, &report.worldserver), + (exe_names.1, &report.authserver), ] { if let Some(e) = exe { meta.original_hashes.insert(rel.into(), e.sha256.clone()); @@ -885,7 +945,7 @@ async fn install_requirements( } blocking(move || { let (m, _) = coa_core::pkgsource::fetch_manifest( - &package_source(package), + &installation_source(package)?, coa_core::signing::EMBEDDED_PUBLIC_KEY, )?; let archive = m @@ -916,10 +976,10 @@ async fn install_new( .install_cancel .lock() .map_err(|_| Error::Invalid("state poisoned".into()))? = Some(cancel.clone()); - let source = package_source(package); let registry = Registry::at(data_dir().join("installs.json")); let dest_path = PathBuf::from(dest); let result = tauri::async_runtime::spawn_blocking(move || { + let source = installation_source(package)?; install::install_base( &install::Params { source, @@ -1133,7 +1193,7 @@ async fn repair_server( &root, &dir, &package_source(None), - &update_source(None), + &update_source(None)?, coa_core::signing::EMBEDDED_PUBLIC_KEY, &|step, percent| { let _ = app.emit( @@ -1167,9 +1227,9 @@ async fn custom_races_status( state: State<'_, AppState>, id: String, ) -> std::result::Result, UiError> { - let root = path_of(&state, &id)?; + let ctx = client_context(&state, &id)?; blocking(move || { - let client = linked_client(&root)?; + let client = ctx.linked(); Ok(client .as_deref() .map(coa_core::custom_races::client_patch_status)) @@ -1183,13 +1243,12 @@ async fn custom_races_install( state: State<'_, AppState>, id: String, ) -> std::result::Result<(), UiError> { - let root = path_of(&state, &id)?; + let ctx = client_context(&state, &id)?; let _guard = BusyGuard::acquire(&state, &id)?; let cancel = begin_client_job(&state)?; let sid = id.clone(); let result = blocking(move || { - let client = linked_client(&root)? - .ok_or_else(|| Error::Invalid("No game client is linked to this server.".into()))?; + let client = ctx.require()?; coa_core::custom_races::install_client_patches(&client, &cancel, &|step, percent| { let _ = app.emit( "custom-races-progress", @@ -1200,9 +1259,11 @@ async fn custom_races_install( }), ); })?; - let enabled = coa_core::modules::list(&root) - .iter() - .any(|module| module.id == "custom-races" && module.enabled); + let enabled = ctx.root.as_ref().map_or(true, |root| { + coa_core::modules::list(root) + .iter() + .any(|module| module.id == "custom-races" && module.enabled) + }); coa_core::custom_races::set_client_patch_enabled(&client, enabled) }) .await; @@ -1210,6 +1271,16 @@ async fn custom_races_install( result } +#[tauri::command] +async fn custom_races_uninstall( + state: State<'_, AppState>, + id: String, +) -> std::result::Result<(), UiError> { + let ctx = client_context(&state, &id)?; + let _guard = BusyGuard::acquire(&state, &id)?; + blocking(move || coa_core::custom_races::uninstall_client_patches(&ctx.require()?)).await +} + #[tauri::command] async fn custom_races_toggle_client( state: State<'_, AppState>, @@ -1361,7 +1432,7 @@ async fn check_update( 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 @@ -1370,14 +1441,21 @@ async fn check_update( }; blocking(move || { let (_, meta) = install_meta(&root)?; - update::preview_with( + let selection = coa_core::update_route::select( + &meta, + update_source(source)?, + coa_core::signing::EMBEDDED_PUBLIC_KEY, + )?; + let mut preview = update::preview_with( &root, &meta, - &src, + &selection.source, coa_core::signing::EMBEDDED_PUBLIC_KEY, &Default::default(), access, - ) + )?; + preview.latest_version = selection.latest_version; + Ok(preview) }) .await } @@ -1400,10 +1478,19 @@ 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 (dir, meta) = install_meta(&root)?; + let src = coa_core::update_route::select( + &meta, + update_source(source)?, + coa_core::signing::EMBEDDED_PUBLIC_KEY, + )? + .source; + 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 @@ -2229,7 +2316,7 @@ struct FriendsStatus { exposure: Vec, /// The configuration lets other computers reach the login and world servers. servers_open: bool, - firewall: coa_core::firewall::Status, + firewall: Option, tailscale: coa_core::net::Tailscale, server_running: bool, auth_port: u16, @@ -2284,7 +2371,23 @@ fn report_targets() -> Vec { coa_core::report::targets() } +/// The distribution on Linux ("Linux (Arch Linux)"), from `/etc/os-release`; empty if it cannot be read. +#[cfg(not(windows))] +fn windows_version() -> String { + std::fs::read_to_string("/etc/os-release") + .ok() + .and_then(|t| { + t.lines().find_map(|l| { + l.strip_prefix("PRETTY_NAME=") + .map(|v| v.trim_matches('"').to_string()) + }) + }) + .map(|name| format!("Linux ({name})")) + .unwrap_or_default() +} + /// "Windows 11 (build 26200)" from `ver`; empty if it cannot be read. +#[cfg(windows)] fn windows_version() -> String { let mut cmd = std::process::Command::new("cmd.exe"); cmd.args(["/C", "ver"]); @@ -2366,9 +2469,14 @@ async fn friends_status( settings: coa_core::friends::load(&meta), lan_ip: automatic.map(|a| a.to_string()), lan_addresses: coa_core::net::lan_addresses(automatic).unwrap_or_default(), - exposure: coa_core::net::exposure(&ports), + exposure: if coa_core::docker::is_docker(&root) { + coa_core::docker::exposure(&root, &ports) + } else { + coa_core::net::exposure(&ports) + }, servers_open: coa_core::friends::bind_is_open(&root), - firewall: coa_core::firewall::status(), + // The Windows firewall exists only on Windows; on Linux Docker publishes the ports itself. + firewall: (platform::flavor() == Flavor::Repack).then(coa_core::firewall::status), tailscale: coa_core::net::tailscale(), server_running: coa_core::process::observe(&root, &ports).world.state == coa_core::process::ServiceState::Running, @@ -2443,7 +2551,7 @@ async fn friends_enable( }; let open = mode != Mode::Local; let changed = friends::set_open(&root, &meta, open)?; - if open { + if open && platform::flavor() == Flavor::Repack { coa_core::firewall::ensure_rules_with_secondary(&ports, secondary)?; } if mode == Mode::Direct && use_upnp { @@ -2481,10 +2589,14 @@ async fn friends_package( let host = coa_core::friends::load(&meta) .host .ok_or_else(|| Error::Invalid("Choose how friends connect first.".into()))?; - let desktop = std::env::var_os("USERPROFILE") - .map(PathBuf::from) - .unwrap_or_else(std::env::temp_dir) - .join("Desktop"); + let desktop = if platform::flavor() == Flavor::Docker { + coa_core::diag::desktop_or_temp() + } else { + std::env::var_os("USERPROFILE") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir) + .join("Desktop") + }; let out = if desktop.is_dir() { desktop } else { @@ -2569,6 +2681,16 @@ async fn console_tail( ) -> std::result::Result, UiError> { let root = path_of(&state, &id)?; blocking(move || { + // A Docker server's database writes to its container's output, not to a file. + if coa_core::docker::is_docker(&root) + && matches!(source, coa_core::console::Source::Database) + { + return coa_core::docker::logs::database_log( + &root, + filter.as_deref(), + lines.unwrap_or(300).min(2000), + ); + } let path = coa_core::console::log_path( &root, &data_dir().join("logs").join("manager.log"), @@ -2630,6 +2752,8 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ default_install_dir, + game_data_folder, + set_game_data_folder, install_environment, scan_server, add_server, @@ -2724,6 +2848,7 @@ pub fn run() { console_command, custom_races_status, custom_races_install, + custom_races_uninstall, custom_races_toggle_client, portable_state, portable_preflight, diff --git a/src/dev/mock.ts b/src/dev/mock.ts index 122ad26..1b1159b 100644 --- a/src/dev/mock.ts +++ b/src/dev/mock.ts @@ -92,9 +92,11 @@ export {}; if(cmd==="open_link"){(window as any).__lastLink=args.url;return null;} if(cmd==="export_diagnostics") return "C:\\Users\\you\\Desktop\\CoA-Diagnostics-20261001-140000.zip"; if(cmd==="list_crashes") return []; + if(cmd==="game_data_folder") return new URLSearchParams(location.search).get("flavor")==="docker"?"/srv/coa/server-data":null; + if(cmd==="set_game_data_folder") return args.path; if(cmd==="install_environment"){const docker=new URLSearchParams(location.search).get("flavor")==="docker";return docker?{flavor:"docker",default_dir:"/home/you/CoaServer",docker_problem:new URLSearchParams(location.search).has("nodocker")?"permission denied while trying to connect to the Docker daemon socket":null}:{flavor:"repack",default_dir:"C:\\Games\\CoA Server",docker_problem:null};} if(cmd==="install_preflight") return {ok:true,problems:[],free_bytes:210*2**30}; - if(cmd==="install_requirements") return {download_bytes:Math.round(5.9*2**30),unpacked_bytes:Math.round(11.4*2**30),version:"0.2.0"}; + if(cmd==="install_requirements") return {download_bytes:Math.round(5.9*2**30),unpacked_bytes:Math.round(11.4*2**30),version:"0.261014.1"}; if(cmd==="list_accounts"){const w=window as any;w.__acc=w.__acc??[{id:2,name:"ALICE",access:3,online:true,last_login:"2026-10-01 12:04:11",characters:4},{id:5,name:"BOB",access:0,online:false,last_login:null,characters:0},{id:6,name:"CAROL",access:2,online:false,last_login:"2026-09-30 21:40:02",characters:2}];return w.__acc;} if(cmd==="account_set_access"){const a=(window as any).__acc.find((x: any)=>x.name===args.name);if(a)a.access=args.level;return null;} if(cmd==="account_set_password") return null; diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 8371e8b..cff440f 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -195,6 +195,7 @@ const de: Partial> = { "comp.saved": "Gespeichert. {n} Begleiter werden beim Serverstart erstellt.", // Client + "client.races.install": "Patch für benutzerdefinierte Völker installieren", "client.title": "Spielclient", "client.looking": "Suche …", "client.intro": "Sag dem Manager, wo dein Spiel installiert ist, um einen SPIELEN-Button zu erhalten. Deine Spieldateien werden nie geändert, außer der Serveradresse und dem Begleiter-Addon.", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index ebfb8aa..479ff6d 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -103,6 +103,7 @@ const en = { "status.attention": "Needs attention", "status.running": "Running", "status.stopped": "Stopped", + "status.crashed": "Stopped unexpectedly", "btn.start": "START SERVER", "btn.stop": "STOP SERVER", @@ -145,6 +146,8 @@ const en = { "err.operation_in_progress.message": "The server is busy starting or stopping. Wait a moment and try again.", "err.startup_failed.title": "Server could not start", "err.startup_failed.message": "A service stopped while starting. Check the details for the cause.", + "err.game_data_mismatch.title": "The game data folder is not the CoA set", + "err.game_data_mismatch.message": "The server stopped because the DBC files in the game data folder are not the ones of the Conquest of Azeroth client. Choose the folder that holds the CoA data (Settings > Game data folder), then start again.", "err.docker_unavailable.title": "Docker is not available", "err.docker_unavailable.message": "This server runs in Docker. Install Docker, start it, and make sure your user account may use it, then try again.", "err.hash_mismatch.title": "A downloaded file is damaged", @@ -192,6 +195,11 @@ const en = { "install.localPkg": "Install from a local package folder instead of downloading", "install.localPkgHint": "(leave empty to download)", "install.button": "Install", + "gamedata.title": "Game data folder", + "gamedata.text": "The folder with the game's dbc, maps, vmaps and mmaps that this server reads. It is used where it is and never changed. It must be the Conquest of Azeroth set, not the standard one. Stop the server before changing it.", + "gamedata.change": "Change…", + "gamedata.dialog": "Choose the game data folder", + "gamedata.saved": "Saved. It is used the next time the server starts.", "install.docker.title": "Docker is needed", "install.docker.text": "On Linux the server runs in Docker. Install Docker and start it, or allow your user to use it, then check again.", "install.docker.retry": "Check again", @@ -240,6 +248,7 @@ const en = { "comp.saved": "Saved. {n} companions will be created when the server starts.", // Client + "client.races.install": "Install custom race patch", "client.title": "Game client", "client.looking": "Looking…", "client.intro": "Tell Manager where your game is installed to get a PLAY button. Your game files are never changed except the server address and the companion addon.", @@ -423,6 +432,7 @@ const en = { "upd.folderHint": "Update package folder (leave empty to use the official source)", "upd.available": "Version {to} is available", "upd.availableFrom": "Version {to} is available (you have {from})", + "upd.intermediate": "Update to {to} first, then check again to continue to {latest}. The package supports this version sequence; your database is checked when you update.", "upd.summary": "Download {size} · {files} file(s) change", "upd.summaryDb": "Download {size} · {files} file(s) change · {db} database update(s)", "upd.conflict": "This file was modified outside CoA Server Manager.", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 33441b3..00ce747 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -195,6 +195,7 @@ const es: Partial> = { "comp.saved": "Guardado. Se crearán {n} compañeros cuando se inicie el servidor.", // Client + "client.races.install": "Instalar parche de razas personalizadas", "client.title": "Cliente del juego", "client.looking": "Buscando…", "client.intro": "Indica al Manager dónde está instalado el juego para obtener un botón JUGAR. Tus archivos del juego nunca se modifican, salvo la dirección del servidor y el addon de compañeros.", diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 4174370..6ba40c9 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -195,6 +195,7 @@ const fr: Partial> = { "comp.saved": "Enregistré. {n} compagnons seront créés au démarrage du serveur.", // Client + "client.races.install": "Installer le patch des races personnalisées", "client.title": "Client du jeu", "client.looking": "Recherche…", "client.intro": "Indiquez au Manager où le jeu est installé pour obtenir un bouton JOUER. Vos fichiers de jeu ne sont jamais modifiés, sauf l’adresse du serveur et l’addon des compagnons.", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 263c642..cfaab72 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -230,6 +230,7 @@ const ru: Partial> = { "comp.saved": "Сохранено. Компаньоны ({n}) будут созданы при запуске сервера.", // Client + "client.races.install": "Установить патч кастомных рас", "client.title": "Клиент игры", "client.looking": "Ищу…", "client.intro": "Укажите, где установлена игра, чтобы появилась кнопка «Играть». Файлы игры не изменяются, кроме адреса сервера и аддона компаньонов.", @@ -413,6 +414,7 @@ const ru: Partial> = { "upd.folderHint": "Папка с пакетом обновления (оставьте пустым, чтобы использовать официальный источник)", "upd.available": "Доступна версия {to}", "upd.availableFrom": "Доступна версия {to} (у вас {from})", + "upd.intermediate": "Сначала обновитесь до {to}, затем проверьте обновления ещё раз для перехода на {latest}. Пакет поддерживает эту последовательность версий; состояние вашей базы проверяется при обновлении.", "upd.summary": "Загрузка {size} · изменится файлов: {files}", "upd.summaryDb": "Загрузка {size} · изменится файлов: {files} · обновлений базы данных: {db}", "upd.conflict": "Этот файл был изменён вне CoA Server Manager.", diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index a9e6fb3..466b3e0 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -207,6 +207,7 @@ const zh = { "comp.saved": "已保存。{n} 名随从将在服务器启动时创建。", // Client + "client.races.install": "安装自定义种族补丁", "client.title": "游戏客户端", "client.looking": "正在寻找…", "client.intro": "指定游戏客户端安装路径即可启用“开始游戏”按钮。除服务器地址和随从插件外,你的游戏文件绝不会被修改。", diff --git a/src/lib/api.ts b/src/lib/api.ts index 9d11c35..f1a4fc4 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 { @@ -306,6 +307,7 @@ export interface UpdatePlanItem { export interface UpdatePreview { from_version: string | null; to_version: string; + latest_version?: string; items: UpdatePlanItem[]; conflicts: string[]; migrations: number; @@ -392,6 +394,7 @@ export interface ClientDownloadCheck { export type ClientPatchState = "not-installed" | "enabled" | "disabled" | "partially-installed"; export interface CustomRacesClientStatus { + version: string | null; state: ClientPatchState; installedMpqs: number; totalMpqs: number; @@ -419,7 +422,8 @@ export interface FriendsStatus { lan_addresses: LanAddress[]; exposure: { port: number; what: string; reachable_from_network: boolean; listening: boolean }[]; servers_open: boolean; - firewall: { auth: boolean; world: boolean }; + /** null where there is no Windows firewall to ask about (Linux). */ + firewall: { auth: boolean; world: boolean } | null; tailscale: { installed: boolean; ip: string | null; connected: boolean }; server_running: boolean; auth_port: number; @@ -496,6 +500,9 @@ export const api = { restoreConfigs: (id: string, backupId: string) => invoke("restore_backup_configs", { id, backupId }), restoreDatabase: (id: string, backupId: string, database: string) => invoke("restore_backup_database", { id, backupId, database }), installEnvironment: () => invoke("install_environment"), + /** The game data folder of a Docker server; null for a repack. */ + gameDataFolder: (id: string) => invoke("game_data_folder", { id }), + setGameDataFolder: (id: string, path: string) => invoke("set_game_data_folder", { id, path }), installPreflight: (dest: string, needed?: number, gameData?: string) => invoke("install_preflight", { dest, needed: needed ?? null, gameData: gameData ?? null }), installRequirements: (pkg?: string) => invoke("install_requirements", { package: pkg ?? null }), realmlistProfiles: (id: string) => invoke<{ profiles: RealmProfile[]; active: string | null }>("realmlist_profiles", { id }), @@ -546,6 +553,7 @@ export const api = { clientDownload: (id: string, parent: string) => invoke("client_download", { id, parent }), clientCancel: () => invoke("client_cancel"), customRacesStatus: (id: string) => invoke("custom_races_status", { id }), + customRacesUninstall: (id: string) => invoke("custom_races_uninstall", { id }), customRacesInstall: (id: string) => invoke("custom_races_install", { id }), customRacesToggleClient: (id: string, enabled: boolean) => invoke("custom_races_toggle_client", { id, enabled }), play: (id: string) => invoke("play", { id }), diff --git a/src/lib/moduleSettings.ts b/src/lib/moduleSettings.ts index 764645a..78e5117 100644 --- a/src/lib/moduleSettings.ts +++ b/src/lib/moduleSettings.ts @@ -87,7 +87,14 @@ for (let n = 1; n <= 3; n++) add(`EC.P${n}`, "string", [`NPC phrase ${n}`, `Ре add("CoACustomRaces.BotsEnable", "bool", ["Bots can be custom races", "Боты кастомных рас", "Bots benutzerdefinierter Völker", "Bots de races personnalisées", "Bots de razas personalizadas", "允许机器人为自定义种族"], - ["Allow Playerbots to spawn as custom races.", "Разрешает ботам-игрокам появляться кастомными расами.", "Erlaubt Spieler-Bots, benutzerdefinierte Völker zu nutzen.", "Autorise les bots joueurs à s’incarner avec des races personnalisées.", "Permite a los bots jugadores crearse con razas personalizadas.", "允许玩家机器人扮演自定义种族。"] + [ + "Allow Playerbots to spawn as custom races (experimental). Off excludes all custom races; on allows stable custom races.", + "Разрешает ботам-игрокам появляться кастомными расами (экспериментально). Выключено — исключает все кастомные расы, включено — разрешает стабильные.", + "Erlaubt Spieler-Bots, benutzerdefinierte Völker zu nutzen (experimentell). Aus schließt alle benutzerdefinierten Völker aus; An erlaubt stabile.", + "Autorise les bots joueurs à s’incarner avec des races personnalisées (expérimental). Désactivé exclut toutes les races personnalisées ; activé autorise les stables.", + "Permite a los bots jugadores crearse con razas personalizadas (experimental). Desactivado excluye todas las razas personalizadas; activado permite las estables.", + "允许玩家机器人扮演自定义种族(实验性)。关闭将排除所有自定义种族,开启允许稳定的自定义种族。" + ] ); const ah = "AuctionHouseBot."; 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/src/screens/ClientCard.tsx b/src/screens/ClientCard.tsx index 4103e6b..7d5baaa 100644 --- a/src/screens/ClientCard.tsx +++ b/src/screens/ClientCard.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; import { open } from "@tauri-apps/plugin-dialog"; import { api, asUiError, type ClientInfo, type ClientStatus, type UiError } from "@/lib/api"; import { checkClient } from "@/lib/clientUpdate"; @@ -16,6 +17,7 @@ export function ClientCard({ serverId, remote = false }: { serverId: string; rem const [note, setNote] = useState(null); const [busy, setBusy] = useState(false); const [status, setStatus] = useState(null); + const [patchProgress, setPatchProgress] = useState(null); const [dialog, setDialog] = useState(null); const [playAfter, setPlayAfter] = useStartAfterUpdate("client", serverId); @@ -25,6 +27,10 @@ export function ClientCard({ serverId, remote = false }: { serverId: string; rem }; useEffect(() => { void refresh(); + const unlisten = listen<{ id: string; step: string; percent: number }>("custom-races-progress", e => { + if (e.payload.id === serverId) setPatchProgress(`${e.payload.step} (${Math.round(e.payload.percent * 100)}%)`); + }); + return () => { void unlisten.then(f => f()); }; }, [serverId]); // eslint-disable-line react-hooks/exhaustive-deps async function run(fn: () => Promise) { @@ -40,6 +46,7 @@ export function ClientCard({ serverId, remote = false }: { serverId: string; rem setError(asUiError(e)); } finally { setBusy(false); + setPatchProgress(null); } } @@ -111,11 +118,13 @@ export function ClientCard({ serverId, remote = false }: { serverId: string; rem {status && !status.managed && ( )} + {remote && }
)} + {patchProgress &&

{patchProgress}

} {note &&

{note}

} {error &&

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

} {dialog && ( diff --git a/src/screens/FriendsPage.tsx b/src/screens/FriendsPage.tsx index a7d3059..6371e76 100644 --- a/src/screens/FriendsPage.tsx +++ b/src/screens/FriendsPage.tsx @@ -170,7 +170,7 @@ export function FriendsPage({ serverId }: { serverId: string }) {
    {t("fr.st.running")} {t("fr.st.open")} - {t("fr.st.firewall")} + {st.firewall && {t("fr.st.firewall")}} e.what === "database" || e.what === "server console").every((e) => !e.reachable_from_network)}> {t("fr.st.private")} diff --git a/src/screens/GameDataCard.tsx b/src/screens/GameDataCard.tsx new file mode 100644 index 0000000..1f3d22d --- /dev/null +++ b/src/screens/GameDataCard.tsx @@ -0,0 +1,44 @@ +import { useEffect, useState } from "react"; +import { open } from "@tauri-apps/plugin-dialog"; +import { api, asUiError } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { useT } from "@/i18n"; + +/** Where a Docker server reads the game data from. Not shown for a repack, which keeps its data in its own folder. */ +export function GameDataCard({ serverId }: { serverId: string }) { + const t = useT(); + const [folder, setFolder] = useState(null); + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); + + useEffect(() => { + let alive = true; + void api.gameDataFolder(serverId).then((f) => alive && setFolder(f)).catch(() => alive && setFolder(null)); + return () => { alive = false; }; + }, [serverId]); + + if (folder === null) return null; + + async function change() { + const picked = await open({ directory: true, multiple: false, title: t("gamedata.dialog"), defaultPath: folder ?? undefined }); + if (typeof picked !== "string") return; + try { + setFolder(await api.setGameDataFolder(serverId, picked)); + setMessage({ ok: true, text: t("gamedata.saved") }); + } catch (e) { + setMessage({ ok: false, text: asUiError(e).technical }); + } + } + + return ( + +

    {t("gamedata.title")}

    +

    {t("gamedata.text")}

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

    {message.text}

    } +
    + ); +} diff --git a/src/screens/ModulesPage.tsx b/src/screens/ModulesPage.tsx index 5843dbd..5cba9dd 100644 --- a/src/screens/ModulesPage.tsx +++ b/src/screens/ModulesPage.tsx @@ -144,6 +144,17 @@ export function ModulesPage({ serverId, onChanged, onRegisterGuard }: { serverId } } + async function uninstallClientPatch() { + setRacesInstalling(true); + setError(null); + try { + await api.customRacesUninstall(serverId); + await load(); + onChanged?.(); + } catch (e) { setError(asUiError(e)); } + finally { setRacesInstalling(false); } + } + async function toggle(m: ModuleView) { setError(null); try { @@ -205,7 +216,9 @@ export function ModulesPage({ serverId, onChanged, onRegisterGuard }: { serverId {!m.installed && m.status !== "soon" && {t("mod.notHere")}} {m.id === "custom-races" && ( +
    +

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

    {racesInstalling && racesProgress ? (
    @@ -252,7 +265,8 @@ export function ModulesPage({ serverId, onChanged, onRegisterGuard }: { serverId )}
    - {(!racesPatchStatus || racesPatchStatus.state === "not-installed" || racesPatchStatus.state === "partially-installed") && ( + {racesPatchStatus?.state === "partially-installed" &&

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

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

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

    )}
    diff --git a/src/screens/Overview.tsx b/src/screens/Overview.tsx index 58175c7..d2e162f 100644 --- a/src/screens/Overview.tsx +++ b/src/screens/Overview.tsx @@ -46,21 +46,22 @@ function Row({ label, s }: { label: string; s: ServiceStatus }) { const t = useT(); const running = s.state === "running"; const starting = s.state === "starting"; + const crashed = s.state === "crashed"; return (
    {label} - {running ? t("status.running") : starting ? t("status.starting") : t("status.stopped")} + {running ? t("status.running") : starting ? t("status.starting") : crashed ? t("status.crashed") : t("status.stopped")}
    ); @@ -98,7 +99,13 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o const [keepMine, setKeepMine] = useState(true); const [perf, setPerf] = useState(null); const upd = useServerUpdate(server.id); + const visibleFailure = failure ?? upd.stateError ?? upd.uiError; const needsDecision = (upd.preview?.conflicts.length ?? 0) > 0; + const runServerUpdate = async () => { + setFailure(null); + setShowDetails(false); + await applyServerUpdate(server.id); + }; const [startBots, setStartBots] = useState(undefined); const [autoRestart, setAutoRestart] = useAutoRestart(server.id); const autoRestartRef = useRef(autoRestart); @@ -400,7 +407,7 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o variant="primary" size="xl" disabled={transitioning || upd.applying} - onClick={() => (needsDecision ? onOpenUpdates() : void applyServerUpdate(server.id))} + onClick={() => (needsDecision ? onOpenUpdates() : void runServerUpdate())} className="min-w-56" > {upd.applying && } @@ -423,7 +430,7 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o )} {running && upd.available && ( - @@ -538,6 +545,7 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o {upd.available && upd.preview && (

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

    + {upd.preview.latest_version &&

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

    } {needsDecision &&

    {t("overview.updateConflicts")}

    } {upd.applying && (
    @@ -586,21 +594,21 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o )} - {failure && ( + {visibleFailure && ( -

    {human(failure.human).title}

    -

    {human(failure.human).message}

    +

    {human(visibleFailure.human).title}

    +

    {human(visibleFailure.human).message}

    -
    {showDetails && (
    -              {failure.technical || t("common.noOutput")}
    +              {visibleFailure.technical || t("common.noOutput")}
                 
    )}
    diff --git a/src/screens/SettingsHome.tsx b/src/screens/SettingsHome.tsx index 8786f28..a15c025 100644 --- a/src/screens/SettingsHome.tsx +++ b/src/screens/SettingsHome.tsx @@ -7,6 +7,7 @@ import { api, asUiError, type UiError, type UpdatePreview, type UpdateTxn } from import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { ClientCard } from "@/screens/ClientCard"; +import { GameDataCard } from "@/screens/GameDataCard"; import { DiagnosticsCard } from "@/screens/DiagnosticsCard"; import { AboutCard } from "@/screens/AboutCard"; import { RemoveServerCard } from "@/screens/RemoveServerCard"; @@ -175,6 +176,8 @@ export function SettingsHome({ serverId, serverName = "", path, onForget }: { se + + @@ -233,6 +236,7 @@ export function SettingsHome({ serverId, serverName = "", path, onForget }: { se

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

    + {preview.latest_version &&

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

    }

    {!preview.database_check_error && (preview.pending_migrations ?? preview.migrations) > 0 ? t("upd.summaryDb", { size: mb(preview.download_bytes), files: preview.items.filter((i) => i.action !== "skip").length, db: preview.pending_migrations ?? preview.migrations }) 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/overview-update-error.test.mjs b/tools/overview-update-error.test.mjs new file mode 100644 index 0000000..0028fa8 --- /dev/null +++ b/tools/overview-update-error.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { rolldown } from "rolldown"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +const stubs = { + react: `export const useEffect=()=>{}; export const useCallback=f=>f; export const useRef=v=>({current:v}); export const useState=v=>[globalThis.__overviewStateCalls++===0?globalThis.__overviewStatus:v,()=>{}];`, + "@/lib/api": `export const api={}; export const asUiError=e=>e;`, + "@/lib/utils": `export const cn=(...s)=>s.filter(Boolean).join(' '); export const formatBytes=String; export const formatUptime=String;`, + "@/i18n": `export const useT=()=>k=>k; export const useHuman=()=>h=>h;`, + "@/lib/serverUpdate": `export const applyServerUpdate=async()=>false; export const useServerUpdate=()=>globalThis.__overviewUpdate;`, + "@/lib/clientJob": `export const dismissClientJob=()=>{}; export const startClientUpdate=()=>{}; export const stopClientJob=()=>{}; export const syncClient=()=>{}; export const useClientJob=()=>({phase:'idle'});`, + "@/lib/clientUpdate": `export const checkClient=()=>{}; export const useClientStatus=()=>({status:null});`, + "@/lib/autoRestartPrefs": `export const useAutoRestart=()=>[false,()=>{}];`, + "@/components/ui/button": `import {createElement} from 'react'; export const Button=({children,disabled,onClick,className})=>createElement('button',{disabled,onClick,className},children);`, + "@/components/ui/card": `import {createElement} from 'react'; export const Card=({children,...props})=>createElement('div',props,children);`, +}; +const bundle = await rolldown({ + input: fileURLToPath(new URL("../src/screens/Overview.tsx", import.meta.url)), + transform: { jsx: "react-jsx" }, + external: (id, importer) => id === "react/jsx-runtime" || id === "lucide-react" || (id === "react" && !importer?.endsWith("Overview.tsx")), + plugins: [{ name: "overview-test", + resolveId(id, importer) { + if (id.startsWith("@/") || (id === "react" && importer?.endsWith("Overview.tsx"))) return "\0" + id; + }, + load(id) { + if (!id.startsWith("\0")) return; + const name = id.slice(1); + return stubs[name] ?? `export const ${name.split('/').at(-1)}=()=>null;`; + }, + }], +}); +const result = await bundle.generate({ format: "cjs" }); +await bundle.close(); +const module = { exports: {} }; +new Function("require", "module", "exports", result.output[0].code)(createRequire(import.meta.url), module, module.exports); + +function overview(update) { + globalThis.__overviewStateCalls = 0; + globalThis.__overviewStatus = { path_exists: true, busy: false, observed: Object.fromEntries(["mysql", "auth", "world"].map(k => [k, { state: "stopped" }])) }; + globalThis.__overviewUpdate = { available: false, applying: false, preview: null, uiError: null, stateError: null, ...update }; + return renderToStaticMarkup(createElement(module.exports.Overview, { server: { id: "fixture", path: "C:/CoA_Server" }, onForget() {}, onOpenUpdates() {}, onRealmChanged() {}, onReport() {} })); +} +const failure = { human: { code: "unknown", title: "Server update failed", message: "See the technical details." }, technical: "Stop this repack before moving its folder or changing its ports." }; + +test("Overview displays failed updates after the progress and cached offer disappear", () => { + const html = overview({ uiError: failure }); + assert.match(html, /role="alert"/); + assert.match(html, /Server update failed/); + assert.match(html, /common.viewDetails/); + assert.match(html, /common.copyError/); +}); +test("Overview displays update-state failures even without an update offer", () => { + assert.match(overview({ stateError: failure }), /Server update failed/); +}); +test("Overview does not display an update failure when there is none", () => { + assert.doesNotMatch(overview({}), /Server update failed/); +}); diff --git a/tools/server-update-refresh.test.mjs b/tools/server-update-refresh.test.mjs index dde2c9d..2132b30 100644 --- a/tools/server-update-refresh.test.mjs +++ b/tools/server-update-refresh.test.mjs @@ -5,17 +5,21 @@ import { registerHooks } from "node:module"; let applyFailure = false; let stateFailure = false; let pending = null; +let serverRunning = true; const calls = []; globalThis.__hotfixApi = { + status: async () => ({ observed: Object.fromEntries(["mysql", "auth", "world"].map(key => [key, { state: serverRunning ? "running" : "stopped" }])) }), + stop: async () => { calls.push("stop"); }, applyUpdate: async () => { calls.push("apply"); if (applyFailure) throw new Error("SQL failure"); return { txn: { state: "committed", to_version: "2" } }; }, pendingUpdate: async () => { calls.push("disk"); if (stateFailure) throw new Error("damaged journal"); return pending; }, checkUpdate: async () => ({ from_version: "2", to_version: "2", items: [], conflicts: [], migrations: 1, pending_migrations: 0 }), }; registerHooks({ resolve(specifier, context, next) { - if (specifier === "react") return { shortCircuit: true, url: "data:text/javascript,export const useSyncExternalStore=(subscribe,getSnapshot)=>getSnapshot();" }; + if (specifier === "react") return { shortCircuit: true, url: "data:text/javascript,export const useSyncExternalStore=(subscribe,getSnapshot)=>getSnapshot();export const useState=()=>{throw new Error('React state is not used in this test');};" }; if (specifier === "@/lib/api") return { shortCircuit: true, url: "data:text/javascript,export const api=globalThis.__hotfixApi;export const asUiError=e=>({human:{code:'unknown'},technical:e.message});" }; if (specifier === "@tauri-apps/api/event") return { shortCircuit: true, url: "data:text/javascript,export const listen=async()=>()=>{};" }; if (specifier === "./serverUpdateStatus" && context.parentURL?.endsWith("/serverUpdate.ts")) return { shortCircuit: true, url: new URL("../src/lib/serverUpdateStatus.ts", import.meta.url).href }; + if (specifier === "./updatePrefs" && context.parentURL?.endsWith("/serverUpdate.ts")) return { shortCircuit: true, url: new URL("../src/lib/updatePrefs.ts", import.meta.url).href }; return next(specifier, context); } }); const { applyServerUpdate, refreshServerUpdateState, checkServerUpdate, useServerUpdate } = await import("../src/lib/serverUpdate.ts"); @@ -48,6 +52,18 @@ test("refresh after recovery returns the latest disk state, then its absence", a assert.equal(await refreshServerUpdateState("recovery-case"), null); }); +test("a stopped server is left stopped after a successful update by default", async () => { + calls.length = 0; + serverRunning = false; + applyFailure = false; + stateFailure = false; + pending = null; + try { + assert.equal(await applyServerUpdate("stopped-case", { choices: {} }), true); + assert.deepEqual(calls, ["apply", "stop", "disk"]); + } finally { serverRunning = true; } +}); + test("a rejected recheck removes an earlier cached update offer", async () => { const original = globalThis.__hotfixApi.checkUpdate; try { @@ -60,3 +76,17 @@ test("a rejected recheck removes an earlier cached update offer", async () => { assert.equal(useServerUpdate("stale-offer").preview, null); } finally { globalThis.__hotfixApi.checkUpdate = original; } }); + +test("a successful recheck keeps the last failed update details visible", async () => { + applyFailure = true; + stateFailure = false; + pending = null; + try { + assert.equal(await applyServerUpdate("visible-error", { choices: {} }), false); + await checkServerUpdate("visible-error"); + const state = useServerUpdate("visible-error"); + assert.equal(state.applying, false); + assert.equal(state.available, false); + assert.equal(state.uiError.technical, "SQL failure"); + } finally { applyFailure = false; } +}); 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()