From efd4b8bbf224035a03c5dba662ccd28b978be0f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 6 Oct 2026 07:13:14 +0500 Subject: [PATCH] feat(portable): local canonical storage for portable characters (Phase 1) Adds crates/coa-core/src/portable: UUIDv7 identities, namespaced content ids, the v1 portable character model (validated, bounded, canonical), compressed hashed snapshots, a union-only collection set, and a SQLite (WAL) store with revisions, stale-revision rejection, rollback-as-new-revision, configurable history, realm/item mappings and forward-only migrations. No realm database, network or UI is touched; no existing behaviour changes. 45 new tests. Format documented in docs/PORTABLE_FORMAT.md. Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 82 +++ crates/coa-core/Cargo.toml | 3 +- crates/coa-core/src/lib.rs | 1 + crates/coa-core/src/portable/collection.rs | 253 +++++++ crates/coa-core/src/portable/error.rs | 50 ++ crates/coa-core/src/portable/fixtures.rs | 215 ++++++ crates/coa-core/src/portable/ids.rs | 229 ++++++ .../src/portable/migrations/001_init.sql | 77 ++ crates/coa-core/src/portable/mod.rs | 24 + crates/coa-core/src/portable/model.rs | 640 +++++++++++++++++ crates/coa-core/src/portable/snapshot.rs | 230 ++++++ crates/coa-core/src/portable/store.rs | 672 ++++++++++++++++++ crates/coa-core/src/portable/store/tests.rs | 455 ++++++++++++ crates/coa-core/src/portable/versions.rs | 18 + docs/PORTABLE_FORMAT.md | 91 +++ 15 files changed, 3039 insertions(+), 1 deletion(-) create mode 100644 crates/coa-core/src/portable/collection.rs create mode 100644 crates/coa-core/src/portable/error.rs create mode 100644 crates/coa-core/src/portable/fixtures.rs create mode 100644 crates/coa-core/src/portable/ids.rs create mode 100644 crates/coa-core/src/portable/migrations/001_init.sql create mode 100644 crates/coa-core/src/portable/mod.rs create mode 100644 crates/coa-core/src/portable/model.rs create mode 100644 crates/coa-core/src/portable/snapshot.rs create mode 100644 crates/coa-core/src/portable/store.rs create mode 100644 crates/coa-core/src/portable/store/tests.rs create mode 100644 crates/coa-core/src/portable/versions.rs create mode 100644 docs/PORTABLE_FORMAT.md diff --git a/Cargo.lock b/Cargo.lock index 97bff9f..809b6e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -502,6 +502,7 @@ dependencies = [ "num-bigint", "rand_core", "reqwest 0.12.28", + "rusqlite", "serde", "serde_json", "sha1", @@ -1100,6 +1101,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.5.0" @@ -1613,11 +1626,32 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown 0.17.1", +] [[package]] name = "heck" @@ -2244,6 +2278,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3294,6 +3339,31 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.21", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags 2.13.2", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -3869,6 +3939,18 @@ dependencies = [ "der", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/crates/coa-core/Cargo.toml b/crates/coa-core/Cargo.toml index 1bea4ee..990a4df 100644 --- a/crates/coa-core/Cargo.toml +++ b/crates/coa-core/Cargo.toml @@ -10,7 +10,7 @@ serde_json = "1" sha2 = "0.10" hex = "0.4" thiserror = "2" -uuid = { version = "1", features = ["v4", "serde"] } +uuid = { version = "1", features = ["v4", "v7", "serde"] } chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] } dunce = "1" fs4 = "0.13" @@ -25,6 +25,7 @@ zip = { version = "2", default-features = false, features = ["deflate"] } sha1 = "0.10" tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "std"] } tempfile = "3" +rusqlite = { version = "0.40.2", features = ["bundled"] } [dev-dependencies] rand_core = { version = "0.6", features = ["getrandom"] } diff --git a/crates/coa-core/src/lib.rs b/crates/coa-core/src/lib.rs index 7b1cc55..f2c7f02 100644 --- a/crates/coa-core/src/lib.rs +++ b/crates/coa-core/src/lib.rs @@ -25,6 +25,7 @@ pub mod population; pub mod process; pub mod manifest; pub mod migrations; +pub mod portable; pub mod multiworld; pub mod repair; pub mod schema_check; diff --git a/crates/coa-core/src/portable/collection.rs b/crates/coa-core/src/portable/collection.rs new file mode 100644 index 0000000..5a96cce --- /dev/null +++ b/crates/coa-core/src/portable/collection.rs @@ -0,0 +1,253 @@ +//! Account-wide collections (wardrobe, vanity, mounts, pets, ...): sets of numeric ids that only ever **grow**. +//! +//! * `canonical = canonical U incoming`: an id is never removed because another realm did not return it. +//! * Compact form: sorted ids, delta + LEB128 varint, ~1-2 bytes per id; never a verbose JSON array. +//! * `collection_hash` makes "did anything change?" a 32-byte comparison; when it is equal nothing is transferred. + +use sha2::{Digest, Sha256}; + +use super::error::{PortableError, Result}; +use super::versions::PORTABLE_COLLECTION_FORMAT_VERSION; + +/// Largest number of ids in one collection (a realm's whole wardrobe is a few thousand). +pub const MAX_SET_IDS: usize = 2_000_000; +/// Largest encoded size accepted when decoding. +pub const MAX_ENCODED_BYTES: usize = 8 * 1024 * 1024; + +/// Collection kinds are namespaced names such as `coa:wardrobe`, `coa:vanity`, `mod:foo:mounts`. +pub fn valid_kind(kind: &str) -> bool { + super::ids::valid_namespace(kind) && kind.contains(':') +} + +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct IdSet(Vec); + +impl IdSet { + pub fn new() -> Self { + Self(Vec::new()) + } + + /// Build from any ids: sorted and de-duplicated. + pub fn from_ids(ids: impl IntoIterator) -> Result { + let mut v: Vec = ids.into_iter().collect(); + v.sort_unstable(); + v.dedup(); + if v.len() > MAX_SET_IDS { + return Err(PortableError::LimitExceeded(format!("a collection cannot hold more than {MAX_SET_IDS} ids"))); + } + Ok(Self(v)) + } + + pub fn len(&self) -> usize { + self.0.len() + } + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + pub fn contains(&self, id: u32) -> bool { + self.0.binary_search(&id).is_ok() + } + pub fn ids(&self) -> &[u32] { + &self.0 + } + + /// `self U other`. + pub fn union(&self, other: &IdSet) -> IdSet { + let (a, b) = (&self.0, &other.0); + let mut out = Vec::with_capacity(a.len() + b.len()); + let (mut i, mut j) = (0, 0); + while i < a.len() && j < b.len() { + match a[i].cmp(&b[j]) { + std::cmp::Ordering::Less => { + out.push(a[i]); + i += 1; + } + std::cmp::Ordering::Greater => { + out.push(b[j]); + j += 1; + } + std::cmp::Ordering::Equal => { + out.push(a[i]); + i += 1; + j += 1; + } + } + } + out.extend_from_slice(&a[i..]); + out.extend_from_slice(&b[j..]); + IdSet(out) + } + + /// How many ids of `other` are not in `self`. + pub fn count_new(&self, other: &IdSet) -> usize { + other.0.iter().filter(|id| !self.contains(**id)).count() + } + + /// `[format version][count varint][first id varint][gap varint ...]` where every gap is the difference to the + /// previous id (always at least 1). + pub fn encode(&self) -> Vec { + let mut out = Vec::with_capacity(2 + self.0.len() * 2); + out.push(PORTABLE_COLLECTION_FORMAT_VERSION as u8); + put_varint(&mut out, self.0.len() as u64); + let mut previous = 0u32; + for (index, id) in self.0.iter().enumerate() { + put_varint(&mut out, if index == 0 { *id as u64 } else { (*id - previous) as u64 }); + previous = *id; + } + out + } + + pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() > MAX_ENCODED_BYTES { + return Err(PortableError::LimitExceeded("an encoded collection is too large".into())); + } + let bad = |what: &str| PortableError::CorruptSnapshot(format!("collection: {what}")); + let (&version, mut rest) = bytes.split_first().ok_or_else(|| bad("empty"))?; + if version as u32 > PORTABLE_COLLECTION_FORMAT_VERSION { + return Err(PortableError::UnsupportedFormat { found: version as u32, supported: PORTABLE_COLLECTION_FORMAT_VERSION }); + } + if version == 0 { + return Err(bad("version 0")); + } + let count = take_varint(&mut rest).ok_or_else(|| bad("truncated count"))?; + if count > MAX_SET_IDS as u64 { + return Err(PortableError::LimitExceeded(format!("a collection cannot hold more than {MAX_SET_IDS} ids"))); + } + let mut ids = Vec::with_capacity(count as usize); + let mut previous = 0u64; + for index in 0..count { + let step = take_varint(&mut rest).ok_or_else(|| bad("truncated ids"))?; + let id = if index == 0 { + step + } else { + if step == 0 { + return Err(bad("ids are not strictly ascending")); + } + previous.checked_add(step).ok_or_else(|| bad("overflow"))? + }; + if id > u32::MAX as u64 { + return Err(bad("id out of range")); + } + ids.push(id as u32); + previous = id; + } + if !rest.is_empty() { + return Err(bad("trailing bytes")); + } + Ok(IdSet(ids)) + } + + /// Hash of one collection: binds the kind and the exact content. + pub fn hash(&self, kind: &str) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(b"coa-collection-v1\0"); + h.update(kind.as_bytes()); + h.update([0]); + h.update(self.encode()); + h.finalize().into() + } +} + +fn put_varint(out: &mut Vec, mut v: u64) { + while v >= 0x80 { + out.push((v as u8 & 0x7F) | 0x80); + v >>= 7; + } + out.push(v as u8); +} + +fn take_varint(rest: &mut &[u8]) -> Option { + let mut value = 0u64; + for shift in (0..64).step_by(7) { + let (&byte, tail) = rest.split_first()?; + *rest = tail; + value |= ((byte & 0x7F) as u64) << shift; + if byte & 0x80 == 0 { + return Some(value); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pseudo_random_ids(n: usize, spread: u32) -> Vec { + // deterministic xorshift, no dependency + let mut x = 0x9E37_79B9u32; + (0..n) + .map(|_| { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + x % spread + }) + .collect() + } + + #[test] + fn roundtrip_and_size_at_realistic_and_extreme_sizes() { + for (count, spread) in [(0usize, 1u32), (1, 10), (2_000, 40_000), (10_000, 200_000), (50_000, 1_000_000)] { + let set = IdSet::from_ids(pseudo_random_ids(count, spread)).unwrap(); + let encoded = set.encode(); + assert_eq!(IdSet::decode(&encoded).unwrap(), set); + // a compact binary form: well under 3 bytes per id even for scattered ids, never a JSON array + assert!(encoded.len() <= 8 + set.len() * 3, "{} ids -> {} bytes", set.len(), encoded.len()); + } + } + + #[test] + fn union_never_removes_and_is_idempotent() { + let a = IdSet::from_ids([5, 1, 9, 9]).unwrap(); + let b = IdSet::from_ids([2, 9, 11]).unwrap(); + let u = a.union(&b); + assert_eq!(u.ids(), &[1, 2, 5, 9, 11]); + assert!(a.ids().iter().all(|id| u.contains(*id)), "nothing of the old set may disappear"); + assert_eq!(u.union(&b), u); + assert_eq!(u.union(&IdSet::new()), u); + assert_eq!(a.count_new(&b), 2); + assert_eq!(u.count_new(&a), 0); + } + + #[test] + fn hash_changes_with_content_and_kind() { + let a = IdSet::from_ids([1, 2, 3]).unwrap(); + let b = IdSet::from_ids([1, 2, 4]).unwrap(); + assert_eq!(a.hash("coa:wardrobe"), IdSet::from_ids([3, 2, 1]).unwrap().hash("coa:wardrobe")); + assert_ne!(a.hash("coa:wardrobe"), b.hash("coa:wardrobe")); + assert_ne!(a.hash("coa:wardrobe"), a.hash("coa:vanity")); + } + + #[test] + fn malformed_encodings_are_refused() { + let good = IdSet::from_ids([10, 20, 30]).unwrap().encode(); + assert!(IdSet::decode(&[]).is_err()); + assert!(IdSet::decode(&good[..good.len() - 1]).is_err(), "truncated"); + let mut trailing = good.clone(); + trailing.push(0); + assert!(IdSet::decode(&trailing).is_err()); + assert!(IdSet::decode(&[1, 3, 10, 0, 5]).is_err(), "a zero gap means a duplicate"); + assert!(IdSet::decode(&[9, 0]).is_err(), "newer format"); + assert!(IdSet::decode(&[0, 0]).is_err(), "version zero"); + // count far beyond the limit + let mut huge = vec![1]; + put_varint(&mut huge, MAX_SET_IDS as u64 + 1); + assert!(matches!(IdSet::decode(&huge), Err(PortableError::LimitExceeded(_)))); + // an id above u32 + let mut big = vec![1, 1]; + put_varint(&mut big, u32::MAX as u64 + 1); + assert!(IdSet::decode(&big).is_err()); + // an over-long varint must not loop or overflow + assert!(IdSet::decode(&[1, 1, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80]).is_err()); + } + + #[test] + fn kinds_are_namespaced() { + assert!(valid_kind("coa:wardrobe")); + assert!(valid_kind("mod:foo:mounts")); + assert!(!valid_kind("wardrobe")); + assert!(!valid_kind("Coa:Wardrobe")); + assert!(!valid_kind("")); + } +} diff --git a/crates/coa-core/src/portable/error.rs b/crates/coa-core/src/portable/error.rs new file mode 100644 index 0000000..0bf4127 --- /dev/null +++ b/crates/coa-core/src/portable/error.rs @@ -0,0 +1,50 @@ +use super::ids::CharacterId; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum PortableError { + #[error("database: {0}")] + Sqlite(#[from] rusqlite::Error), + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("json: {0}")] + Json(#[from] serde_json::Error), + + #[error("character {0} already exists")] + DuplicateCharacter(CharacterId), + #[error("character {0} does not exist")] + UnknownCharacter(CharacterId), + #[error("revision {revision} of character {character} does not exist (it may have been pruned)")] + UnknownRevision { character: CharacterId, revision: u64 }, + #[error("stale revision: the caller expected {expected} but the canonical revision is {current}")] + StaleRevision { expected: u64, current: u64 }, + #[error("a character's ruleset cannot change ({from} -> {to})")] + RulesetChange { from: String, to: String }, + #[error("the snapshot belongs to character {found}, not {expected}")] + WrongCharacter { expected: CharacterId, found: CharacterId }, + + #[error("local character {local_guid} on server {server_id} is already bound to another portable character")] + LocalGuidTaken { server_id: String, local_guid: u32 }, + #[error("local item {local_item_guid} on server {server_id} is mapped twice for one character")] + ItemGuidConflict { server_id: String, local_item_guid: u32 }, + + #[error("invalid data: {0}")] + Invalid(String), + #[error("limit exceeded: {0}")] + LimitExceeded(String), + #[error("stored snapshot is damaged: {0}")] + CorruptSnapshot(String), + #[error("data format version {found} is newer than the supported version {supported}")] + UnsupportedFormat { found: u32, supported: u32 }, + #[error("the portable database was written by a newer Manager (schema {found}, this Manager understands {supported})")] + NewerDatabase { found: i64, supported: i64 }, + #[error("this file is not a Manager portable database")] + NotPortableDatabase, +} + +impl From for crate::Error { + fn from(e: PortableError) -> Self { + crate::Error::Invalid(e.to_string()) + } +} diff --git a/crates/coa-core/src/portable/fixtures.rs b/crates/coa-core/src/portable/fixtures.rs new file mode 100644 index 0000000..ce62186 --- /dev/null +++ b/crates/coa-core/src/portable/fixtures.rs @@ -0,0 +1,215 @@ +//! Deterministic test characters. Ids are fixed UUIDv7 values so two calls build equal characters. + +use std::collections::BTreeMap; + +use uuid::Uuid; + +use super::ids::{CharacterId, ContentId, PortableItemId, PortablePetId}; +use super::model::*; +use super::versions::PORTABLE_CHARACTER_FORMAT_VERSION; + +pub fn id7(n: u64) -> Uuid { + let mut random = [0u8; 10]; + random[..8].copy_from_slice(&n.to_be_bytes()); + uuid::Builder::from_unix_timestamp_millis(1_790_000_000_000 + n, &random).into_uuid() +} + +pub fn character_id(n: u64) -> CharacterId { + CharacterId::from_uuid(id7(n)).unwrap() +} + +fn item_id(n: u64) -> PortableItemId { + PortableItemId::from_uuid(id7(10_000 + n)).unwrap() +} + +fn content(kind: &str, id: u64) -> ContentId { + ContentId::new("coa", kind, id).unwrap() +} + +fn empty_progression(level: u8) -> Progression { + Progression { + level, + xp: 0, + money: 0, + honor: Honor { arena_points: 0, total_honor: 0, today_honor: 0, yesterday_honor: 0, total_kills: 0, today_kills: 0, yesterday_kills: 0 }, + known_currencies: 0, + chosen_title: 0, + known_titles: "0 0 0 0 0 0".into(), + explored_zones: "0 0 0 0".into(), + taxi_mask: "0 0 0 0".into(), + bank_slots: 0, + stable_slots: 0, + watched_faction: 0, + action_bars_mask: 0, + } +} + +fn empty_build() -> Build { + Build { + spells: Vec::new(), + talents: Vec::new(), + skills: Vec::new(), + glyphs: Vec::new(), + talent_groups_count: 1, + active_talent_group: 0, + extra_bonus_talent_count: 0, + reset_talents_cost: 0, + } +} + +pub fn naked_level_one() -> PortableCharacter { + PortableCharacter { + format_version: PORTABLE_CHARACTER_FORMAT_VERSION, + character_id: character_id(1), + ruleset: Ruleset::Coa, + content_namespace: "coa".into(), + identity: Identity { + name: "Newbie".into(), + race: content("race", 1), + class: content("class", 12), + gender: 0, + appearance: Appearance { skin: 1, face: 2, hair_style: 3, hair_color: 4, facial_style: 0 }, + cosmetic_flags: 0, + }, + progression: empty_progression(1), + build: empty_build(), + items: Vec::new(), + quests: Quests { active: Vec::new(), rewarded: Vec::new() }, + reputation: Vec::new(), + actions: Vec::new(), + pets: Vec::new(), + settings: BTreeMap::new(), + client_data: BTreeMap::new(), + extensions: BTreeMap::new(), + } +} + +fn simple_item(n: u64, container: Option, slot: u8, entry: u64, count: u32) -> PortableItem { + PortableItem { + id: item_id(n), + container, + slot, + entry: content("item", entry), + count, + duration: 0, + charges: vec![0, 0, 0, 0, 0], + flags: 1, + enchantments: Vec::new(), + random_property_id: 0, + durability: 100, + played_time: 3600, + text: None, + creator_name: None, + gift: None, + } +} + +/// A level 80 character shaped like the real data of the audit: ~1,300 spells, 105 reputations, 23 skills, +/// equipment, four bags with contents, enchants, gems, a pet, CoA settings and macros. +pub fn geared_level_eighty() -> PortableCharacter { + let mut c = naked_level_one(); + c.character_id = character_id(2); + c.identity.name = "Geared".into(); + c.identity.class = content("class", 21); + c.progression = empty_progression(80); + c.progression.xp = 12_345; + c.progression.money = 200_060_523; + c.progression.honor.total_honor = 4092; + c.progression.honor.arena_points = 150; + c.progression.known_currencies = 0b1010_0101; + c.progression.chosen_title = 5; + c.progression.explored_zones = (0..128).map(|i| (i * 7919u32).to_string()).collect::>().join(" "); + c.progression.bank_slots = 4; + + c.build.spells = (1..=1284u32).map(|n| (500_000 + n * 3, if n % 5 == 0 { 3 } else { 1 })).collect(); + c.build.skills = (1..=23u32).map(|n| Skill { skill: n * 11, value: 300 + n as u16, max: 450 }).collect(); + c.build.glyphs = vec![GlyphSet { talent_group: 0, glyphs: [1, 2, 3, 4, 5, 6] }]; + c.build.extra_bonus_talent_count = 2; + + // equipment 0..18 + let mut n = 0u64; + let mut equipped_ids = Vec::new(); + for slot in 0..19u8 { + n += 1; + let mut item = simple_item(n, None, slot, 30_000 + n * 17, 1); + item.flags = 1; + item.durability = 90 + slot as u32; + if slot % 3 == 0 { + item.enchantments = vec![ + Enchantment { slot: 0, id: 3_000 + slot as u32, duration: 0, charges: 0 }, + Enchantment { slot: 2, id: 3_878, duration: 0, charges: 0 }, + Enchantment { slot: 3, id: 3_879, duration: 0, charges: 0 }, + ]; + } + if slot == 5 { + item.random_property_id = -57; + item.creator_name = Some("Crafter".into()); + } + equipped_ids.push(item.id); + c.items.push(item); + } + // four bags (slots 19..22), each with six items + for bag_slot in 19..23u8 { + n += 1; + let bag = simple_item(n, None, bag_slot, 41_000 + bag_slot as u64, 1); + let bag_id = bag.id; + c.items.push(bag); + for slot in 0..6u8 { + n += 1; + c.items.push(simple_item(n, Some(bag_id), slot, 50_000 + n, 1 + (slot as u32 % 3) * 4)); + } + } + // backpack + for slot in 23..31u8 { + n += 1; + c.items.push(simple_item(n, None, slot, 60_000 + n, 20)); + } + // bank bag with a book that has text, and a currency token + n += 1; + let mut book = simple_item(n, None, 40, 70_001, 1); + book.text = Some("A very short book.\nSecond line.".into()); + c.items.push(book); + n += 1; + let mut token = simple_item(n, None, 118, 375_250, 12); + token.flags = 0; + c.items.push(token); + n += 1; + let mut wrapped = simple_item(n, None, 41, 5_042, 1); + wrapped.gift = Some(Gift { entry: content("item", 8_000), flags: 8 }); + c.items.push(wrapped); + + c.quests.active = vec![ + ActiveQuest { quest: 12_001, status: 3, explored: false, timer: 0, mob_counts: [3, 0, 0, 0], item_counts: [0; 6], player_count: 0 }, + ActiveQuest { quest: 12_002, status: 1, explored: true, timer: 600, mob_counts: [0; 4], item_counts: [2, 0, 0, 0, 0, 0], player_count: 0 }, + ]; + c.quests.rewarded = vec![12_000, 12_003, 12_004, 99_001]; + c.reputation = (1..=105u32).map(|f| ReputationEntry { faction: f * 3, standing: (f as i32) * 100 - 3_000, flags: if f % 7 == 0 { 17 } else { 1 } }).collect(); + c.actions = (0..12u8).map(|b| ActionButton { spec: 0, button: b, action: 500_003 + b as u32 * 3, kind: 0 }).collect(); + + c.pets = vec![PortablePet { + id: PortablePetId::from_uuid(id7(20_000)).unwrap(), + entry: content("creature", 416), + model_id: 4_449, + created_by_spell: 688, + pet_type: 0, + level: 80, + exp: 1_000, + react_state: 1, + name: "Fuzzy".into(), + renamed: true, + slot: 0, + health: 5_000, + mana: 800, + happiness: 0, + action_bar: "7 2 0 0 1 0 1 1 0".into(), + spells: vec![PetSpell { spell: 3110, active: 1 }, PetSpell { spell: 6307, active: 0 }], + declined_names: None, + }]; + + c.settings.insert("core.ascension_active_spec".into(), vec![54]); + c.settings.insert("core.ascension_starter".into(), vec![1]); + c.settings.insert("core.ascension_build.54".into(), vec![3, 56_710, 56_320, 90_101]); + c.settings.insert("core.ascension_bar.54".into(), vec![2, 0, 500_003, 1, 500_006]); + c.client_data.insert(5, ClientBlob { time: 1_790_000_000, data: Bytes(b"macro data \x00\x01\x02 with binary".to_vec()) }); + c.normalized() +} diff --git a/crates/coa-core/src/portable/ids.rs b/crates/coa-core/src/portable/ids.rs new file mode 100644 index 0000000..7f3e899 --- /dev/null +++ b/crates/coa-core/src/portable/ids.rs @@ -0,0 +1,229 @@ +//! Identifiers. Everything that must be globally unique is a UUIDv7 (time-ordered, generated locally, never an +//! auto-increment). Numeric ids of game content are never global: they live in a namespace. + +use std::fmt; +use std::str::FromStr; + +use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; +use uuid::Uuid; + +use super::error::{PortableError, Result}; + +macro_rules! uuid7_id { + ($(#[$meta:meta])* $name:ident) => { + $(#[$meta])* + #[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] + pub struct $name(Uuid); + + impl $name { + /// A new, time-ordered id. + pub fn new() -> Self { + Self(Uuid::now_v7()) + } + + /// Wrap an existing UUID. Only version-7 ids are accepted: an id from outside this system that is not + /// time-ordered is a sign of a foreign or forged source. + pub fn from_uuid(uuid: Uuid) -> Result { + if uuid.get_version_num() != 7 || uuid.get_variant() != uuid::Variant::RFC4122 { + return Err(PortableError::Invalid(format!("{} is not a UUIDv7", uuid))); + } + Ok(Self(uuid)) + } + + pub fn as_uuid(&self) -> Uuid { + self.0 + } + } + + impl Default for $name { + fn default() -> Self { + Self::new() + } + } + + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0.hyphenated()) + } + } + + impl fmt::Debug for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}({})", stringify!($name), self.0.hyphenated()) + } + } + + impl FromStr for $name { + type Err = PortableError; + fn from_str(s: &str) -> Result { + let uuid = Uuid::parse_str(s).map_err(|_| PortableError::Invalid(format!("{s:?} is not a UUID")))?; + Self::from_uuid(uuid) + } + } + + impl Serialize for $name { + fn serialize(&self, s: S) -> std::result::Result { + s.collect_str(self) + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize>(d: D) -> std::result::Result { + let text = String::deserialize(d)?; + text.parse().map_err(de::Error::custom) + } + } + }; +} + +uuid7_id!( + /// The global identity of a portable character. Unlike the local AzerothCore guid it is the same on every realm. + CharacterId +); +uuid7_id!( + /// The owner's profile (their characters and collections). + ProfileId +); +uuid7_id!( + /// The global identity of one item of a portable character. Local item guids differ per realm; this does not. + PortableItemId +); +uuid7_id!( + /// The global identity of one pet of a portable character. + PortablePetId +); + +/// A reference to a piece of game content that cannot be mistaken for another namespace's numbering: +/// `core:wotlk:item:19019`, `coa:wardrobe:10581`, `mod::item:7`. +/// +/// The textual form is `::` where the namespace may itself contain colons; the last two +/// segments are always the kind and the numeric id. +#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ContentId { + namespace: String, + kind: String, + id: u64, +} + +const MAX_SEGMENT: usize = 48; + +fn valid_segment(s: &str) -> bool { + !s.is_empty() && s.len() <= MAX_SEGMENT && s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-' || b == b'.') +} + +/// A namespace is one or more segments joined by colons. +pub fn valid_namespace(ns: &str) -> bool { + ns.len() <= 96 && ns.split(':').all(valid_segment) +} + +impl ContentId { + pub fn new(namespace: &str, kind: &str, id: u64) -> Result { + if !valid_namespace(namespace) || !valid_segment(kind) { + return Err(PortableError::Invalid(format!("invalid content id {namespace}:{kind}:{id}"))); + } + Ok(Self { namespace: namespace.to_string(), kind: kind.to_string(), id }) + } + + pub fn namespace(&self) -> &str { + &self.namespace + } + pub fn kind(&self) -> &str { + &self.kind + } + pub fn id(&self) -> u64 { + self.id + } +} + +impl fmt::Display for ContentId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}:{}:{}", self.namespace, self.kind, self.id) + } +} + +impl fmt::Debug for ContentId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "ContentId({self})") + } +} + +impl FromStr for ContentId { + type Err = PortableError; + fn from_str(s: &str) -> Result { + let bad = || PortableError::Invalid(format!("{s:?} is not a content id (namespace:kind:id)")); + let (rest, id) = s.rsplit_once(':').ok_or_else(bad)?; + let (namespace, kind) = rest.rsplit_once(':').ok_or_else(bad)?; + // `u64::from_str` accepts a leading '+', which would make two spellings of one id. + if id.is_empty() || !id.bytes().all(|b| b.is_ascii_digit()) { + return Err(bad()); + } + let id: u64 = id.parse().map_err(|_| bad())?; + Self::new(namespace, kind, id) + } +} + +impl Serialize for ContentId { + fn serialize(&self, s: S) -> std::result::Result { + s.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for ContentId { + fn deserialize>(d: D) -> std::result::Result { + let text = String::deserialize(d)?; + text.parse().map_err(de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ids_are_uuid_v7_and_strictly_time_ordered() { + let ids: Vec = (0..2000).map(|_| CharacterId::new()).collect(); + assert!(ids.iter().all(|id| id.as_uuid().get_version_num() == 7)); + assert!(ids.windows(2).all(|w| w[0] < w[1]), "ids generated in sequence must sort in sequence"); + let mut unique = ids.clone(); + unique.sort(); + unique.dedup(); + assert_eq!(unique.len(), ids.len()); + } + + #[test] + fn only_uuid_v7_is_accepted_from_text() { + let good = CharacterId::new(); + assert_eq!(good.to_string().parse::().unwrap(), good); + assert!(Uuid::new_v4().to_string().parse::().is_err(), "a v4 UUID is not a portable id"); + assert!(Uuid::nil().to_string().parse::().is_err()); + assert!("not-a-uuid".parse::().is_err()); + } + + #[test] + fn content_ids_keep_their_namespace() { + for text in ["core:wotlk:item:19019", "coa:wardrobe:10581", "mod:craftsmans-codex:item:7", "coa:class:12"] { + let id: ContentId = text.parse().unwrap(); + assert_eq!(id.to_string(), text); + } + let id: ContentId = "core:wotlk:item:19019".parse().unwrap(); + assert_eq!((id.namespace(), id.kind(), id.id()), ("core:wotlk", "item", 19019)); + let other: ContentId = "mod:x:item:19019".parse().unwrap(); + assert_ne!(id, other, "the same number in another namespace is a different thing"); + } + + #[test] + fn malformed_content_ids_are_refused() { + for text in ["", "item:1", "a:b", "a:b:c", "a:b:-1", "a:b:+1", "A:b:1", "a b:c:1", "a::1", ":b:1", "a:b:18446744073709551616"] { + assert!(text.parse::().is_err(), "{text:?} must not parse"); + } + } + + #[test] + fn ids_serialise_as_plain_strings() { + let id = CharacterId::new(); + let json = serde_json::to_string(&id).unwrap(); + assert_eq!(json, format!("\"{id}\"")); + assert_eq!(serde_json::from_str::(&json).unwrap(), id); + assert!(serde_json::from_str::(&format!("\"{}\"", Uuid::new_v4())).is_err()); + } +} diff --git a/crates/coa-core/src/portable/migrations/001_init.sql b/crates/coa-core/src/portable/migrations/001_init.sql new file mode 100644 index 0000000..fda742a --- /dev/null +++ b/crates/coa-core/src/portable/migrations/001_init.sql @@ -0,0 +1,77 @@ +-- Portable store, schema 1. Local to this Manager; realm databases are never touched. + +CREATE TABLE profile ( + profile_id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + format_version INTEGER NOT NULL +) STRICT; + +CREATE TABLE character ( + character_id TEXT PRIMARY KEY, + profile_id TEXT NOT NULL REFERENCES profile(profile_id), + ruleset TEXT NOT NULL CHECK (ruleset IN ('coa', 'wildcard')), + name TEXT NOT NULL, + race TEXT NOT NULL, + class TEXT NOT NULL, + gender INTEGER NOT NULL, + level INTEGER NOT NULL, + revision INTEGER NOT NULL CHECK (revision >= 1), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + archived INTEGER NOT NULL DEFAULT 0 CHECK (archived IN (0, 1)) +) STRICT; +CREATE INDEX character_profile ON character(profile_id); + +-- One row per kept revision. The newest row is the canonical state (character.revision points at it). +CREATE TABLE snapshot ( + character_id TEXT NOT NULL REFERENCES character(character_id) ON DELETE CASCADE, + revision INTEGER NOT NULL CHECK (revision >= 1), + snapshot_format_version INTEGER NOT NULL, + created_at TEXT NOT NULL, + source_server_id TEXT NOT NULL, + content_hash BLOB NOT NULL CHECK (length(content_hash) = 32), + uncompressed_size INTEGER NOT NULL, + payload BLOB NOT NULL, + note TEXT, + PRIMARY KEY (character_id, revision) +) STRICT, WITHOUT ROWID; + +-- Which local guid a portable character has on which realm. Internal lookups (checkpoints, imports) go through +-- (server_id, local_guid): a local guid belongs to at most one portable character. +CREATE TABLE character_server_mapping ( + character_id TEXT NOT NULL REFERENCES character(character_id) ON DELETE CASCADE, + server_id TEXT NOT NULL, + local_guid INTEGER NOT NULL, + last_revision INTEGER NOT NULL, + state TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (character_id, server_id), + UNIQUE (server_id, local_guid) +) STRICT; + +CREATE TABLE item_mapping ( + character_id TEXT NOT NULL, + server_id TEXT NOT NULL, + portable_item_id TEXT NOT NULL, + local_item_guid INTEGER NOT NULL, + PRIMARY KEY (character_id, server_id, portable_item_id), + UNIQUE (character_id, server_id, local_item_guid), + FOREIGN KEY (character_id, server_id) REFERENCES character_server_mapping(character_id, server_id) ON DELETE CASCADE +) STRICT; + +CREATE TABLE collection ( + profile_id TEXT NOT NULL REFERENCES profile(profile_id), + kind TEXT NOT NULL, + collection_revision INTEGER NOT NULL CHECK (collection_revision >= 1), + collection_hash BLOB NOT NULL CHECK (length(collection_hash) = 32), + format_version INTEGER NOT NULL, + item_count INTEGER NOT NULL, + updated_at TEXT NOT NULL, + payload BLOB NOT NULL, + PRIMARY KEY (profile_id, kind) +) STRICT; + +CREATE TABLE setting ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +) STRICT; diff --git a/crates/coa-core/src/portable/mod.rs b/crates/coa-core/src/portable/mod.rs new file mode 100644 index 0000000..7d4344f --- /dev/null +++ b/crates/coa-core/src/portable/mod.rs @@ -0,0 +1,24 @@ +//! Portable characters: the local canonical storage of Phase 1. +//! +//! A portable character is owned by this Manager, not by any realm. Realm databases only ever hold temporary working +//! copies. This module contains only the Manager-side model and store; it does not read or write any realm database +//! (that is Phase 2 onwards) and knows nothing about the network. +//! +//! See `docs/PORTABLE_CHARACTERS_AUDIT.md` for the reasoning and `docs/PORTABLE_FORMAT.md` for the format rules. + +pub mod collection; +pub mod error; +pub mod ids; +pub mod model; +pub mod snapshot; +pub mod store; +pub mod versions; + +#[cfg(test)] +pub(crate) mod fixtures; + +pub use collection::IdSet; +pub use error::{PortableError, Result}; +pub use ids::{CharacterId, ContentId, PortableItemId, PortablePetId, ProfileId}; +pub use model::{PortableCharacter, Ruleset}; +pub use store::Store; diff --git a/crates/coa-core/src/portable/model.rs b/crates/coa-core/src/portable/model.rs new file mode 100644 index 0000000..2111ab6 --- /dev/null +++ b/crates/coa-core/src/portable/model.rs @@ -0,0 +1,640 @@ +//! The portable character: a *semantic* description of what moves between realms (see +//! `docs/PORTABLE_CHARACTERS_AUDIT.md`, sections 5-7). No database row, guid or position of any realm appears here: +//! local guids live in the Manager's mapping tables, and every item/pet carries a global UUIDv7 instead. +//! +//! Rules: +//! * Unknown fields are refused (`deny_unknown_fields`); a change of shape bumps `format_version`. +//! * Numeric ids of game content other than race/class/item/pet entries (spells, quests, factions, skills, ...) are +//! interpreted in `content_namespace`; race, class and the entries of items/pets carry their own namespace. +//! * Data of modules this Manager does not understand is kept as opaque [`Extension`]s and is never dropped. +//! * Everything is bounded (`limits`), because a snapshot may come from another person's Manager. + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use super::error::{PortableError, Result}; +use super::ids::{valid_namespace, CharacterId, ContentId, PortableItemId, PortablePetId}; +use super::versions::PORTABLE_CHARACTER_FORMAT_VERSION; + +pub mod limits { + pub const MAX_NAME_CHARS: usize = 25; + pub const MAX_PET_NAME_CHARS: usize = 21; + pub const MAX_ITEMS: usize = 1_500; + pub const MAX_SLOT: u8 = 149; + pub const MAX_ENCHANT_SLOTS: u8 = 12; + pub const MAX_CHARGES: usize = 5; + pub const MAX_ITEM_TEXT_BYTES: usize = 16 * 1024; + pub const MAX_SPELLS: usize = 20_000; + pub const MAX_TALENTS: usize = 4_096; + pub const MAX_SKILLS: usize = 512; + pub const MAX_GLYPH_SETS: usize = 8; + pub const MAX_REPUTATIONS: usize = 2_048; + pub const MAX_ACTIVE_QUESTS: usize = 512; + pub const MAX_REWARDED_QUESTS: usize = 50_000; + pub const MAX_ACTIONS: usize = 1_024; + pub const MAX_PETS: usize = 64; + pub const MAX_PET_SPELLS: usize = 512; + pub const MAX_SETTINGS: usize = 4_096; + pub const MAX_SETTING_VALUES: usize = 4_096; + pub const MAX_SETTING_SOURCE_BYTES: usize = 128; + pub const MAX_CLIENT_BLOBS: usize = 8; + pub const MAX_CLIENT_BLOB_BYTES: usize = 64 * 1024; + pub const MAX_RAW_TEXT_BYTES: usize = 8 * 1024; + pub const MAX_EXTENSIONS: usize = 64; + pub const MAX_EXTENSION_BYTES: usize = 1024 * 1024; + pub const MAX_EXTENSIONS_TOTAL_BYTES: usize = 4 * 1024 * 1024; + /// What the realm itself allows (`MAX_MONEY_AMOUNT`, `Player.h`). + pub const MAX_MONEY: u32 = 0x7FFF_FFFF - 1; + /// Largest decompressed snapshot accepted (decompression-bomb guard). + pub const MAX_SNAPSHOT_BYTES: usize = 16 * 1024 * 1024; + /// Largest compressed snapshot accepted. + pub const MAX_COMPRESSED_BYTES: usize = 8 * 1024 * 1024; +} + +use limits::*; + +/// A character belongs to exactly one ruleset for its whole life; CoA and Wildcard never mix. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Ruleset { + Coa, + Wildcard, +} + +impl Ruleset { + pub fn as_str(self) -> &'static str { + match self { + Ruleset::Coa => "coa", + Ruleset::Wildcard => "wildcard", + } + } + pub fn parse(s: &str) -> Result { + match s { + "coa" => Ok(Ruleset::Coa), + "wildcard" => Ok(Ruleset::Wildcard), + other => Err(PortableError::Invalid(format!("unknown ruleset {other:?}"))), + } + } +} + +impl std::fmt::Display for Ruleset { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Binary data as base64 text in the serialised form. +#[derive(Clone, PartialEq, Eq, Default)] +pub struct Bytes(pub Vec); + +impl std::fmt::Debug for Bytes { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Bytes({} bytes)", self.0.len()) + } +} + +impl Serialize for Bytes { + fn serialize(&self, s: S) -> std::result::Result { + use base64::Engine; + s.serialize_str(&base64::engine::general_purpose::STANDARD.encode(&self.0)) + } +} + +impl<'de> Deserialize<'de> for Bytes { + fn deserialize>(d: D) -> std::result::Result { + use base64::Engine; + let text = String::deserialize(d)?; + base64::engine::general_purpose::STANDARD + .decode(text.as_bytes()) + .map(Bytes) + .map_err(serde::de::Error::custom) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Appearance { + pub skin: u8, + pub face: u8, + pub hair_style: u8, + pub hair_color: u8, + pub facial_style: u8, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Identity { + pub name: String, + pub race: ContentId, + pub class: ContentId, + pub gender: u8, + pub appearance: Appearance, + /// Only the cosmetic bits of `playerFlags` (helm/cloak visibility); never GM/ghost/AFK bits. + pub cosmetic_flags: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Honor { + pub arena_points: u32, + pub total_honor: u32, + pub today_honor: u32, + pub yesterday_honor: u32, + pub total_kills: u32, + pub today_kills: u16, + pub yesterday_kills: u16, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Progression { + pub level: u8, + pub xp: u32, + /// Copper. + pub money: u32, + pub honor: Honor, + pub known_currencies: u64, + pub chosen_title: u32, + /// Raw `knownTitles` text of the realm (space separated integers). + pub known_titles: String, + /// Raw `exploredZones` text. + pub explored_zones: String, + /// Raw `taximask` text. + pub taxi_mask: String, + pub bank_slots: u8, + pub stable_slots: u8, + pub watched_faction: u32, + /// Which action bars are shown (bitmask); not the bars themselves. + pub action_bars_mask: u8, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Skill { + pub skill: u32, + pub value: u16, + pub max: u16, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GlyphSet { + pub talent_group: u8, + pub glyphs: [u16; 6], +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Build { + /// `(spell id, spec mask)`, ascending by spell id. CoA talents are spells, so this is also the talent state. + pub spells: Vec<(u32, u8)>, + /// Stock talents `(spell id, spec mask)`; empty on CoA, kept for stock-shaped rulesets. + pub talents: Vec<(u32, u8)>, + pub skills: Vec, + pub glyphs: Vec, + pub talent_groups_count: u8, + pub active_talent_group: u8, + pub extra_bonus_talent_count: i32, + pub reset_talents_cost: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Enchantment { + /// 0..12, the realm's enchantment slot. + pub slot: u8, + pub id: u32, + pub duration: u32, + pub charges: u32, +} + +/// A wrapped gift: what is inside the wrapper. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Gift { + pub entry: ContentId, + pub flags: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PortableItem { + pub id: PortableItemId, + /// The bag this item is in; `None` = directly on the character (equipment, bag slots, backpack, bank, keyring, + /// currency tokens). A container must itself be directly on the character. + pub container: Option, + pub slot: u8, + pub entry: ContentId, + pub count: u32, + pub duration: i32, + /// Spell charges, at most five. + pub charges: Vec, + pub flags: u32, + /// Only the non-empty slots, ascending by slot. + pub enchantments: Vec, + /// Negative = suffix. + pub random_property_id: i32, + pub durability: u32, + pub played_time: u32, + pub text: Option, + /// Display name of the crafter; the crafter's guid is never carried (it names a character of another realm). + pub creator_name: Option, + pub gift: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ActiveQuest { + pub quest: u32, + pub status: u8, + pub explored: bool, + pub timer: u32, + pub mob_counts: [u16; 4], + pub item_counts: [u16; 6], + pub player_count: u16, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Quests { + pub active: Vec, + /// Completed quest ids, ascending. Daily/weekly/monthly/seasonal state is realm-clock state and is not carried. + pub rewarded: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReputationEntry { + pub faction: u32, + pub standing: i32, + pub flags: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ActionButton { + pub spec: u8, + pub button: u8, + pub action: u32, + pub kind: u8, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PetSpell { + pub spell: u32, + pub active: u8, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PortablePet { + pub id: PortablePetId, + pub entry: ContentId, + pub model_id: u32, + pub created_by_spell: u32, + /// 0 = summon pet, 1 = hunter pet. + pub pet_type: u8, + pub level: u16, + pub exp: u32, + pub react_state: u8, + pub name: String, + pub renamed: bool, + /// The realm's slot: current / stable 1-4 / not in slot. + pub slot: u8, + pub health: u32, + pub mana: u32, + pub happiness: u32, + pub action_bar: String, + /// Ascending by spell id. + pub spells: Vec, + /// The five declined-name forms (ruRU) when present. + pub declined_names: Option<[String; 5]>, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ClientBlob { + pub time: u32, + pub data: Bytes, +} + +/// Data of a module this Manager may not understand. It is stored and returned byte for byte. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Extension { + pub module_version: String, + pub format_version: u32, + /// Lower-case hex SHA-256 of `payload`; a mismatch means the blob was corrupted. + pub content_hash: String, + pub payload: Bytes, +} + +impl Extension { + pub fn new(module_version: &str, format_version: u32, payload: Vec) -> Self { + Self { + module_version: module_version.to_string(), + format_version, + content_hash: hex::encode(Sha256::digest(&payload)), + payload: Bytes(payload), + } + } + + pub fn is_intact(&self) -> bool { + self.content_hash == hex::encode(Sha256::digest(&self.payload.0)) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PortableCharacter { + pub format_version: u32, + pub character_id: CharacterId, + pub ruleset: Ruleset, + /// Namespace of the plain numeric ids below (spells, quests, factions, skills, glyphs). + pub content_namespace: String, + pub identity: Identity, + pub progression: Progression, + pub build: Build, + pub items: Vec, + pub quests: Quests, + pub reputation: Vec, + pub actions: Vec, + pub pets: Vec, + /// CoA gameplay state (`character_settings`): source -> integer vector. Only allow-listed sources are carried + /// (policy lives with the exporter). + pub settings: BTreeMap>, + /// Per-character client blobs by account-data type (macros = 5). + pub client_data: BTreeMap, + /// Module data by extension namespace (`mod:`), kept even when unsupported by a realm. + pub extensions: BTreeMap, +} + +fn invalid(msg: impl Into) -> Result { + Err(PortableError::Invalid(msg.into())) +} + +fn too_many(what: &str, max: usize) -> Result { + Err(PortableError::LimitExceeded(format!("more than {max} {what}"))) +} + +fn check_text(what: &str, s: &str, max_bytes: usize) -> Result<()> { + if s.len() > max_bytes { + return Err(PortableError::LimitExceeded(format!("{what} is longer than {max_bytes} bytes"))); + } + Ok(()) +} + +fn check_name(what: &str, s: &str, max_chars: usize) -> Result<()> { + let chars = s.chars().count(); + if chars == 0 || chars > max_chars { + return invalid(format!("{what} must have 1 to {max_chars} characters")); + } + if s.chars().any(|c| c.is_control()) || s.trim() != s { + return invalid(format!("{what} contains control characters or surrounding spaces")); + } + Ok(()) +} + +fn ascending_unique(what: &str, ids: impl Iterator) -> Result<()> { + let mut previous: Option = None; + for id in ids { + if previous.is_some_and(|p| p >= id) { + return invalid(format!("{what} must be strictly ascending (call normalize first)")); + } + previous = Some(id); + } + Ok(()) +} + +impl PortableCharacter { + /// Canonical order: everything that is a set is sorted and de-duplicated, so equal characters serialise to equal + /// bytes (and therefore to equal hashes). + pub fn normalized(mut self) -> Self { + self.normalize(); + self + } + + pub fn normalize(&mut self) { + fn merge_masks(list: &mut Vec<(u32, u8)>) { + list.sort_by_key(|e| e.0); + let mut out: Vec<(u32, u8)> = Vec::with_capacity(list.len()); + for (id, mask) in list.drain(..) { + match out.last_mut() { + Some(last) if last.0 == id => last.1 |= mask, + _ => out.push((id, mask)), + } + } + *list = out; + } + merge_masks(&mut self.build.spells); + merge_masks(&mut self.build.talents); + self.build.skills.sort_by_key(|s| s.skill); + self.build.skills.dedup_by_key(|s| s.skill); + self.build.glyphs.sort_by_key(|g| g.talent_group); + self.build.glyphs.dedup_by_key(|g| g.talent_group); + self.items.sort_by(|a, b| { + let key = |i: &PortableItem| (i.container.is_some(), i.container.map(|c| c.as_uuid()), i.slot, i.id.as_uuid()); + key(a).cmp(&key(b)) + }); + for item in &mut self.items { + item.enchantments.sort_by_key(|e| e.slot); + } + self.quests.active.sort_by_key(|q| q.quest); + self.quests.rewarded.sort_unstable(); + self.quests.rewarded.dedup(); + self.reputation.sort_by_key(|r| r.faction); + self.actions.sort_by_key(|a| (a.spec, a.button)); + self.pets.sort_by_key(|p| (p.slot, p.id.as_uuid())); + for pet in &mut self.pets { + pet.spells.sort_by_key(|s| s.spell); + } + } + + /// Structural validation. Run on everything that enters the store or comes from outside. + pub fn validate(&self) -> Result<()> { + if self.format_version != PORTABLE_CHARACTER_FORMAT_VERSION { + return Err(PortableError::UnsupportedFormat { found: self.format_version, supported: PORTABLE_CHARACTER_FORMAT_VERSION }); + } + if !valid_namespace(&self.content_namespace) { + return invalid("content_namespace is not a valid namespace"); + } + + // identity + check_name("character name", &self.identity.name, MAX_NAME_CHARS)?; + if self.identity.gender > 2 { + return invalid("gender must be 0, 1 or 2"); + } + + // progression + let p = &self.progression; + if p.level == 0 { + return invalid("level must be at least 1"); + } + if p.money > MAX_MONEY { + return invalid(format!("money exceeds the realm limit of {MAX_MONEY} copper")); + } + for (what, text) in [("known_titles", &p.known_titles), ("explored_zones", &p.explored_zones), ("taxi_mask", &p.taxi_mask)] { + check_text(what, text, MAX_RAW_TEXT_BYTES)?; + if !text.bytes().all(|b| b.is_ascii_digit() || b == b' ') { + return invalid(format!("{what} may only contain digits and spaces")); + } + } + + // build + let b = &self.build; + if b.spells.len() > MAX_SPELLS { + return too_many("spells", MAX_SPELLS); + } + if b.talents.len() > MAX_TALENTS { + return too_many("talents", MAX_TALENTS); + } + if b.skills.len() > MAX_SKILLS { + return too_many("skills", MAX_SKILLS); + } + if b.glyphs.len() > MAX_GLYPH_SETS { + return too_many("glyph sets", MAX_GLYPH_SETS); + } + ascending_unique("spells", b.spells.iter().map(|e| e.0))?; + ascending_unique("talents", b.talents.iter().map(|e| e.0))?; + ascending_unique("skills", b.skills.iter().map(|e| e.skill))?; + ascending_unique("glyph sets", b.glyphs.iter().map(|e| e.talent_group))?; + + self.validate_items()?; + + // quests, reputation, actions + if self.quests.active.len() > MAX_ACTIVE_QUESTS { + return too_many("active quests", MAX_ACTIVE_QUESTS); + } + if self.quests.rewarded.len() > MAX_REWARDED_QUESTS { + return too_many("completed quests", MAX_REWARDED_QUESTS); + } + ascending_unique("active quests", self.quests.active.iter().map(|q| q.quest))?; + ascending_unique("completed quests", self.quests.rewarded.iter().copied())?; + if self.reputation.len() > MAX_REPUTATIONS { + return too_many("reputations", MAX_REPUTATIONS); + } + ascending_unique("reputations", self.reputation.iter().map(|r| r.faction))?; + if self.actions.len() > MAX_ACTIONS { + return too_many("action buttons", MAX_ACTIONS); + } + ascending_unique("action buttons", self.actions.iter().map(|a| (a.spec, a.button)))?; + + // pets + if self.pets.len() > MAX_PETS { + return too_many("pets", MAX_PETS); + } + let mut pet_ids = HashSet::new(); + for pet in &self.pets { + if !pet_ids.insert(pet.id) { + return invalid("two pets have the same id"); + } + check_name("pet name", &pet.name, MAX_PET_NAME_CHARS)?; + check_text("pet action bar", &pet.action_bar, MAX_RAW_TEXT_BYTES)?; + if pet.spells.len() > MAX_PET_SPELLS { + return too_many("pet spells", MAX_PET_SPELLS); + } + ascending_unique("pet spells", pet.spells.iter().map(|s| s.spell))?; + if let Some(names) = &pet.declined_names { + for n in names { + check_text("pet declined name", n, 128)?; + } + } + } + + // settings, client blobs, extensions + if self.settings.len() > MAX_SETTINGS { + return too_many("settings sources", MAX_SETTINGS); + } + for (source, values) in &self.settings { + if source.is_empty() || source.len() > MAX_SETTING_SOURCE_BYTES || !source.bytes().all(|c| c.is_ascii_alphanumeric() || b"._-:".contains(&c)) { + return invalid(format!("invalid settings source {source:?}")); + } + if values.len() > MAX_SETTING_VALUES { + return too_many("values in one settings source", MAX_SETTING_VALUES); + } + } + if self.client_data.len() > MAX_CLIENT_BLOBS { + return too_many("client data blobs", MAX_CLIENT_BLOBS); + } + for blob in self.client_data.values() { + if blob.data.0.len() > MAX_CLIENT_BLOB_BYTES { + return Err(PortableError::LimitExceeded(format!("a client data blob is larger than {MAX_CLIENT_BLOB_BYTES} bytes"))); + } + } + if self.extensions.len() > MAX_EXTENSIONS { + return too_many("extensions", MAX_EXTENSIONS); + } + let mut total = 0usize; + for (namespace, ext) in &self.extensions { + if !valid_namespace(namespace) { + return invalid(format!("invalid extension namespace {namespace:?}")); + } + if ext.format_version == 0 || ext.module_version.is_empty() || ext.module_version.len() > 64 { + return invalid(format!("extension {namespace} has an invalid version")); + } + if ext.payload.0.len() > MAX_EXTENSION_BYTES { + return Err(PortableError::LimitExceeded(format!("extension {namespace} is larger than {MAX_EXTENSION_BYTES} bytes"))); + } + if !ext.is_intact() { + return Err(PortableError::CorruptSnapshot(format!("extension {namespace} does not match its content hash"))); + } + total += ext.payload.0.len(); + } + if total > MAX_EXTENSIONS_TOTAL_BYTES { + return Err(PortableError::LimitExceeded(format!("extensions are larger than {MAX_EXTENSIONS_TOTAL_BYTES} bytes together"))); + } + Ok(()) + } + + fn validate_items(&self) -> Result<()> { + if self.items.len() > MAX_ITEMS { + return too_many("items", MAX_ITEMS); + } + let mut by_id: HashMap = HashMap::with_capacity(self.items.len()); + for item in &self.items { + if by_id.insert(item.id, item).is_some() { + return invalid("two items have the same id"); + } + } + let mut places: BTreeSet<(Option, u8)> = BTreeSet::new(); + for item in &self.items { + if item.slot > MAX_SLOT { + return invalid(format!("item slot {} is out of range", item.slot)); + } + if item.count == 0 { + return invalid("an item stack cannot be empty"); + } + if item.charges.len() > MAX_CHARGES { + return too_many("charges on one item", MAX_CHARGES); + } + if let Some(text) = &item.text { + check_text("item text", text, MAX_ITEM_TEXT_BYTES)?; + } + if let Some(name) = &item.creator_name { + check_name("creator name", name, MAX_NAME_CHARS)?; + } + let mut last_slot: Option = None; + for e in &item.enchantments { + if e.slot >= MAX_ENCHANT_SLOTS || last_slot.is_some_and(|l| l >= e.slot) { + return invalid("enchantment slots must be ascending and below 12"); + } + last_slot = Some(e.slot); + } + if let Some(container) = item.container { + match by_id.get(&container) { + None => return invalid("an item is in a container that is not part of the character"), + Some(c) if c.container.is_some() => return invalid("a container must be directly on the character"), + Some(_) => {} + } + } + if !places.insert((item.container, item.slot)) { + return invalid("two items occupy the same place"); + } + } + Ok(()) + } +} + diff --git a/crates/coa-core/src/portable/snapshot.rs b/crates/coa-core/src/portable/snapshot.rs new file mode 100644 index 0000000..e22fe29 --- /dev/null +++ b/crates/coa-core/src/portable/snapshot.rs @@ -0,0 +1,230 @@ +//! Snapshot encoding: canonical JSON -> SHA-256 -> zstd. +//! +//! * The **content hash** is the SHA-256 of the canonical (normalised) JSON, i.e. of the data, not of the compressed +//! bytes, so it does not depend on the compressor version. +//! * Decoding is hostile-input safe: compressed and decompressed sizes are capped *while reading* +//! (decompression bombs), the format version is checked before the shape, unknown fields are refused, and the +//! hash and every structural limit are verified. + +use std::io::Read; + +use sha2::{Digest, Sha256}; + +use super::error::{PortableError, Result}; +use super::model::{limits::*, PortableCharacter}; +use super::versions::PORTABLE_CHARACTER_FORMAT_VERSION; + +const ZSTD_LEVEL: i32 = 3; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EncodedSnapshot { + pub content_hash: [u8; 32], + pub uncompressed_size: u64, + pub payload: Vec, +} + +pub fn hash_hex(hash: &[u8; 32]) -> String { + hex::encode(hash) +} + +/// The canonical JSON of a character (normalised first). +pub fn canonical_json(model: &PortableCharacter) -> Result> { + let normalized = model.clone().normalized(); + let json = serde_json::to_vec(&normalized)?; + if json.len() > MAX_SNAPSHOT_BYTES { + return Err(PortableError::LimitExceeded(format!("the snapshot is larger than {MAX_SNAPSHOT_BYTES} bytes"))); + } + Ok(json) +} + +pub fn content_hash(model: &PortableCharacter) -> Result<[u8; 32]> { + Ok(Sha256::digest(canonical_json(model)?).into()) +} + +/// Validate, then encode. +pub fn encode(model: &PortableCharacter) -> Result { + let model = model.clone().normalized(); + model.validate()?; + let json = canonical_json(&model)?; + let content_hash: [u8; 32] = Sha256::digest(&json).into(); + let payload = zstd::stream::encode_all(json.as_slice(), ZSTD_LEVEL)?; + if payload.len() > MAX_COMPRESSED_BYTES { + return Err(PortableError::LimitExceeded(format!("the compressed snapshot is larger than {MAX_COMPRESSED_BYTES} bytes"))); + } + Ok(EncodedSnapshot { content_hash, uncompressed_size: json.len() as u64, payload }) +} + +/// Decode and fully verify. `expected_hash` is the hash recorded next to the payload. +pub fn decode(payload: &[u8], expected_hash: Option<&[u8; 32]>) -> Result { + if payload.len() > MAX_COMPRESSED_BYTES { + return Err(PortableError::LimitExceeded(format!("the compressed snapshot is larger than {MAX_COMPRESSED_BYTES} bytes"))); + } + let decoder = zstd::stream::read::Decoder::new(payload).map_err(|e| PortableError::CorruptSnapshot(format!("not a compressed snapshot: {e}")))?; + let mut json = Vec::new(); + // Read one byte more than allowed: if it is there, the payload would have inflated past the cap. + decoder + .take(MAX_SNAPSHOT_BYTES as u64 + 1) + .read_to_end(&mut json) + .map_err(|e| PortableError::CorruptSnapshot(format!("decompression failed: {e}")))?; + if json.len() > MAX_SNAPSHOT_BYTES { + return Err(PortableError::LimitExceeded(format!("the snapshot inflates beyond {MAX_SNAPSHOT_BYTES} bytes"))); + } + if let Some(expected) = expected_hash { + let actual: [u8; 32] = Sha256::digest(&json).into(); + if &actual != expected { + return Err(PortableError::CorruptSnapshot("content hash mismatch".into())); + } + } + decode_json(&json) +} + +/// Decode canonical JSON (already decompressed and, if needed, hash-checked). +pub fn decode_json(json: &[u8]) -> Result { + let value: serde_json::Value = serde_json::from_slice(json)?; + let version = value + .get("format_version") + .and_then(|v| v.as_u64()) + .ok_or_else(|| PortableError::CorruptSnapshot("format_version is missing".into()))?; + if version > PORTABLE_CHARACTER_FORMAT_VERSION as u64 { + return Err(PortableError::UnsupportedFormat { found: version.min(u32::MAX as u64) as u32, supported: PORTABLE_CHARACTER_FORMAT_VERSION }); + } + let model: PortableCharacter = serde_json::from_value(value)?; + model.validate()?; + Ok(model) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::portable::fixtures; + + #[test] + fn roundtrip_is_structurally_equal() { + for model in [fixtures::naked_level_one(), fixtures::geared_level_eighty()] { + let model = model.normalized(); + let encoded = encode(&model).unwrap(); + let back = decode(&encoded.payload, Some(&encoded.content_hash)).unwrap(); + assert_eq!(back, model); + assert_eq!(content_hash(&back).unwrap(), encoded.content_hash); + } + } + + #[test] + fn unsorted_input_is_canonicalised_before_hashing() { + let mut shuffled = fixtures::geared_level_eighty(); + shuffled.build.spells.reverse(); + shuffled.reputation.reverse(); + shuffled.items.reverse(); + let sorted = fixtures::geared_level_eighty(); + assert_eq!(content_hash(&shuffled).unwrap(), content_hash(&sorted).unwrap()); + assert_eq!(encode(&shuffled).unwrap().payload, encode(&sorted).unwrap().payload); + } + + #[test] + fn hash_is_stable_and_sensitive() { + let a = fixtures::geared_level_eighty(); + let mut b = a.clone(); + assert_eq!(content_hash(&a).unwrap(), content_hash(&b).unwrap()); + b.progression.money += 1; + assert_ne!(content_hash(&a).unwrap(), content_hash(&b).unwrap()); + } + + #[test] + fn unknown_extension_survives_byte_for_byte() { + let mut model = fixtures::geared_level_eighty(); + let payload: Vec = (0..=255u8).cycle().take(5000).collect(); + model.extensions.insert("mod:unknown-module".into(), crate::portable::model::Extension::new("9.9.9", 7, payload.clone())); + let encoded = encode(&model).unwrap(); + let back = decode(&encoded.payload, Some(&encoded.content_hash)).unwrap(); + let ext = &back.extensions["mod:unknown-module"]; + assert_eq!(ext.payload.0, payload); + assert_eq!((ext.module_version.as_str(), ext.format_version), ("9.9.9", 7)); + } + + #[test] + fn corrupted_extension_is_refused() { + let mut model = fixtures::geared_level_eighty(); + let mut ext = crate::portable::model::Extension::new("1", 1, vec![1, 2, 3]); + ext.payload.0[0] ^= 0xFF; + model.extensions.insert("mod:broken".into(), ext); + assert!(matches!(encode(&model), Err(PortableError::CorruptSnapshot(_)))); + } + + #[test] + fn wrong_hash_is_refused() { + let encoded = encode(&fixtures::geared_level_eighty()).unwrap(); + let mut wrong = encoded.content_hash; + wrong[0] ^= 1; + assert!(matches!(decode(&encoded.payload, Some(&wrong)), Err(PortableError::CorruptSnapshot(_)))); + } + + #[test] + fn garbage_and_truncation_are_refused() { + assert!(decode(b"definitely not zstd", None).is_err()); + let encoded = encode(&fixtures::geared_level_eighty()).unwrap(); + assert!(decode(&encoded.payload[..encoded.payload.len() / 2], None).is_err()); + } + + #[test] + fn decompression_bomb_is_stopped_at_the_cap() { + // 20 MiB of spaces compress to a few KB; the decoder must refuse instead of inflating it. + let bomb = zstd::stream::encode_all(vec![b' '; MAX_SNAPSHOT_BYTES + 4 * 1024 * 1024].as_slice(), 19).unwrap(); + assert!(bomb.len() < 4096, "the test needs a genuinely small payload, got {}", bomb.len()); + assert!(matches!(decode(&bomb, None), Err(PortableError::LimitExceeded(_)))); + } + + #[test] + fn newer_format_is_refused_before_the_shape_is_checked() { + let mut value = serde_json::to_value(fixtures::geared_level_eighty()).unwrap(); + value["format_version"] = serde_json::json!(PORTABLE_CHARACTER_FORMAT_VERSION + 1); + value["a_field_of_the_future"] = serde_json::json!(true); + let json = serde_json::to_vec(&value).unwrap(); + assert!(matches!(decode_json(&json), Err(PortableError::UnsupportedFormat { .. }))); + } + + #[test] + fn unknown_fields_in_the_current_format_are_refused() { + let mut value = serde_json::to_value(fixtures::geared_level_eighty()).unwrap(); + value["identity"]["secret_flag"] = serde_json::json!(1); + assert!(decode_json(&serde_json::to_vec(&value).unwrap()).is_err()); + } + + #[test] + fn oversized_content_is_refused() { + let mut model = fixtures::geared_level_eighty(); + model.build.spells = (1..=(MAX_SPELLS as u32 + 1)).map(|s| (s, 1)).collect(); + assert!(matches!(encode(&model), Err(PortableError::LimitExceeded(_)))); + let mut model = fixtures::geared_level_eighty(); + model.items[0].text = Some("x".repeat(MAX_ITEM_TEXT_BYTES + 1)); + assert!(matches!(encode(&model), Err(PortableError::LimitExceeded(_)))); + let mut model = fixtures::geared_level_eighty(); + model.progression.money = MAX_MONEY + 1; + assert!(encode(&model).is_err()); + } + + #[test] + fn broken_item_structure_is_refused() { + // an item inside a container that does not exist + let mut model = fixtures::geared_level_eighty(); + let orphan = model.items.iter().position(|i| i.container.is_some()).unwrap(); + model.items[orphan].container = Some(crate::portable::ids::PortableItemId::new()); + assert!(encode(&model).is_err()); + // two items in the same place + let mut model = fixtures::geared_level_eighty(); + let mut copy = model.items[0].clone(); + copy.id = crate::portable::ids::PortableItemId::new(); + model.items.push(copy); + assert!(encode(&model).is_err()); + // duplicate item ids + let mut model = fixtures::geared_level_eighty(); + let dup = model.items[0].clone(); + model.items.push(dup); + assert!(encode(&model).is_err()); + } + + #[test] + fn a_geared_snapshot_is_small() { + let encoded = encode(&fixtures::geared_level_eighty()).unwrap(); + assert!(encoded.payload.len() < 64 * 1024, "{} bytes", encoded.payload.len()); + } +} diff --git a/crates/coa-core/src/portable/store.rs b/crates/coa-core/src/portable/store.rs new file mode 100644 index 0000000..ede7e7f --- /dev/null +++ b/crates/coa-core/src/portable/store.rs @@ -0,0 +1,672 @@ +//! The local canonical store (SQLite, WAL). This is the **source of truth** for portable characters: realms only +//! hold working copies. Every write is one transaction; every mutation of a character is checked against the revision +//! the caller saw (`StaleRevision`), so a stale writer can never overwrite newer state. +//! +//! Nothing here touches a realm database. + +use std::path::{Path, PathBuf}; + +use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior}; + +use super::collection::{valid_kind, IdSet}; +use super::error::{PortableError, Result}; +use super::ids::{CharacterId, PortableItemId, ProfileId}; +use super::model::{PortableCharacter, Ruleset}; +use super::snapshot::{self, EncodedSnapshot}; +use super::versions::{PORTABLE_COLLECTION_FORMAT_VERSION, SNAPSHOT_FORMAT_VERSION}; + +pub const DATABASE_FILE: &str = "portable.db"; +/// `PRAGMA application_id`: "COAP". Refuses to adopt an unrelated SQLite file. +const APPLICATION_ID: i64 = 0x434F_4150; +const MIGRATIONS: &[&str] = &[include_str!("migrations/001_init.sql")]; + +pub const DEFAULT_HISTORY_KEEP: u32 = 20; +pub const MAX_HISTORY_KEEP: u32 = 1_000; + +pub struct Store { + conn: Connection, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CharacterRecord { + pub character_id: CharacterId, + pub profile_id: ProfileId, + pub ruleset: Ruleset, + pub name: String, + pub race: String, + pub class: String, + pub gender: u8, + pub level: u8, + pub revision: u64, + pub created_at: String, + pub updated_at: String, + pub archived: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RevisionInfo { + pub revision: u64, + pub created_at: String, + pub source_server_id: String, + pub content_hash: [u8; 32], + pub uncompressed_size: u64, + pub stored_size: u64, + pub note: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MappingState { + /// Bound, nothing imported yet. + Pending, + /// The character is (or may be) in play on that realm. + Active, + /// The realm's copy equals `last_revision`. + Synced, + /// The realm's copy is older than canonical. + Stale, +} + +impl MappingState { + pub fn as_str(self) -> &'static str { + match self { + MappingState::Pending => "pending", + MappingState::Active => "active", + MappingState::Synced => "synced", + MappingState::Stale => "stale", + } + } + fn parse(s: &str) -> Result { + Ok(match s { + "pending" => MappingState::Pending, + "active" => MappingState::Active, + "synced" => MappingState::Synced, + "stale" => MappingState::Stale, + other => return Err(PortableError::Invalid(format!("unknown mapping state {other:?}"))), + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MappingRecord { + pub character_id: CharacterId, + pub server_id: String, + pub local_guid: u32, + pub last_revision: u64, + pub state: MappingState, + pub updated_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CollectionInfo { + pub kind: String, + pub revision: u64, + pub hash: [u8; 32], + pub count: usize, + pub updated_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CollectionMerge { + /// `false` when the incoming set added nothing: no revision was created. + pub changed: bool, + pub added: usize, + /// `None` only when nothing is stored and nothing was added. + pub info: Option, +} + +fn now() -> String { + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) +} + +pub fn valid_server_id(id: &str) -> bool { + !id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_.:".contains(&b)) +} + +fn check_server_id(id: &str) -> Result<()> { + if valid_server_id(id) { + Ok(()) + } else { + Err(PortableError::Invalid(format!("invalid server id {id:?}"))) + } +} + +fn hash_from_blob(blob: Vec) -> Result<[u8; 32]> { + blob.try_into().map_err(|_| PortableError::CorruptSnapshot("a stored hash is not 32 bytes".into())) +} + +impl Store { + /// Open (creating it if necessary) `/portable.db`. + pub fn open(dir: &Path) -> Result { + std::fs::create_dir_all(dir)?; + Store::open_file(&dir.join(DATABASE_FILE)) + } + + pub fn open_file(file: &Path) -> Result { + let conn = Connection::open(file)?; + // WAL keeps readers out of writers' way and survives a crash mid-write; FULL sync keeps the canonical state + // durable even on power loss. + conn.pragma_update_and_check(None, "journal_mode", "WAL", |r| r.get::<_, String>(0))?; + conn.pragma_update(None, "synchronous", "FULL")?; + Store::init(conn) + } + + pub fn open_in_memory() -> Result { + Store::init(Connection::open_in_memory()?) + } + + fn init(conn: Connection) -> Result { + conn.pragma_update(None, "foreign_keys", true)?; + conn.busy_timeout(std::time::Duration::from_secs(5))?; + let mut store = Store { conn }; + store.migrate()?; + Ok(store) + } + + fn migrate(&mut self) -> Result<()> { + let version: i64 = self.conn.pragma_query_value(None, "user_version", |r| r.get(0))?; + let application: i64 = self.conn.pragma_query_value(None, "application_id", |r| r.get(0))?; + let supported = MIGRATIONS.len() as i64; + if application != APPLICATION_ID { + let objects: i64 = self.conn.query_row("SELECT count(*) FROM sqlite_master", [], |r| r.get(0))?; + if application != 0 || version != 0 || objects != 0 { + return Err(PortableError::NotPortableDatabase); + } + } + if version > supported { + return Err(PortableError::NewerDatabase { found: version, supported }); + } + for next in version..supported { + let tx = self.conn.transaction()?; + tx.execute_batch(MIGRATIONS[next as usize])?; + tx.pragma_update(None, "application_id", APPLICATION_ID)?; + tx.pragma_update(None, "user_version", next + 1)?; + tx.commit()?; + } + Ok(()) + } + + pub fn schema_version(&self) -> Result { + Ok(self.conn.pragma_query_value(None, "user_version", |r| r.get(0))?) + } + + fn write_tx(&mut self) -> Result> { + Ok(self.conn.transaction_with_behavior(TransactionBehavior::Immediate)?) + } + + // ---- settings ------------------------------------------------------------------------------------------- + + /// How many revisions of one character are kept (the newest ones). + pub fn history_keep(&self) -> Result { + let value: Option = self.conn.query_row("SELECT value FROM setting WHERE key = 'history_keep'", [], |r| r.get(0)).optional()?; + Ok(value.and_then(|v| v.parse().ok()).filter(|n| (1..=MAX_HISTORY_KEEP).contains(n)).unwrap_or(DEFAULT_HISTORY_KEEP)) + } + + pub fn set_history_keep(&mut self, keep: u32) -> Result<()> { + if !(1..=MAX_HISTORY_KEEP).contains(&keep) { + return Err(PortableError::Invalid(format!("history_keep must be between 1 and {MAX_HISTORY_KEEP}"))); + } + self.conn.execute("INSERT INTO setting(key, value) VALUES ('history_keep', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", [keep.to_string()])?; + Ok(()) + } + + // ---- profiles ------------------------------------------------------------------------------------------- + + pub fn create_profile(&mut self) -> Result { + let id = ProfileId::new(); + self.conn.execute( + "INSERT INTO profile(profile_id, created_at, format_version) VALUES (?1, ?2, ?3)", + params![id.to_string(), now(), super::versions::PORTABLE_CHARACTER_FORMAT_VERSION], + )?; + Ok(id) + } + + /// The profile of this Manager: the oldest one, created on first use. + pub fn default_profile(&mut self) -> Result { + let tx = self.write_tx()?; + let existing: Option = tx.query_row("SELECT profile_id FROM profile ORDER BY profile_id LIMIT 1", [], |r| r.get(0)).optional()?; + let id = match existing { + Some(text) => text.parse()?, + None => { + let id = ProfileId::new(); + tx.execute( + "INSERT INTO profile(profile_id, created_at, format_version) VALUES (?1, ?2, ?3)", + params![id.to_string(), now(), super::versions::PORTABLE_CHARACTER_FORMAT_VERSION], + )?; + id + } + }; + tx.commit()?; + Ok(id) + } + + // ---- characters ----------------------------------------------------------------------------------------- + + /// Create a character with a **new** UUIDv7 (the one in `model.character_id` is replaced). Revision 1. + pub fn create_character(&mut self, profile: ProfileId, mut model: PortableCharacter, source_server_id: &str) -> Result { + model.character_id = CharacterId::new(); + self.create_character_with_id(profile, model, source_server_id) + } + + /// Create a character that already has its identity (restoring or receiving an existing portable character). + /// An id that exists is refused and nothing is written. + pub fn create_character_with_id(&mut self, profile: ProfileId, model: PortableCharacter, source_server_id: &str) -> Result { + check_server_id(source_server_id)?; + let encoded = snapshot::encode(&model)?; + let id = model.character_id; + let tx = self.write_tx()?; + let exists: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM character WHERE character_id = ?1)", [id.to_string()], |r| r.get(0))?; + if exists { + return Err(PortableError::DuplicateCharacter(id)); + } + let profile_exists: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM profile WHERE profile_id = ?1)", [profile.to_string()], |r| r.get(0))?; + if !profile_exists { + return Err(PortableError::Invalid(format!("profile {profile} does not exist"))); + } + let at = now(); + tx.execute( + "INSERT INTO character(character_id, profile_id, ruleset, name, race, class, gender, level, revision, created_at, updated_at, archived) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1, ?9, ?9, 0)", + params![ + id.to_string(), + profile.to_string(), + model.ruleset.as_str(), + model.identity.name, + model.identity.race.to_string(), + model.identity.class.to_string(), + model.identity.gender, + model.progression.level, + at + ], + )?; + insert_snapshot(&tx, id, 1, &encoded, source_server_id, Some("created"), &at)?; + tx.commit()?; + Ok(id) + } + + pub fn character(&self, id: CharacterId) -> Result { + read_character(&self.conn, id) + } + + pub fn list_characters(&self, profile: ProfileId) -> Result> { + let mut stmt = self.conn.prepare("SELECT character_id FROM character WHERE profile_id = ?1 ORDER BY character_id")?; + let ids: Vec = stmt.query_map([profile.to_string()], |r| r.get(0))?.collect::>()?; + ids.iter().map(|text| read_character(&self.conn, text.parse()?)).collect() + } + + pub fn set_archived(&mut self, id: CharacterId, archived: bool) -> Result<()> { + let changed = self.conn.execute("UPDATE character SET archived = ?2 WHERE character_id = ?1", params![id.to_string(), archived])?; + if changed == 0 { + return Err(PortableError::UnknownCharacter(id)); + } + Ok(()) + } + + /// Store the next canonical revision. `expected_revision` is the revision the caller based its change on; if the + /// canonical revision has moved on, nothing is written and `StaleRevision` is returned. Returns the new revision. + pub fn commit_snapshot(&mut self, id: CharacterId, expected_revision: u64, model: PortableCharacter, source_server_id: &str, note: Option<&str>) -> Result { + check_server_id(source_server_id)?; + if model.character_id != id { + return Err(PortableError::WrongCharacter { expected: id, found: model.character_id }); + } + let encoded = snapshot::encode(&model)?; + let keep = self.history_keep()?; + let tx = self.write_tx()?; + let current = read_character(&tx, id)?; + if current.ruleset != model.ruleset { + return Err(PortableError::RulesetChange { from: current.ruleset.to_string(), to: model.ruleset.to_string() }); + } + if current.revision != expected_revision { + return Err(PortableError::StaleRevision { expected: expected_revision, current: current.revision }); + } + let revision = current.revision + 1; + let at = now(); + insert_snapshot(&tx, id, revision, &encoded, source_server_id, note, &at)?; + update_character(&tx, id, &model, revision, &at)?; + prune_tx(&tx, id, revision, keep)?; + tx.commit()?; + Ok(revision) + } + + /// Make an older revision current **as a new revision** (revision numbers never go down, so freshness stays a + /// plain integer comparison). Returns the new revision. + pub fn rollback_to(&mut self, id: CharacterId, expected_revision: u64, target_revision: u64, source_server_id: &str) -> Result { + check_server_id(source_server_id)?; + let keep = self.history_keep()?; + let tx = self.write_tx()?; + let current = read_character(&tx, id)?; + if current.revision != expected_revision { + return Err(PortableError::StaleRevision { expected: expected_revision, current: current.revision }); + } + let (model, encoded) = read_snapshot_row(&tx, id, target_revision)?; + let revision = current.revision + 1; + let at = now(); + insert_snapshot(&tx, id, revision, &encoded, source_server_id, Some(&format!("rollback to revision {target_revision}")), &at)?; + update_character(&tx, id, &model, revision, &at)?; + prune_tx(&tx, id, revision, keep)?; + tx.commit()?; + Ok(revision) + } + + pub fn list_revisions(&self, id: CharacterId) -> Result> { + read_character(&self.conn, id)?; + let mut stmt = self.conn.prepare( + "SELECT revision, created_at, source_server_id, content_hash, uncompressed_size, length(payload), note + FROM snapshot WHERE character_id = ?1 ORDER BY revision", + )?; + let rows = stmt.query_map([id.to_string()], |r| { + Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?, r.get::<_, Vec>(3)?, r.get::<_, i64>(4)?, r.get::<_, i64>(5)?, r.get::<_, Option>(6)?)) + })?; + let mut out = Vec::new(); + for row in rows { + let (revision, created_at, source_server_id, hash, uncompressed, stored, note) = row?; + out.push(RevisionInfo { + revision: revision as u64, + created_at, + source_server_id, + content_hash: hash_from_blob(hash)?, + uncompressed_size: uncompressed as u64, + stored_size: stored as u64, + note, + }); + } + Ok(out) + } + + /// One stored revision, verified (hash, limits, structure). + pub fn load_snapshot(&self, id: CharacterId, revision: u64) -> Result { + Ok(read_snapshot_row(&self.conn, id, revision)?.0) + } + + pub fn load_current(&self, id: CharacterId) -> Result { + let record = read_character(&self.conn, id)?; + self.load_snapshot(id, record.revision) + } + + /// Remove revisions beyond the history limit. The current revision is never removed. Returns how many went. + pub fn prune(&mut self, id: CharacterId) -> Result { + let keep = self.history_keep()?; + let tx = self.write_tx()?; + let current = read_character(&tx, id)?; + let removed = prune_tx(&tx, id, current.revision, keep)?; + tx.commit()?; + Ok(removed) + } + + // ---- realm mappings ------------------------------------------------------------------------------------- + + /// Record (or update) the local guid of a portable character on a realm. A local guid can belong to one portable + /// character only. Changing the local guid of an existing binding drops that binding's item mappings (they named + /// items of the previous local character). + pub fn bind_server(&mut self, id: CharacterId, server_id: &str, local_guid: u32, last_revision: u64, state: MappingState) -> Result<()> { + check_server_id(server_id)?; + let tx = self.write_tx()?; + let record = read_character(&tx, id)?; + if last_revision > record.revision { + return Err(PortableError::Invalid(format!("revision {last_revision} is newer than the canonical revision {}", record.revision))); + } + let holder: Option = tx + .query_row("SELECT character_id FROM character_server_mapping WHERE server_id = ?1 AND local_guid = ?2", params![server_id, local_guid], |r| r.get(0)) + .optional()?; + if holder.is_some_and(|h| h != id.to_string()) { + return Err(PortableError::LocalGuidTaken { server_id: server_id.to_string(), local_guid }); + } + let previous: Option = tx + .query_row("SELECT local_guid FROM character_server_mapping WHERE character_id = ?1 AND server_id = ?2", params![id.to_string(), server_id], |r| r.get(0)) + .optional()?; + if previous.is_some_and(|p| p != local_guid as i64) { + tx.execute("DELETE FROM item_mapping WHERE character_id = ?1 AND server_id = ?2", params![id.to_string(), server_id])?; + } + tx.execute( + "INSERT INTO character_server_mapping(character_id, server_id, local_guid, last_revision, state, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(character_id, server_id) DO UPDATE SET local_guid = excluded.local_guid, last_revision = excluded.last_revision, + state = excluded.state, updated_at = excluded.updated_at", + params![id.to_string(), server_id, local_guid, last_revision as i64, state.as_str(), now()], + )?; + tx.commit()?; + Ok(()) + } + + /// The portable character a realm's local character belongs to. Checkpoints arrive keyed by the local guid. + pub fn find_by_local(&self, server_id: &str, local_guid: u32) -> Result> { + let found: Option = self + .conn + .query_row("SELECT character_id FROM character_server_mapping WHERE server_id = ?1 AND local_guid = ?2", params![server_id, local_guid], |r| r.get(0)) + .optional()?; + found.map(|text| text.parse()).transpose() + } + + pub fn server_mappings(&self, id: CharacterId) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT server_id, local_guid, last_revision, state, updated_at FROM character_server_mapping WHERE character_id = ?1 ORDER BY server_id", + )?; + let rows = stmt.query_map([id.to_string()], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, String>(3)?, r.get::<_, String>(4)?)))?; + let mut out = Vec::new(); + for row in rows { + let (server_id, guid, revision, state, updated_at) = row?; + out.push(MappingRecord { character_id: id, server_id, local_guid: guid as u32, last_revision: revision as u64, state: MappingState::parse(&state)?, updated_at }); + } + Ok(out) + } + + /// Replace the item mappings of one (character, realm) pair. Every portable item id and every local item guid may + /// appear once. The pair must already be bound with [`Store::bind_server`]. + pub fn set_item_mappings(&mut self, id: CharacterId, server_id: &str, mappings: &[(PortableItemId, u32)]) -> Result<()> { + check_server_id(server_id)?; + let mut portable = std::collections::HashSet::new(); + let mut local = std::collections::HashSet::new(); + for (item, guid) in mappings { + if !portable.insert(*item) { + return Err(PortableError::Invalid(format!("portable item {item} is mapped twice"))); + } + if !local.insert(*guid) { + return Err(PortableError::ItemGuidConflict { server_id: server_id.to_string(), local_item_guid: *guid }); + } + } + let tx = self.write_tx()?; + let bound: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM character_server_mapping WHERE character_id = ?1 AND server_id = ?2)", + params![id.to_string(), server_id], + |r| r.get(0), + )?; + if !bound { + return Err(PortableError::Invalid(format!("character {id} is not bound to server {server_id}"))); + } + tx.execute("DELETE FROM item_mapping WHERE character_id = ?1 AND server_id = ?2", params![id.to_string(), server_id])?; + { + let mut insert = tx.prepare("INSERT INTO item_mapping(character_id, server_id, portable_item_id, local_item_guid) VALUES (?1, ?2, ?3, ?4)")?; + for (item, guid) in mappings { + insert.execute(params![id.to_string(), server_id, item.to_string(), guid])?; + } + } + tx.commit()?; + Ok(()) + } + + pub fn item_mappings(&self, id: CharacterId, server_id: &str) -> Result> { + let mut stmt = self.conn.prepare("SELECT portable_item_id, local_item_guid FROM item_mapping WHERE character_id = ?1 AND server_id = ?2 ORDER BY local_item_guid")?; + let rows = stmt.query_map(params![id.to_string(), server_id], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?; + let mut out = Vec::new(); + for row in rows { + let (item, guid) = row?; + out.push((item.parse()?, guid as u32)); + } + Ok(out) + } + + // ---- collections ---------------------------------------------------------------------------------------- + + /// `stored = stored U incoming`. Nothing is ever removed. When the union equals what is stored, no revision is + /// created and the hash stays the same. + pub fn merge_collection(&mut self, profile: ProfileId, kind: &str, incoming: &IdSet) -> Result { + if !valid_kind(kind) { + return Err(PortableError::Invalid(format!("invalid collection kind {kind:?}"))); + } + let tx = self.write_tx()?; + let profile_exists: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM profile WHERE profile_id = ?1)", [profile.to_string()], |r| r.get(0))?; + if !profile_exists { + return Err(PortableError::Invalid(format!("profile {profile} does not exist"))); + } + let stored = read_collection(&tx, profile, kind)?; + let (current_set, revision) = match &stored { + Some((info, set)) => (set.clone(), info.revision), + None => (IdSet::new(), 0), + }; + let added = current_set.count_new(incoming); + if added == 0 { + tx.commit()?; + return Ok(CollectionMerge { changed: false, added: 0, info: stored.map(|(info, _)| info) }); + } + let merged = current_set.union(incoming); + let hash = merged.hash(kind); + let payload = merged.encode(); + let at = now(); + tx.execute( + "INSERT INTO collection(profile_id, kind, collection_revision, collection_hash, format_version, item_count, updated_at, payload) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT(profile_id, kind) DO UPDATE SET collection_revision = excluded.collection_revision, collection_hash = excluded.collection_hash, + format_version = excluded.format_version, item_count = excluded.item_count, updated_at = excluded.updated_at, payload = excluded.payload", + params![profile.to_string(), kind, (revision + 1) as i64, hash.as_slice(), PORTABLE_COLLECTION_FORMAT_VERSION, merged.len() as i64, at, payload], + )?; + tx.commit()?; + Ok(CollectionMerge { + changed: true, + added, + info: Some(CollectionInfo { kind: kind.to_string(), revision: revision + 1, hash, count: merged.len(), updated_at: at }), + }) + } + + /// Revision, hash and size without loading the set: the "did it change?" check. + pub fn collection_info(&self, profile: ProfileId, kind: &str) -> Result> { + let row = self + .conn + .query_row( + "SELECT collection_revision, collection_hash, item_count, updated_at FROM collection WHERE profile_id = ?1 AND kind = ?2", + params![profile.to_string(), kind], + |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Vec>(1)?, r.get::<_, i64>(2)?, r.get::<_, String>(3)?)), + ) + .optional()?; + row.map(|(revision, hash, count, updated_at)| Ok(CollectionInfo { kind: kind.to_string(), revision: revision as u64, hash: hash_from_blob(hash)?, count: count as usize, updated_at })) + .transpose() + } + + pub fn collection(&self, profile: ProfileId, kind: &str) -> Result> { + read_collection(&self.conn, profile, kind) + } + + pub fn path_of(dir: &Path) -> PathBuf { + dir.join(DATABASE_FILE) + } +} + +fn read_collection(conn: &Connection, profile: ProfileId, kind: &str) -> Result> { + let row = conn + .query_row( + "SELECT collection_revision, collection_hash, item_count, updated_at, payload FROM collection WHERE profile_id = ?1 AND kind = ?2", + params![profile.to_string(), kind], + |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Vec>(1)?, r.get::<_, i64>(2)?, r.get::<_, String>(3)?, r.get::<_, Vec>(4)?)), + ) + .optional()?; + let Some((revision, hash, count, updated_at, payload)) = row else { return Ok(None) }; + let set = IdSet::decode(&payload)?; + let hash = hash_from_blob(hash)?; + if set.hash(kind) != hash || set.len() != count as usize { + return Err(PortableError::CorruptSnapshot(format!("collection {kind} does not match its recorded hash"))); + } + Ok(Some((CollectionInfo { kind: kind.to_string(), revision: revision as u64, hash, count: set.len(), updated_at }, set))) +} + +fn read_character(conn: &Connection, id: CharacterId) -> Result { + let row = conn + .query_row( + "SELECT profile_id, ruleset, name, race, class, gender, level, revision, created_at, updated_at, archived FROM character WHERE character_id = ?1", + [id.to_string()], + |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, String>(3)?, + r.get::<_, String>(4)?, + r.get::<_, u8>(5)?, + r.get::<_, u8>(6)?, + r.get::<_, i64>(7)?, + r.get::<_, String>(8)?, + r.get::<_, String>(9)?, + r.get::<_, bool>(10)?, + )) + }, + ) + .optional()?; + let Some((profile, ruleset, name, race, class, gender, level, revision, created_at, updated_at, archived)) = row else { + return Err(PortableError::UnknownCharacter(id)); + }; + Ok(CharacterRecord { + character_id: id, + profile_id: profile.parse()?, + ruleset: Ruleset::parse(&ruleset)?, + name, + race, + class, + gender, + level, + revision: revision as u64, + created_at, + updated_at, + archived, + }) +} + +fn insert_snapshot(tx: &Transaction<'_>, id: CharacterId, revision: u64, encoded: &EncodedSnapshot, source_server_id: &str, note: Option<&str>, at: &str) -> Result<()> { + tx.execute( + "INSERT INTO snapshot(character_id, revision, snapshot_format_version, created_at, source_server_id, content_hash, uncompressed_size, payload, note) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![id.to_string(), revision as i64, SNAPSHOT_FORMAT_VERSION, at, source_server_id, encoded.content_hash.as_slice(), encoded.uncompressed_size as i64, encoded.payload, note], + )?; + Ok(()) +} + +fn update_character(tx: &Transaction<'_>, id: CharacterId, model: &PortableCharacter, revision: u64, at: &str) -> Result<()> { + tx.execute( + "UPDATE character SET name = ?2, race = ?3, class = ?4, gender = ?5, level = ?6, revision = ?7, updated_at = ?8 WHERE character_id = ?1", + params![id.to_string(), model.identity.name, model.identity.race.to_string(), model.identity.class.to_string(), model.identity.gender, model.progression.level, revision as i64, at], + )?; + Ok(()) +} + +/// Delete everything older than the newest `keep` revisions; `current` is always among the newest. +fn prune_tx(tx: &Transaction<'_>, id: CharacterId, current: u64, keep: u32) -> Result { + let cutoff = current as i64 - keep as i64; + if cutoff < 1 { + return Ok(0); + } + Ok(tx.execute("DELETE FROM snapshot WHERE character_id = ?1 AND revision <= ?2 AND revision < ?3", params![id.to_string(), cutoff, current as i64])?) +} + +/// Read one revision and verify it against its recorded hash. Also returns the re-encoded form so a rollback can store +/// exactly the same payload. +fn read_snapshot_row(conn: &Connection, id: CharacterId, revision: u64) -> Result<(PortableCharacter, EncodedSnapshot)> { + let row = conn + .query_row( + "SELECT snapshot_format_version, content_hash, uncompressed_size, payload FROM snapshot WHERE character_id = ?1 AND revision = ?2", + params![id.to_string(), revision as i64], + |r| Ok((r.get::<_, u32>(0)?, r.get::<_, Vec>(1)?, r.get::<_, i64>(2)?, r.get::<_, Vec>(3)?)), + ) + .optional()?; + let Some((format, hash, size, payload)) = row else { + read_character(conn, id)?; + return Err(PortableError::UnknownRevision { character: id, revision }); + }; + if format > SNAPSHOT_FORMAT_VERSION { + return Err(PortableError::UnsupportedFormat { found: format, supported: SNAPSHOT_FORMAT_VERSION }); + } + let hash = hash_from_blob(hash)?; + let model = snapshot::decode(&payload, Some(&hash))?; + if model.character_id != id { + return Err(PortableError::CorruptSnapshot("a stored snapshot names another character".into())); + } + Ok((model, EncodedSnapshot { content_hash: hash, uncompressed_size: size as u64, payload })) +} + +#[cfg(test)] +mod tests; diff --git a/crates/coa-core/src/portable/store/tests.rs b/crates/coa-core/src/portable/store/tests.rs new file mode 100644 index 0000000..a127893 --- /dev/null +++ b/crates/coa-core/src/portable/store/tests.rs @@ -0,0 +1,455 @@ +use super::*; +use crate::portable::fixtures::{self, character_id}; + +fn fresh() -> (Store, ProfileId) { + let mut store = Store::open_in_memory().unwrap(); + let profile = store.default_profile().unwrap(); + (store, profile) +} + +fn created(store: &mut Store, profile: ProfileId) -> CharacterId { + store.create_character(profile, fixtures::geared_level_eighty(), "realm-a").unwrap() +} + +fn with_money(mut model: PortableCharacter, money: u32) -> PortableCharacter { + model.progression.money = money; + model +} + +#[test] +fn create_character_identity() { + let (mut store, profile) = fresh(); + let model = fixtures::geared_level_eighty(); + let id = store.create_character(profile, model.clone(), "realm-a").unwrap(); + + // a fresh UUIDv7, not the placeholder carried by the model + assert_ne!(id, model.character_id); + assert_eq!(id.as_uuid().get_version_num(), 7); + + let record = store.character(id).unwrap(); + assert_eq!(record.revision, 1); + assert_eq!((record.name.as_str(), record.level, record.ruleset), ("Geared", 80, Ruleset::Coa)); + assert_eq!(record.race, "coa:race:1"); + assert_eq!(record.class, "coa:class:21"); + assert_eq!(record.profile_id, profile); + assert!(!record.archived); + + let loaded = store.load_current(id).unwrap(); + let mut expected = model.normalized(); + expected.character_id = id; + assert_eq!(loaded, expected, "the stored revision 1 is the model that was passed in"); + + let revisions = store.list_revisions(id).unwrap(); + assert_eq!(revisions.len(), 1); + assert_eq!((revisions[0].revision, revisions[0].source_server_id.as_str(), revisions[0].note.as_deref()), (1, "realm-a", Some("created"))); + assert_eq!(store.list_characters(profile).unwrap().len(), 1); +} + +#[test] +fn two_new_characters_never_share_an_id() { + let (mut store, profile) = fresh(); + let a = created(&mut store, profile); + let b = created(&mut store, profile); + assert_ne!(a, b); + assert!(a < b, "ids are time-ordered"); +} + +#[test] +fn revision_increments_monotonically() { + let (mut store, profile) = fresh(); + let id = created(&mut store, profile); + let mut model = store.load_current(id).unwrap(); + for expected in 2..=6u64 { + model.progression.xp += 100; + let revision = store.commit_snapshot(id, expected - 1, model.clone(), "realm-b", Some("checkpoint")).unwrap(); + assert_eq!(revision, expected); + assert_eq!(store.character(id).unwrap().revision, expected); + } + let revisions: Vec = store.list_revisions(id).unwrap().iter().map(|r| r.revision).collect(); + assert_eq!(revisions, vec![1, 2, 3, 4, 5, 6]); + assert_eq!(store.load_current(id).unwrap().progression.xp, model.progression.xp); + assert_eq!(store.character(id).unwrap().level, 80); +} + +#[test] +fn commit_updates_the_summary_row() { + let (mut store, profile) = fresh(); + let id = created(&mut store, profile); + let mut model = store.load_current(id).unwrap(); + model.identity.name = "Renamed".into(); + model.progression.level = 81; + store.commit_snapshot(id, 1, model, "realm-a", None).unwrap(); + let record = store.character(id).unwrap(); + assert_eq!((record.name.as_str(), record.level, record.revision), ("Renamed", 81, 2)); +} + +#[test] +fn stale_revision_is_rejected_and_nothing_is_written() { + let (mut store, profile) = fresh(); + let id = created(&mut store, profile); + let base = store.load_current(id).unwrap(); + + // a fresher state is committed first + store.commit_snapshot(id, 1, with_money(base.clone(), 500), "realm-a", None).unwrap(); + let before = store.list_revisions(id).unwrap(); + + // a writer that still believes revision 1 is current must lose + let result = store.commit_snapshot(id, 1, with_money(base.clone(), 999_999), "realm-b", None); + assert!(matches!(result, Err(PortableError::StaleRevision { expected: 1, current: 2 })), "{result:?}"); + // so must one that claims a revision from the future + let result = store.commit_snapshot(id, 7, with_money(base, 1), "realm-b", None); + assert!(matches!(result, Err(PortableError::StaleRevision { expected: 7, current: 2 })), "{result:?}"); + + assert_eq!(store.list_revisions(id).unwrap(), before, "a rejected commit leaves no trace"); + assert_eq!(store.load_current(id).unwrap().progression.money, 500); + assert_eq!(store.character(id).unwrap().revision, 2); +} + +#[test] +fn two_stores_on_one_file_cannot_both_win() { + let dir = tempfile::tempdir().unwrap(); + let mut a = Store::open(dir.path()).unwrap(); + let mut b = Store::open(dir.path()).unwrap(); + let profile = a.default_profile().unwrap(); + let id = created(&mut a, profile); + let base = b.load_current(id).unwrap(); + + a.commit_snapshot(id, 1, with_money(base.clone(), 10), "realm-a", None).unwrap(); + let late = b.commit_snapshot(id, 1, with_money(base, 20), "realm-b", None); + assert!(matches!(late, Err(PortableError::StaleRevision { .. })), "{late:?}"); + assert_eq!(b.load_current(id).unwrap().progression.money, 10); +} + +#[test] +fn duplicate_uuid_is_rejected_and_the_original_is_untouched() { + let (mut store, profile) = fresh(); + let id = created(&mut store, profile); + let original = store.load_current(id).unwrap(); + + let mut impostor = fixtures::naked_level_one(); + impostor.character_id = id; + let result = store.create_character_with_id(profile, impostor, "realm-x"); + assert!(matches!(result, Err(PortableError::DuplicateCharacter(dup)) if dup == id), "{result:?}"); + + assert_eq!(store.load_current(id).unwrap(), original); + assert_eq!(store.list_revisions(id).unwrap().len(), 1); + assert_eq!(store.list_characters(profile).unwrap().len(), 1); +} + +#[test] +fn a_restored_character_keeps_its_own_identity() { + let (mut store, profile) = fresh(); + let model = fixtures::naked_level_one(); + let id = store.create_character_with_id(profile, model.clone(), "realm-a").unwrap(); + assert_eq!(id, model.character_id); + assert_eq!(id, character_id(1)); +} + +#[test] +fn wrong_character_ruleset_and_unknown_character_are_refused() { + let (mut store, profile) = fresh(); + let id = created(&mut store, profile); + let other = store.create_character(profile, fixtures::naked_level_one(), "realm-a").unwrap(); + + // a snapshot of another character cannot be committed under this id + let foreign = store.load_current(other).unwrap(); + assert!(matches!(store.commit_snapshot(id, 1, foreign, "realm-a", None), Err(PortableError::WrongCharacter { .. }))); + + // CoA and Wildcard never mix + let mut wildcard = store.load_current(id).unwrap(); + wildcard.ruleset = Ruleset::Wildcard; + assert!(matches!(store.commit_snapshot(id, 1, wildcard, "realm-a", None), Err(PortableError::RulesetChange { .. }))); + + let missing = CharacterId::new(); + assert!(matches!(store.character(missing), Err(PortableError::UnknownCharacter(_)))); + assert!(matches!(store.list_revisions(missing), Err(PortableError::UnknownCharacter(_)))); + assert_eq!(store.character(id).unwrap().revision, 1); +} + +#[test] +fn an_invalid_model_is_refused_without_side_effects() { + let (mut store, profile) = fresh(); + let id = created(&mut store, profile); + let mut broken = store.load_current(id).unwrap(); + broken.progression.money = u32::MAX; + assert!(store.commit_snapshot(id, 1, broken, "realm-a", None).is_err()); + assert!(store.commit_snapshot(id, 1, store.load_current(id).unwrap(), "bad server id!", None).is_err()); + assert_eq!(store.character(id).unwrap().revision, 1); + assert_eq!(store.list_revisions(id).unwrap().len(), 1); +} + +#[test] +fn rollback_creates_a_new_revision_with_the_old_state() { + let (mut store, profile) = fresh(); + let id = created(&mut store, profile); + let rev1 = store.load_current(id).unwrap(); + store.commit_snapshot(id, 1, with_money(rev1.clone(), 111), "realm-a", None).unwrap(); + store.commit_snapshot(id, 2, with_money(rev1.clone(), 222), "realm-a", None).unwrap(); + + let new = store.rollback_to(id, 3, 1, "manager").unwrap(); + assert_eq!(new, 4, "revision numbers never go down"); + assert_eq!(store.character(id).unwrap().revision, 4); + assert_eq!(store.load_current(id).unwrap(), rev1, "the state is revision 1's"); + + let revisions = store.list_revisions(id).unwrap(); + assert_eq!(revisions.len(), 4, "history is kept, nothing is rewritten"); + assert_eq!(revisions[3].note.as_deref(), Some("rollback to revision 1")); + assert_eq!(revisions[3].content_hash, revisions[0].content_hash); + assert_eq!(store.load_snapshot(id, 3).unwrap().progression.money, 222, "the state before the rollback is still there"); + + // a rollback is itself a revision: a stale caller cannot roll back on top of newer state + assert!(matches!(store.rollback_to(id, 3, 1, "manager"), Err(PortableError::StaleRevision { .. }))); + assert!(matches!(store.rollback_to(id, 4, 99, "manager"), Err(PortableError::UnknownRevision { revision: 99, .. }))); + assert_eq!(store.character(id).unwrap().revision, 4); +} + +#[test] +fn history_is_capped_and_the_current_revision_survives() { + let (mut store, profile) = fresh(); + assert_eq!(store.history_keep().unwrap(), DEFAULT_HISTORY_KEEP); + store.set_history_keep(3).unwrap(); + assert_eq!(store.history_keep().unwrap(), 3); + let id = created(&mut store, profile); + let mut model = store.load_current(id).unwrap(); + for rev in 1..=9u64 { + model.progression.xp = rev as u32; + store.commit_snapshot(id, rev, model.clone(), "realm-a", None).unwrap(); + } + let revisions: Vec = store.list_revisions(id).unwrap().iter().map(|r| r.revision).collect(); + assert_eq!(revisions, vec![8, 9, 10], "only the newest three remain"); + assert_eq!(store.character(id).unwrap().revision, 10); + assert_eq!(store.load_current(id).unwrap().progression.xp, 9); + assert!(matches!(store.load_snapshot(id, 1), Err(PortableError::UnknownRevision { .. }))); + + // lowering the limit and pruning never removes the current revision + store.set_history_keep(1).unwrap(); + assert_eq!(store.prune(id).unwrap(), 2); + assert_eq!(store.list_revisions(id).unwrap().len(), 1); + assert_eq!(store.load_current(id).unwrap().progression.xp, 9); + assert_eq!(store.prune(id).unwrap(), 0); + + assert!(store.set_history_keep(0).is_err()); + assert!(store.set_history_keep(MAX_HISTORY_KEEP + 1).is_err()); +} + +#[test] +fn a_damaged_snapshot_is_detected_not_trusted() { + let (mut store, profile) = fresh(); + let id = created(&mut store, profile); + // flip bytes in the stored payload + store.conn.execute("UPDATE snapshot SET payload = CAST(substr(payload, 1, 20) || zeroblob(40) AS BLOB) WHERE character_id = ?1", [id.to_string()]).unwrap(); + assert!(store.load_current(id).is_err()); + // a payload that decodes but does not match the recorded hash + let (mut other, profile) = fresh(); + let a = created(&mut other, profile); + let b = other.create_character(profile, fixtures::naked_level_one(), "realm-a").unwrap(); + other.conn.execute( + "UPDATE snapshot SET payload = (SELECT payload FROM snapshot WHERE character_id = ?2) WHERE character_id = ?1", + params![a.to_string(), b.to_string()], + ) + .unwrap(); + assert!(matches!(other.load_current(a), Err(PortableError::CorruptSnapshot(_)))); +} + +#[test] +fn data_survives_reopening_the_file() { + let dir = tempfile::tempdir().unwrap(); + let id; + { + let mut store = Store::open(dir.path()).unwrap(); + let profile = store.default_profile().unwrap(); + id = created(&mut store, profile); + let model = store.load_current(id).unwrap(); + store.commit_snapshot(id, 1, with_money(model, 4242), "realm-a", None).unwrap(); + store.set_history_keep(7).unwrap(); + } + let mut store = Store::open(dir.path()).unwrap(); + assert_eq!(store.character(id).unwrap().revision, 2); + assert_eq!(store.load_current(id).unwrap().progression.money, 4242); + assert_eq!(store.history_keep().unwrap(), 7); + let profile = store.default_profile().unwrap(); + assert_eq!(store.list_characters(profile).unwrap().len(), 1); + assert!(Store::path_of(dir.path()).is_file()); +} + +#[test] +fn migrations_are_idempotent_and_newer_databases_are_refused() { + let dir = tempfile::tempdir().unwrap(); + { + let store = Store::open(dir.path()).unwrap(); + assert_eq!(store.schema_version().unwrap(), MIGRATIONS.len() as i64); + } + // opening again changes nothing + assert_eq!(Store::open(dir.path()).unwrap().schema_version().unwrap(), MIGRATIONS.len() as i64); + + // a database written by a newer Manager is refused, not "migrated" downwards + { + let conn = Connection::open(Store::path_of(dir.path())).unwrap(); + conn.pragma_update(None, "user_version", 99).unwrap(); + } + assert!(matches!(Store::open(dir.path()), Err(PortableError::NewerDatabase { found: 99, .. }))); +} + +#[test] +fn a_foreign_sqlite_file_is_not_adopted() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join(DATABASE_FILE); + { + let conn = Connection::open(&file).unwrap(); + conn.execute_batch("CREATE TABLE somebody_elses (x INTEGER); INSERT INTO somebody_elses VALUES (1);").unwrap(); + } + assert!(matches!(Store::open(dir.path()), Err(PortableError::NotPortableDatabase))); + let conn = Connection::open(&file).unwrap(); + let rows: i64 = conn.query_row("SELECT count(*) FROM somebody_elses", [], |r| r.get(0)).unwrap(); + assert_eq!(rows, 1, "the foreign file is left alone"); +} + +#[test] +fn foreign_keys_are_enforced() { + let (store, _) = fresh(); + let result = store.conn.execute( + "INSERT INTO character_server_mapping(character_id, server_id, local_guid, last_revision, state, updated_at) VALUES (?1, 'r', 1, 1, 'pending', 'x')", + [CharacterId::new().to_string()], + ); + assert!(result.is_err(), "a mapping for a character that does not exist must be impossible"); +} + +#[test] +fn mapping_unique_constraints() { + let (mut store, profile) = fresh(); + let a = created(&mut store, profile); + let b = store.create_character(profile, fixtures::naked_level_one(), "realm-a").unwrap(); + + store.bind_server(a, "realm-a", 154, 1, MappingState::Active).unwrap(); + assert_eq!(store.find_by_local("realm-a", 154).unwrap(), Some(a)); + assert_eq!(store.find_by_local("realm-a", 155).unwrap(), None); + assert_eq!(store.find_by_local("realm-z", 154).unwrap(), None, "guids are per realm"); + + // the same local guid cannot be claimed by another portable character + let taken = store.bind_server(b, "realm-a", 154, 1, MappingState::Active); + assert!(matches!(taken, Err(PortableError::LocalGuidTaken { local_guid: 154, .. })), "{taken:?}"); + assert_eq!(store.find_by_local("realm-a", 154).unwrap(), Some(a)); + + // but the same number on another realm is a different character + store.bind_server(b, "realm-b", 154, 1, MappingState::Pending).unwrap(); + assert_eq!(store.find_by_local("realm-b", 154).unwrap(), Some(b)); + + // one character, many realms, different local guids (the point of the whole design) + store.bind_server(a, "realm-b", 8421, 1, MappingState::Pending).unwrap(); + store.bind_server(a, "realm-c", 391, 1, MappingState::Pending).unwrap(); + let guids: Vec<(String, u32)> = store.server_mappings(a).unwrap().into_iter().map(|m| (m.server_id, m.local_guid)).collect(); + assert_eq!(guids, vec![("realm-a".into(), 154), ("realm-b".into(), 8421), ("realm-c".into(), 391)]); + + // rebinding updates in place + store.bind_server(a, "realm-a", 154, 1, MappingState::Synced).unwrap(); + assert_eq!(store.server_mappings(a).unwrap()[0].state, MappingState::Synced); + + // a binding cannot name a revision that does not exist yet, nor an unknown character + assert!(store.bind_server(a, "realm-a", 154, 5, MappingState::Synced).is_err()); + assert!(matches!(store.bind_server(CharacterId::new(), "realm-a", 1, 1, MappingState::Pending), Err(PortableError::UnknownCharacter(_)))); +} + +#[test] +fn item_mapping_unique_per_character_and_server() { + let (mut store, profile) = fresh(); + let id = created(&mut store, profile); + let model = store.load_current(id).unwrap(); + let items: Vec = model.items.iter().take(4).map(|i| i.id).collect(); + + // an item mapping needs a server binding first + assert!(store.set_item_mappings(id, "realm-a", &[(items[0], 1)]).is_err()); + store.bind_server(id, "realm-a", 154, 1, MappingState::Active).unwrap(); + + store.set_item_mappings(id, "realm-a", &[(items[0], 9001), (items[1], 9002), (items[2], 9003)]).unwrap(); + assert_eq!(store.item_mappings(id, "realm-a").unwrap(), vec![(items[0], 9001), (items[1], 9002), (items[2], 9003)]); + + // UNIQUE(character_id, server_id, local_item_guid): one local item guid cannot stand for two portable items + let clash = store.set_item_mappings(id, "realm-a", &[(items[0], 9001), (items[1], 9001)]); + assert!(matches!(clash, Err(PortableError::ItemGuidConflict { local_item_guid: 9001, .. })), "{clash:?}"); + // and the same portable item cannot have two local guids + assert!(store.set_item_mappings(id, "realm-a", &[(items[0], 1), (items[0], 2)]).is_err()); + // a failed replacement leaves the previous mappings intact + assert_eq!(store.item_mappings(id, "realm-a").unwrap().len(), 3); + + // the database itself enforces the same rule, not just the Rust check + let direct = store.conn.execute( + "INSERT INTO item_mapping(character_id, server_id, portable_item_id, local_item_guid) VALUES (?1, 'realm-a', ?2, 9001)", + params![id.to_string(), items[3].to_string()], + ); + assert!(direct.is_err(), "UNIQUE(character_id, server_id, local_item_guid)"); + + // the same local item guid on another realm is fine + store.bind_server(id, "realm-b", 8421, 1, MappingState::Active).unwrap(); + store.set_item_mappings(id, "realm-b", &[(items[0], 9001)]).unwrap(); + assert_eq!(store.item_mappings(id, "realm-b").unwrap(), vec![(items[0], 9001)]); + + // if the local character changes (deleted and imported again), the old item guids are meaningless + store.bind_server(id, "realm-a", 777, 1, MappingState::Active).unwrap(); + assert!(store.item_mappings(id, "realm-a").unwrap().is_empty()); + assert_eq!(store.item_mappings(id, "realm-b").unwrap().len(), 1, "other realms are untouched"); +} + +#[test] +fn collection_union_never_removes_and_unchanged_means_no_revision() { + let (mut store, profile) = fresh(); + let kind = "coa:wardrobe"; + assert!(store.collection(profile, kind).unwrap().is_none()); + + // nothing stored, nothing incoming: nothing is created + let empty = store.merge_collection(profile, kind, &IdSet::new()).unwrap(); + assert!(!empty.changed && empty.info.is_none()); + + let first = store.merge_collection(profile, kind, &IdSet::from_ids([10, 20, 30]).unwrap()).unwrap(); + assert!(first.changed); + assert_eq!(first.added, 3); + let info1 = first.info.unwrap(); + assert_eq!((info1.revision, info1.count), (1, 3)); + + // another realm returns a subset: nothing is lost, nothing changes + let subset = store.merge_collection(profile, kind, &IdSet::from_ids([20]).unwrap()).unwrap(); + assert!(!subset.changed); + assert_eq!(subset.info.as_ref().unwrap(), &info1, "same revision, same hash"); + let same = store.merge_collection(profile, kind, &IdSet::from_ids([10, 20, 30]).unwrap()).unwrap(); + assert!(!same.changed); + + // a realm with new unlocks (and without the old ones) only adds + let more = store.merge_collection(profile, kind, &IdSet::from_ids([30, 40, 5]).unwrap()).unwrap(); + assert!(more.changed); + assert_eq!(more.added, 2); + let info2 = more.info.unwrap(); + assert_eq!((info2.revision, info2.count), (2, 5)); + assert_ne!(info2.hash, info1.hash); + + let (info, set) = store.collection(profile, kind).unwrap().unwrap(); + assert_eq!(set.ids(), &[5, 10, 20, 30, 40]); + assert_eq!(info, info2); + assert_eq!(store.collection_info(profile, kind).unwrap().unwrap(), info2); + + // kinds are separate collections + assert!(store.collection_info(profile, "coa:vanity").unwrap().is_none()); + assert!(store.merge_collection(profile, "wardrobe", &IdSet::new()).is_err(), "kinds must be namespaced"); +} + +#[test] +fn collections_hold_tens_of_thousands_of_ids_compactly() { + let (mut store, profile) = fresh(); + for (kind, count) in [("coa:wardrobe", 2_000u32), ("coa:vanity", 10_000), ("mod:test:mounts", 50_000)] { + let ids: Vec = (0..count).map(|n| n * 7 + (n % 5)).collect(); + let set = IdSet::from_ids(ids).unwrap(); + let merge = store.merge_collection(profile, kind, &set).unwrap(); + assert_eq!(merge.added, count as usize); + let stored_bytes: i64 = store.conn.query_row("SELECT length(payload) FROM collection WHERE kind = ?1", [kind], |r| r.get(0)).unwrap(); + assert!(stored_bytes < count as i64 * 2 + 16, "{kind}: {count} ids took {stored_bytes} bytes"); + let (_, back) = store.collection(profile, kind).unwrap().unwrap(); + assert_eq!(back, set); + } +} + +#[test] +fn a_tampered_collection_is_detected() { + let (mut store, profile) = fresh(); + store.merge_collection(profile, "coa:vanity", &IdSet::from_ids([1, 2, 3]).unwrap()).unwrap(); + let forged = IdSet::from_ids([1, 2, 3, 4]).unwrap().encode(); + store.conn.execute("UPDATE collection SET payload = ?1", [forged]).unwrap(); + assert!(matches!(store.collection(profile, "coa:vanity"), Err(PortableError::CorruptSnapshot(_)))); +} diff --git a/crates/coa-core/src/portable/versions.rs b/crates/coa-core/src/portable/versions.rs new file mode 100644 index 0000000..ef9b732 --- /dev/null +++ b/crates/coa-core/src/portable/versions.rs @@ -0,0 +1,18 @@ +//! Format and protocol versions. They exist from the first commit so that nothing has to be guessed later. +//! +//! A reader refuses data whose version is **newer** than it knows; older versions are read through a migration. +//! The registry and relay versions are not used by anything yet (Phases 10 and 13); they live here so there is one +//! place that lists every version the portable system speaks. + +/// Version of the serialised [`PortableCharacter`](super::model::PortableCharacter). +pub const PORTABLE_CHARACTER_FORMAT_VERSION: u32 = 1; +/// Version of the serialised collection set ([`IdSet`](super::collection::IdSet)). +pub const PORTABLE_COLLECTION_FORMAT_VERSION: u32 = 1; +/// Version of the extension container ([`Extension`](super::model::Extension)). +pub const EXTENSION_FORMAT_VERSION: u32 = 1; +/// Version of the snapshot envelope (header + compressed payload) that is stored and, later, transferred. +pub const SNAPSHOT_FORMAT_VERSION: u32 = 1; +/// Registry API protocol version (Phase 10). +pub const REGISTRY_PROTOCOL_VERSION: u32 = 1; +/// Relay protocol version (Phase 13). +pub const RELAY_PROTOCOL_VERSION: u32 = 1; diff --git a/docs/PORTABLE_FORMAT.md b/docs/PORTABLE_FORMAT.md new file mode 100644 index 0000000..75c4aee --- /dev/null +++ b/docs/PORTABLE_FORMAT.md @@ -0,0 +1,91 @@ +# Portable character format (Phase 1) + +What is stored locally and how it is serialised. Code: `crates/coa-core/src/portable/`. Reasoning: +`PORTABLE_CHARACTERS_AUDIT.md`. + +**Status:** format version 1 is *provisional* until the Phase 3 gate (Realm A -> Manager -> Realm B) passes. Until then +a change of shape may be made without a migration because nothing outside development machines holds version 1 data. +After that gate every change bumps the version. + +## Versions (`versions.rs`) + +| Constant | Value | Meaning | +|---|---|---| +| `PORTABLE_CHARACTER_FORMAT_VERSION` | 1 | the serialised `PortableCharacter` | +| `PORTABLE_COLLECTION_FORMAT_VERSION` | 1 | the encoded id set of a collection | +| `EXTENSION_FORMAT_VERSION` | 1 | the extension container | +| `SNAPSHOT_FORMAT_VERSION` | 1 | the stored snapshot envelope (compressed canonical JSON) | +| `REGISTRY_PROTOCOL_VERSION` | 1 | reserved for Phase 10 | +| `RELAY_PROTOCOL_VERSION` | 1 | reserved for Phase 13 | + +A reader **refuses** data whose version is newer than it knows (checked before the shape is parsed), and reads older +versions through a migration. + +## Identity + +* `CharacterId`, `ProfileId`, `PortableItemId`, `PortablePetId` are **UUIDv7**, generated locally. Only version-7 ids + are accepted from text or JSON; a v4 or nil UUID is rejected as foreign. +* Local AzerothCore guids never appear in the model. They exist only in the mapping tables + (`character_server_mapping`, `item_mapping`), per realm. +* Game content is addressed as `::` (`ContentId`), e.g. `core:wotlk:item:19019`, `coa:class:12`, + `mod::item:7`. The last two segments are kind and number; everything before is the namespace. Plain numbers + elsewhere in the model (spells, quests, factions, skills) are interpreted in the character's `content_namespace`. + +## Canonical form and hash + +1. The model is **normalised**: every set is sorted and de-duplicated (spells, talents, skills, glyph sets, items by + container/slot, quests, reputation, action buttons, pets and their spells, enchantments). +2. It is serialised as compact JSON with a fixed field order (`serde`), unknown fields refused on read. +3. `content_hash = SHA-256(canonical JSON)`. Equal characters have equal hashes; the hash does not depend on the + compressor. +4. The stored payload is `zstd(canonical JSON)`. + +## Limits (hostile input) + +`model::limits` caps every list and string; the snapshot is capped at 16 MiB decompressed and 8 MiB compressed, and +decompression stops at the cap while reading (decompression bombs). Money is capped at the realm's own maximum +(2,147,483,646 copper). Items must form a valid container tree (containers directly on the character, no two items in +one place, no duplicate ids). Extension payloads must match their recorded hash. + +## Revisions + +* `character.revision` is the canonical revision; freshness is decided by this integer, never by a timestamp. +* `commit_snapshot(expected_revision, ...)` succeeds only if `expected_revision` equals the canonical revision + (`StaleRevision` otherwise) and creates revision + 1, in one `BEGIN IMMEDIATE` transaction. +* `rollback_to` creates a **new** revision whose state equals an old one; revision numbers never decrease. +* The newest `history_keep` revisions are kept (default 20, 1..=1000, configurable). The current revision is never + pruned. +* A character's `ruleset` (`coa` | `wildcard`) can never change. + +## Extensions + +`extensions` maps an extension namespace (`mod:`) to `{module_version, format_version, content_hash, payload}`. +The payload is opaque bytes: it is stored and returned unchanged, whether or not this Manager understands it. A realm +that does not support an extension simply does not receive it; the canonical copy is kept. + +## Collections + +Account-wide sets of numeric ids (`coa:wardrobe`, `coa:vanity`, ...), stored per profile and kind: + +* union only: `stored = stored U incoming`; nothing is ever removed because a realm did not return it; +* if the union equals what is stored, **no revision is created** and the hash is unchanged; +* encoding: `[format version][count][first id][gap]...`, unsigned LEB128 varints, strictly ascending ids, 1-2 bytes per + id for realistic sets (50,000 scattered ids < 100 KB); +* `collection_hash = SHA-256("coa-collection-v1\0" + kind + "\0" + encoding)`. + +## Store (`%LOCALAPPDATA%\CoAServerManager\portable\portable.db`) + +SQLite, WAL, `synchronous=FULL`, `foreign_keys=ON`, `application_id = 0x434F4150`, forward-only migrations tracked in +`PRAGMA user_version`. A database from a newer Manager, or a foreign SQLite file, is refused and left untouched. + +| Table | Purpose | +|---|---| +| `profile` | the owner | +| `character` | summary row + canonical revision | +| `snapshot` | `(character_id, revision)` -> compressed canonical payload + hash | +| `character_server_mapping` | `(character_id, server_id)` -> local guid; `UNIQUE(server_id, local_guid)` | +| `item_mapping` | `(character_id, server_id, portable_item_id)` -> local item guid; `UNIQUE(character_id, server_id, local_item_guid)` | +| `collection` | per profile and kind: revision, hash, encoded set | +| `setting` | `history_keep` | + +The store never reads or writes a realm database and never leaves the machine.