diff --git a/Cargo.lock b/Cargo.lock index d0d9e0f..008cd61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -491,7 +491,7 @@ dependencies = [ [[package]] name = "coa-core" -version = "0.6.20" +version = "0.6.21" dependencies = [ "base64 0.22.1", "chrono", @@ -520,7 +520,7 @@ dependencies = [ [[package]] name = "coa-release" -version = "0.6.20" +version = "0.6.21" dependencies = [ "chrono", "coa-core", @@ -529,7 +529,7 @@ dependencies = [ [[package]] name = "coa-server-manager" -version = "0.6.20" +version = "0.6.21" dependencies = [ "coa-core", "serde", diff --git a/Cargo.toml b/Cargo.toml index aec4ac2..4248c97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/coa-core", "crates/coa-release", "src-tauri"] [workspace.package] -version = "0.6.20" +version = "0.6.21" edition = "2021" license = "AGPL-3.0-only" diff --git a/changelog.d/20261016-direct-database-upgrades.md b/changelog.d/20261016-direct-database-upgrades.md new file mode 100644 index 0000000..b385164 --- /dev/null +++ b/changelog.d/20261016-direct-database-upgrades.md @@ -0,0 +1,7 @@ +--- +area: manager +type: changed +audience: admins +title: Older server versions update directly without installing intermediate packages +--- +The Manager checks database history and applies missing SQL from the latest signed package. A version missing from the release test list no longer blocks the update. Unknown versions, interrupted migrations and downgrades remain blocked. diff --git a/crates/coa-core/src/manifest.rs b/crates/coa-core/src/manifest.rs index 7cbd4a9..0f6ac54 100644 --- a/crates/coa-core/src/manifest.rs +++ b/crates/coa-core/src/manifest.rs @@ -93,6 +93,7 @@ pub struct Compatibility { pub core_commit: String, pub module_commits: std::collections::BTreeMap, pub client_patch_version: String, + /// Sources exercised during release qualification, not an exclusive updater allowlist. pub source_versions: Vec, #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub source_databases: std::collections::BTreeMap, diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index 052b48c..29e94ba 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -387,8 +387,10 @@ fn reject_downgrade(meta: &InstallMeta, manifest: &Manifest) -> Result<()> { if c.platform != platform { return Err(Error::Invalid(format!("This package targets {} rather than {platform}.", c.platform))); } - if meta.core.version.as_ref().is_none_or(|version| version != &manifest.version && !c.source_versions.contains(version)) { - return Err(Error::Invalid(format!("Installed server {} is outside the compatibility matrix for {}. Tested source versions: {}.", meta.core.version.as_deref().unwrap_or("unknown"), manifest.version, c.source_versions.join(", ")))); + // The source list records release qualification, not an exclusive upgrade route. + // Database history and target validation decide whether the update can complete. + if meta.core.version.as_deref().and_then(crate::manifest::parse_version).is_none() { + return Err(Error::Invalid("The installed server version is unknown. An imported repack needs a verified starting state before updating.".into())); } } if let Some(installed) = meta.core.version.as_deref() { @@ -1630,7 +1632,7 @@ mod tests { } #[test] - fn incompatible_source_or_platform_is_rejected_without_touching_installation() { + fn unlisted_source_is_allowed_but_unknown_versions_and_other_platforms_are_rejected() { let w = world(&[], false); let (_, meta) = MetaDir::open(&w.meta).unwrap(); let mut manifest = Manifest::parse(&fs::read(w.pkg.join("manifest.json")).unwrap()).unwrap(); @@ -1643,7 +1645,12 @@ mod tests { manifest.validate().unwrap(); validate_candidate(&meta, &manifest).unwrap(); manifest.compatibility.as_mut().unwrap().source_versions = vec!["0.9.0".into()]; - assert!(validate_candidate(&meta, &manifest).unwrap_err().to_string().contains("compatibility matrix")); + validate_candidate(&meta, &manifest).unwrap(); + let mut unknown = meta.clone(); + unknown.core.version = None; + assert!(validate_candidate(&unknown, &manifest).unwrap_err().to_string().contains("unknown")); + unknown.core.version = Some("unverified-repack".into()); + assert!(validate_candidate(&unknown, &manifest).is_err()); let mut already_updated = meta.clone(); already_updated.core.version = Some(manifest.version.clone()); validate_candidate(&already_updated, &manifest).unwrap(); diff --git a/crates/coa-core/src/update_route.rs b/crates/coa-core/src/update_route.rs index 73cb9c7..5cabaac 100644 --- a/crates/coa-core/src/update_route.rs +++ b/crates/coa-core/src/update_route.rs @@ -1,12 +1,9 @@ -use std::collections::BTreeSet; - -use crate::{fsx, manifest::Manifest, pkgsource::{self, Source}, registry::InstallMeta, update, Error, Result}; - -const RELEASES: &str = "https://github.com/Corfirean/coa-server-build/releases/download/server-"; +use crate::{manifest::Manifest, pkgsource::{self, Source}, registry::InstallMeta, update, Result}; #[derive(Debug)] pub struct Selection { pub source: Source, + // Retained for the frontend response shape; direct updates never offer a bridge. pub latest_version: Option, } @@ -16,48 +13,8 @@ pub fn select(meta: &InstallMeta, source: Source, key: &str) -> Result Result<(Manifest, Vec)>) -> Result { let (manifest, _) = fetch(&source)?; - if update::validate_candidate(meta, &manifest).is_ok() { - return Ok(Selection { source, latest_version: None }); - } - let original = update::validate_candidate(meta, &manifest).unwrap_err(); - if !matches!(&source, Source::Url(url) if url.trim_end_matches('/') == format!("{RELEASES}{}", manifest.version)) { - return Err(original); - } - let Some(installed) = meta.core.version.as_deref().and_then(crate::manifest::parse_version) else { return Err(original); }; - if manifest.kind != crate::manifest::Kind::Update - || manifest.compatibility.as_ref().is_none_or(|c| c.platform != if cfg!(windows) { "windows-x86_64" } else { "linux-x86_64" }) - || !manifest.compatible_with_manager(crate::MANAGER_VERSION) - || crate::manifest::parse_version(&manifest.version).is_none_or(|version| version <= installed) { - return Err(original); - } - let mut visited = BTreeSet::new(); - let Some(source) = find(meta, &manifest, fetch, &mut visited, 0)? else { return Err(original); }; - Ok(Selection { source, latest_version: Some(manifest.version) }) -} - -fn find(meta: &InstallMeta, manifest: &Manifest, fetch: &mut impl FnMut(&Source) -> Result<(Manifest, Vec)>, visited: &mut BTreeSet, depth: usize) -> Result> { - if depth >= 8 || visited.len() >= 32 { return Ok(None); } - let Some(compatibility) = &manifest.compatibility else { return Ok(None); }; - let Some(installed) = meta.core.version.as_deref().and_then(crate::manifest::parse_version) else { return Ok(None); }; - let target = crate::manifest::parse_version(&manifest.version).unwrap(); - let mut versions: Vec<_> = compatibility.source_versions.iter().filter_map(|version| { - crate::manifest::parse_version(version).filter(|number| *number > installed && *number < target).map(|number| (number, version)) - }).collect(); - versions.sort_by(|left, right| right.0.cmp(&left.0)); - for (_, version) in versions { - let Some(database) = compatibility.source_databases.get(version) else { continue; }; - if !visited.insert(version.clone()) || visited.len() > 32 { continue; } - let source = Source::Url(format!("{RELEASES}{version}")); - let (bridge, bytes) = fetch(&source)?; - if bridge.version != *version || fsx::sha256_bytes(&bytes) != database.manifest_sha256 { - return Err(Error::InvalidManifest("The intermediate update differs from the signed compatibility matrix.".into())); - } - if update::validate_candidate(meta, &bridge).is_ok() { return Ok(Some(source)); } - if bridge.kind != crate::manifest::Kind::Update || !bridge.compatible_with_manager(crate::MANAGER_VERSION) - || bridge.compatibility.as_ref().is_none_or(|c| c.platform != compatibility.platform) { continue; } - if let Some(source) = find(meta, &bridge, fetch, visited, depth + 1)? { return Ok(Some(source)); } - } - Ok(None) + update::validate_candidate(meta, &manifest)?; + Ok(Selection { source, latest_version: None }) } #[cfg(test)] @@ -67,12 +24,12 @@ mod tests { use ed25519_dalek::{Signer, SigningKey}; use std::{fs, path::Path}; - fn package(version: &str, from: &str, digest: &str) -> serde_json::Value { - serde_json::json!({"schema":1,"kind":"update","version":version,"core":{"commit":"a".repeat(40)}, + fn package() -> serde_json::Value { + serde_json::json!({"schema":1,"kind":"update","version":"0.261015.58","core":{"commit":"a".repeat(40)}, "builtAt":"test","minManagerVersion":"0.6.16","files":[],"migrations":[],"archive":null, "compatibility":{"schema":1,"platform":if cfg!(windows) {"windows-x86_64"} else {"linux-x86_64"}, "coreCommit":"a".repeat(40),"moduleCommits":{"squid":"b".repeat(40)},"clientPatchVersion":"1.7", - "sourceVersions":[from],"sourceDatabases":{from:{"manifestSha256":digest,"schemaSha256":null}}}}) + "sourceVersions":["0.261013.54"],"sourceDatabases":{"0.261013.54":{"manifestSha256":"c".repeat(64),"schemaSha256":null}}}}) } fn meta(version: &str) -> InstallMeta { @@ -81,100 +38,56 @@ mod tests { meta } - fn signed_fixture(values: &[serde_json::Value], tamper: Option<&str>) -> (tempfile::TempDir, String) { + fn fixture(value: &serde_json::Value, tamper: bool) -> (tempfile::TempDir, String) { let folder = tempfile::tempdir().unwrap(); let signing = SigningKey::from_bytes(&[37; 32]); - for value in values { - let version = value["version"].as_str().unwrap(); - let dir = folder.path().join(version); - fs::create_dir(&dir).unwrap(); - let bytes = serde_json::to_vec(value).unwrap(); - fs::write(dir.join("manifest.json.sig"), STANDARD.encode(signing.sign(&bytes).to_bytes())).unwrap(); - fs::write(dir.join("manifest.json"), &bytes).unwrap(); - if tamper == Some(version) { fs::write(dir.join("manifest.json"), b"tampered").unwrap(); } - } + let bytes = serde_json::to_vec(value).unwrap(); + fs::write(folder.path().join("manifest.json.sig"), STANDARD.encode(signing.sign(&bytes).to_bytes())).unwrap(); + fs::write(folder.path().join("manifest.json"), if tamper { b"tampered".as_slice() } else { &bytes }).unwrap(); (folder, STANDARD.encode(signing.verifying_key().to_bytes())) } - fn select_fixture(values: &[serde_json::Value], installed: &str, tamper: Option<&str>) -> Result { - let (folder, key) = signed_fixture(values, tamper); - let latest = values.last().unwrap()["version"].as_str().unwrap(); - select_with(&meta(installed), Source::Url(format!("{RELEASES}{latest}")), &mut |source| { - let Source::Url(url) = source else { unreachable!() }; - let version = url.strip_prefix(RELEASES).unwrap(); - pkgsource::fetch_manifest(&Source::Dir(folder.path().join(version)), &key) - }) - } - - fn pair() -> Vec { - let bridge = package("1.1.0", "1.0.0", &"c".repeat(64)); - let digest = fsx::sha256_bytes(&serde_json::to_vec(&bridge).unwrap()); - vec![bridge, package("1.2.0", "1.1.0", &digest)] - } - #[test] - fn finds_a_signed_intermediate_without_relaxing_candidate_validation() { - let selection = select_fixture(&pair(), "1.0.0", None).unwrap(); - assert!(matches!(selection.source, Source::Url(url) if url.ends_with("1.1.0"))); - assert_eq!(selection.latest_version.as_deref(), Some("1.2.0")); - let latest = Manifest::parse(&serde_json::to_vec(pair().last().unwrap()).unwrap()).unwrap(); - assert!(update::validate_candidate(&meta("1.0.0"), &latest).is_err()); - } - - #[test] - fn direct_updates_and_current_versions_do_not_request_an_intermediate() { - for installed in ["1.1.0", "1.2.0"] { - let selection = select_fixture(&pair(), installed, None).unwrap(); - assert!(selection.latest_version.is_none()); - } - } - - #[test] - fn rejects_tampered_or_replaced_intermediate_manifests() { - assert!(select_fixture(&pair(), "1.0.0", Some("1.1.0")).is_err()); - let mut values = pair(); - values[0]["builtAt"] = "replaced".into(); - assert!(select_fixture(&values, "1.0.0", None).unwrap_err().to_string().contains("differs")); - } - - #[test] - fn follows_multiple_tested_edges_and_rejects_missing_paths() { - let mut values = pair(); - let digest = fsx::sha256_bytes(&serde_json::to_vec(values.last().unwrap()).unwrap()); - values.push(package("1.3.0", "1.2.0", &digest)); - let selection = select_fixture(&values, "1.0.0", None).unwrap(); - assert!(matches!(selection.source, Source::Url(url) if url.ends_with("1.1.0"))); - assert!(select_fixture(&values, "0.9.0", None).is_err()); - assert!(select_fixture(&values, "2.0.0", None).is_err()); - } - - #[test] - fn never_routes_custom_sources_or_unknown_installations() { - let values = pair(); - let (folder, key) = signed_fixture(&values, None); - let mut calls = 0; - let result = select_with(&meta("1.0.0"), Source::Dir(folder.path().join("1.2.0")), &mut |source| { - calls += 1; - pkgsource::fetch_manifest(source, &key) - }); - assert!(result.is_err()); - assert_eq!(calls, 1); - assert!(select_fixture(&values, "unknown", None).is_err()); - } - - #[test] - fn rejects_incompatible_platforms_and_manager_versions() { - for index in [0, 1] { - for (field, value) in [("minManagerVersion", "99.0.0"), ("platform", "other-platform")] { - let mut values = pair(); - if field == "platform" { values[index]["compatibility"][field] = value.into(); } - else { values[index][field] = value.into(); } - if index == 0 { - let digest = fsx::sha256_bytes(&serde_json::to_vec(&values[0]).unwrap()); - values[1]["compatibility"]["sourceDatabases"]["1.1.0"]["manifestSha256"] = digest.into(); - } - assert!(select_fixture(&values, "1.0.0", None).is_err()); + fn every_known_older_version_selects_the_latest_signed_package_once() { + for installed in ["0.2.0", "0.261004.20", "0.261005.0", "0.261007.24", "0.261011.800006", "0.261013.54", "0.261015.58"] { + let (folder, key) = fixture(&package(), false); + for kind in [crate::registry::InstallKind::New, crate::registry::InstallKind::Imported] { + let mut installation = meta(installed); + installation.kind = kind; + let mut calls = 0; + let selected = select_with(&installation, Source::Dir(folder.path().into()), &mut |source| { + calls += 1; + pkgsource::fetch_manifest(source, &key) + }).unwrap(); + assert!(matches!(selected.source, Source::Dir(path) if path == folder.path())); + assert!(selected.latest_version.is_none()); + assert_eq!(calls, 1, "no intermediate package fetches for {installed}"); } } } + + #[test] + fn unknown_versions_and_downgrades_remain_blocked() { + let (folder, key) = fixture(&package(), false); + for installed in ["unknown", "a65be06ec62f4", "0.261016.59"] { + assert!(select(&meta(installed), Source::Dir(folder.path().into()), &key).is_err()); + } + } + + #[test] + fn signed_target_is_required_even_for_unlisted_old_versions() { + let (folder, key) = fixture(&package(), true); + assert!(select(&meta("0.261004.20"), Source::Dir(folder.path().into()), &key).is_err()); + } + + #[test] + fn platform_and_manager_requirements_are_still_enforced() { + for (field, value) in [("minManagerVersion", "99.0.0"), ("platform", "other-platform")] { + let mut target = package(); + if field == "platform" { target["compatibility"][field] = value.into(); } + else { target[field] = value.into(); } + let (folder, key) = fixture(&target, false); + assert!(select(&meta("0.261004.20"), Source::Dir(folder.path().into()), &key).is_err()); + } + } } diff --git a/docs/UPDATE-WORKFLOW.md b/docs/UPDATE-WORKFLOW.md index 1176f41..be829da 100644 --- a/docs/UPDATE-WORKFLOW.md +++ b/docs/UPDATE-WORKFLOW.md @@ -1,6 +1,6 @@ # Update workflow -Version compatibility selects a package; it does not prove that a particular installed database can apply it. +The Manager selects the latest signed package directly for any known older server version. The source-version list records release qualification; it is not an exclusive allowlist and does not prove that a particular installed database can apply the package. Unknown imported versions, downgrades, incompatible platforms and newer Manager requirements remain blocked. The updater verifies the signed manifest and archive, checks file changes and database history, stops the server, and saves one complete recovery point. It then replaces files, applies pending SQL once, verifies the target schema and starts the updated server once for validation. Shared authentication migrations run once; both realm histories are checked before changing either. Account and character identities are compared before and after migration and startup. @@ -8,7 +8,7 @@ Account migration history is stored in the shared account database. Read-only ch Applied migrations are skipped; changed checksums and interrupted/failed records stop the update. Duplicate SQL identifiers for the same schema are rejected before execution. SQL or startup failure restores files and databases together. If recovery fails, startup remains blocked and the recovery artifacts remain available. -The per-update nested server rehearsal has been removed. Full-copy upgrade/startup testing belongs to release qualification, on independent disposable installations. Cleanup of interrupted rehearsals from older Manager versions remains for compatibility. Signed packages, recovery verification, configuration merge and startup validation stay enabled. No migration guard is bypassed, no unexecuted SQL is marked applied, and the package compatibility matrix is unchanged. +The per-update nested server rehearsal and intermediate-package routing have been removed. Full-copy upgrade/startup testing belongs to release qualification, on independent disposable installations. Cleanup of interrupted rehearsals from older Manager versions remains for compatibility. Signed packages, recovery verification, configuration merge and startup validation stay enabled. No migration guard is bypassed and no unexecuted SQL is marked applied. Retired source archives do not prevent a direct update, but the database history must still be consistent and target checks must pass. ## Verify with an actual installation copy diff --git a/package-lock.json b/package-lock.json index 3db365a..501453d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coa-server-manager", - "version": "0.6.20", + "version": "0.6.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coa-server-manager", - "version": "0.6.20", + "version": "0.6.21", "dependencies": { "@tauri-apps/api": "^2.12.0", "@tauri-apps/plugin-dialog": "^2.8.0", diff --git a/package.json b/package.json index 209eb21..1e601e8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coa-server-manager", "private": true, - "version": "0.6.20", + "version": "0.6.21", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index baab552..dd98f84 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CoA Server Manager", - "version": "0.6.20", + "version": "0.6.21", "identifier": "com.coa.servermanager", "build": { "beforeDevCommand": "npm run dev",