diff --git a/Cargo.lock b/Cargo.lock index eac2800..dfff001 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3202,6 +3202,16 @@ dependencies = [ name = "openfut-http" version = "0.1.0" +[[package]] +name = "openfut-identity" +version = "0.1.0" +dependencies = [ + "parking_lot", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "openfut-launcher" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 54185ce..6d501ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "openfut-redirector-host", "openfut-roster-host", "openfut-utas-host", + "openfut-identity", "openfut-bridge", "openfut-launcher", "openfut-launcher/openfut-hook", diff --git a/openfut-identity/Cargo.toml b/openfut-identity/Cargo.toml new file mode 100644 index 0000000..dc58ac7 --- /dev/null +++ b/openfut-identity/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "openfut-identity" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "Generic, game-scoped external-identity store: durable, reversible (GameId, EntityKind, CoreId) <-> external wire id. Game-independent infrastructure; adapters supply policy." +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# Guards the in-memory index + atomic file persist; lock guards are unwrapped +# directly (project rule: parking_lot over std::sync when immediately unwrapping). +parking_lot = "0.12" + +[dev-dependencies] +tempfile = "3" diff --git a/openfut-identity/src/lib.rs b/openfut-identity/src/lib.rs new file mode 100644 index 0000000..8062a4a --- /dev/null +++ b/openfut-identity/src/lib.rs @@ -0,0 +1,337 @@ +//! # openfut-identity +//! +//! A **generic, game-scoped external-identity store**: a durable, reversible +//! mapping between an opaque OpenFUT core id and a game's external wire id, +//! keyed by `(game_id, entity_kind, core_id)`. +//! +//! It is game-independent infrastructure. It knows nothing about FIFA — a game +//! adapter supplies the numeric **policy** (e.g. FIFA 17 owned-item ids start at +//! `100_000_000`) by passing a `base_floor`; the store only guarantees the +//! structural properties every adapter needs: +//! +//! * **stable** — the same `(game, kind, core_id)` always resolves to the same +//! external id, including after restart; +//! * **unique** — two distinct core ids in a scope never share an external id; +//! * **reversible** — an external id resolves back to its exact core id; +//! * **game-scoped** — `fifa17` and a future `fifa23` allocate in isolation; +//! * **persistent** — not process memory; +//! * **atomic** — allocation is serialized (one host process owns the file) and +//! persisted with a temp+rename so a crash cannot leave a torn mapping; +//! * **explicit** — an unknown core id or external id returns `None`, never a +//! fabricated value. +//! +//! Core never learns the external (FIFA) integer: only the host/adapter that +//! owns a game boundary uses this store. Persistence is a small JSON file (the +//! smallest durable mechanism; the host uses no database today). The +//! [`ExternalIdentityStore`] trait keeps that a swappable detail — a SQLite +//! implementation can drop in later without touching callers. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; + +/// Store errors. +#[derive(Debug)] +pub enum IdError { + Io(std::io::Error), + Corrupt(String), +} + +impl std::fmt::Display for IdError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + IdError::Io(e) => write!(f, "identity store io: {e}"), + IdError::Corrupt(e) => write!(f, "identity store corrupt: {e}"), + } + } +} +impl std::error::Error for IdError {} + +/// A generic, durable, reversible external-identity mapping. +pub trait ExternalIdentityStore: Send + Sync { + /// Return the external id for `(game, kind, core_id)`, allocating a new one + /// if absent. New ids are monotonic within the `(game, kind)` scope, never + /// below `base_floor` (the adapter's namespace policy). Idempotent: an + /// existing mapping is returned unchanged (base_floor ignored then). + fn resolve_or_allocate( + &self, + game: &str, + kind: &str, + core_id: &str, + base_floor: i64, + ) -> Result; + + /// Existing external id for a core id, or `None` (never allocates). + fn external_for(&self, game: &str, kind: &str, core_id: &str) -> Result, IdError>; + + /// Reverse lookup: the core id that owns `external_id`, or `None`. + fn core_for(&self, game: &str, kind: &str, external_id: i64) + -> Result, IdError>; +} + +/// One persisted mapping row. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Row { + game_id: String, + entity_kind: String, + core_id: String, + external_id: i64, +} + +#[derive(Default)] +struct Index { + rows: Vec, + fwd: HashMap<(String, String, String), i64>, + rev: HashMap<(String, String, i64), String>, + max: HashMap<(String, String), i64>, +} + +impl Index { + fn insert(&mut self, row: Row) { + let scope = (row.game_id.clone(), row.entity_kind.clone()); + self.fwd.insert( + ( + row.game_id.clone(), + row.entity_kind.clone(), + row.core_id.clone(), + ), + row.external_id, + ); + self.rev.insert( + ( + row.game_id.clone(), + row.entity_kind.clone(), + row.external_id, + ), + row.core_id.clone(), + ); + let m = self.max.entry(scope).or_insert(i64::MIN); + if row.external_id > *m { + *m = row.external_id; + } + self.rows.push(row); + } +} + +/// JSON-file backed [`ExternalIdentityStore`]. Single-writer (one host process). +pub struct JsonIdentityStore { + path: PathBuf, + inner: Mutex, +} + +impl JsonIdentityStore { + /// Open (or create) the store at `path`, loading any existing mappings. + pub fn open(path: impl AsRef) -> Result { + let path = path.as_ref().to_path_buf(); + let mut index = Index::default(); + if path.exists() { + let raw = std::fs::read_to_string(&path).map_err(IdError::Io)?; + if !raw.trim().is_empty() { + let rows: Vec = + serde_json::from_str(&raw).map_err(|e| IdError::Corrupt(e.to_string()))?; + for row in rows { + // Reject a torn file that violates reverse-uniqueness rather + // than silently serving an ambiguous reverse lookup. + let rkey = ( + row.game_id.clone(), + row.entity_kind.clone(), + row.external_id, + ); + if index.rev.contains_key(&rkey) { + return Err(IdError::Corrupt(format!( + "duplicate external id {} in scope ({}, {})", + row.external_id, row.game_id, row.entity_kind + ))); + } + index.insert(row); + } + } + } + Ok(JsonIdentityStore { + path, + inner: Mutex::new(index), + }) + } + + /// Atomically persist the full row set (temp + rename). + fn persist(&self, index: &Index) -> Result<(), IdError> { + let json = + serde_json::to_vec_pretty(&index.rows).map_err(|e| IdError::Corrupt(e.to_string()))?; + let tmp = self.path.with_extension("tmp"); + std::fs::write(&tmp, &json).map_err(IdError::Io)?; + std::fs::rename(&tmp, &self.path).map_err(IdError::Io)?; + Ok(()) + } +} + +impl ExternalIdentityStore for JsonIdentityStore { + fn resolve_or_allocate( + &self, + game: &str, + kind: &str, + core_id: &str, + base_floor: i64, + ) -> Result { + let mut ix = self.inner.lock(); + let fkey = (game.to_string(), kind.to_string(), core_id.to_string()); + if let Some(&ext) = ix.fwd.get(&fkey) { + return Ok(ext); + } + let scope = (game.to_string(), kind.to_string()); + let next = match ix.max.get(&scope) { + Some(&m) => (m + 1).max(base_floor), + None => base_floor, + }; + let row = Row { + game_id: game.to_string(), + entity_kind: kind.to_string(), + core_id: core_id.to_string(), + external_id: next, + }; + ix.insert(row); + // Persist before returning; on failure roll the in-memory insert back so + // memory and disk never diverge. + if let Err(e) = self.persist(&ix) { + ix.rows.pop(); + ix.fwd.remove(&fkey); + ix.rev.remove(&(game.to_string(), kind.to_string(), next)); + // `max` may now be stale-high; recompute for the scope. + let new_max = ix + .rows + .iter() + .filter(|r| r.game_id == game && r.entity_kind == kind) + .map(|r| r.external_id) + .max(); + match new_max { + Some(m) => { + ix.max.insert(scope, m); + } + None => { + ix.max.remove(&scope); + } + } + return Err(e); + } + Ok(next) + } + + fn external_for(&self, game: &str, kind: &str, core_id: &str) -> Result, IdError> { + let ix = self.inner.lock(); + Ok(ix + .fwd + .get(&(game.to_string(), kind.to_string(), core_id.to_string())) + .copied()) + } + + fn core_for( + &self, + game: &str, + kind: &str, + external_id: i64, + ) -> Result, IdError> { + let ix = self.inner.lock(); + Ok(ix + .rev + .get(&(game.to_string(), kind.to_string(), external_id)) + .cloned()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn store() -> (JsonIdentityStore, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let s = JsonIdentityStore::open(dir.path().join("ids.json")).unwrap(); + (s, dir) + } + + const G: &str = "fifa17"; + const K: &str = "owned-item"; + const BASE: i64 = 100_000_001; + + #[test] + fn allocation_is_stable_and_reversible() { + let (s, _d) = store(); + let a = s.resolve_or_allocate(G, K, "oc-A", BASE).unwrap(); + // idempotent + assert_eq!(s.resolve_or_allocate(G, K, "oc-A", BASE).unwrap(), a); + assert_eq!(s.external_for(G, K, "oc-A").unwrap(), Some(a)); + // reversible + assert_eq!(s.core_for(G, K, a).unwrap().as_deref(), Some("oc-A")); + } + + #[test] + fn first_allocation_respects_base_floor_then_monotonic() { + let (s, _d) = store(); + let a = s.resolve_or_allocate(G, K, "oc-A", BASE).unwrap(); + let b = s.resolve_or_allocate(G, K, "oc-B", BASE).unwrap(); + assert_eq!(a, BASE); + assert_eq!(b, BASE + 1); + assert_ne!(a, b, "two owned items get distinct wire ids"); + } + + #[test] + fn unknown_ids_are_explicit_none() { + let (s, _d) = store(); + assert_eq!(s.external_for(G, K, "nope").unwrap(), None); + assert_eq!(s.core_for(G, K, 999_999).unwrap(), None); + } + + #[test] + fn game_and_kind_scopes_are_isolated() { + let (s, _d) = store(); + let f = s.resolve_or_allocate("fifa17", K, "oc-A", BASE).unwrap(); + let g = s.resolve_or_allocate("fifa23", K, "oc-A", BASE).unwrap(); + // same core id, different games -> independent allocations, both from base + assert_eq!(f, BASE); + assert_eq!(g, BASE); + // reverse lookup respects the game scope + assert_eq!(s.core_for("fifa17", K, f).unwrap().as_deref(), Some("oc-A")); + assert_eq!(s.core_for("fifa23", K, f).unwrap().as_deref(), Some("oc-A")); + // a different entity kind is its own scope + let k2 = s + .resolve_or_allocate("fifa17", "card-def", "oc-A", 1) + .unwrap(); + assert_eq!(k2, 1); + } + + #[test] + fn mappings_persist_across_reopen() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ids.json"); + let (a, b); + { + let s = JsonIdentityStore::open(&path).unwrap(); + a = s.resolve_or_allocate(G, K, "oc-A", BASE).unwrap(); + b = s.resolve_or_allocate(G, K, "oc-B", BASE).unwrap(); + } + // reopen: same ids, reverse intact, next allocation continues monotonic + let s = JsonIdentityStore::open(&path).unwrap(); + assert_eq!(s.external_for(G, K, "oc-A").unwrap(), Some(a)); + assert_eq!(s.external_for(G, K, "oc-B").unwrap(), Some(b)); + assert_eq!(s.core_for(G, K, a).unwrap().as_deref(), Some("oc-A")); + let c = s.resolve_or_allocate(G, K, "oc-C", BASE).unwrap(); + assert_eq!(c, b + 1, "counter resumes after restart, no reuse"); + } + + #[test] + fn corrupt_reverse_duplicate_is_rejected_on_open() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ids.json"); + // hand-write a file with two core ids sharing one external id in a scope + std::fs::write( + &path, + r#"[{"game_id":"fifa17","entity_kind":"owned-item","core_id":"a","external_id":5}, + {"game_id":"fifa17","entity_kind":"owned-item","core_id":"b","external_id":5}]"#, + ) + .unwrap(); + assert!( + JsonIdentityStore::open(&path).is_err(), + "ambiguous reverse must not load" + ); + } +}