diff --git a/migrations/0018_profile_import_fingerprint.sql b/migrations/0018_profile_import_fingerprint.sql new file mode 100644 index 0000000..3938e98 --- /dev/null +++ b/migrations/0018_profile_import_fingerprint.sql @@ -0,0 +1,10 @@ +-- Generic provenance/rerun-identity token for a transactionally imported profile. +-- +-- Set by the generic profile-import path (services::import). A NULL value means +-- the profile was created by normal gameplay / dev seeding, not an import, and +-- MUST NOT be silently clobbered by an import targeting the same game. A +-- matching token on a re-run is an idempotent no-op; a differing token against +-- an already-imported game fails until an explicit update mode exists. +-- +-- Core never interprets the token's structure; the importer adapter chooses it. +ALTER TABLE profiles ADD COLUMN import_fingerprint TEXT; diff --git a/src/main.rs b/src/main.rs index fdfd9f5..fde2715 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use openfut_core::{config, db, seed}; use tracing::info; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; @@ -30,6 +30,31 @@ async fn main() -> Result<()> { return Ok(()); } + // Opt-in generic import subcommand: `openfut-core import `. + // Reads a GAME-AGNOSTIC ProfileImportRequest (the importer adapter translates + // FIFA17 source data into it), loads production content packs, runs preflight, + // and applies one all-or-nothing transaction. FIFA17 semantics live entirely + // in the adapter; Core only sees opaque ids + opaque extension bytes. + if std::env::args().nth(1).as_deref() == Some("import") { + let path = std::env::args() + .nth(2) + .context("usage: openfut-core import ")?; + let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?; + db::run_migrations(&pool).await?; + let mut card_db = openfut_core::services::card_db::CardDb::load(&cfg.data_dir)?; + for pack in &cfg.content_packs { + card_db.load_pack(pack)?; + } + let raw = std::fs::read_to_string(&path) + .with_context(|| format!("read import request {path}"))?; + let req: openfut_core::services::import::ProfileImportRequest = + serde_json::from_str(&raw).context("parse import request JSON")?; + let outcome = + openfut_core::services::import::apply_profile_import(&pool, &card_db, &req).await?; + println!("{}", serde_json::to_string_pretty(&outcome)?); + return Ok(()); + } + info!("OpenFUT Core starting on {}", cfg.listen_addr); let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?; diff --git a/src/services/import.rs b/src/services/import.rs new file mode 100644 index 0000000..a360bea --- /dev/null +++ b/src/services/import.rs @@ -0,0 +1,318 @@ +//! Generic, game-agnostic transactional profile import. +//! +//! Core installs a profile + club + owned cards + canonical squad + one opaque +//! game extension in a SINGLE all-or-nothing SQLite transaction, stamped with a +//! generic `source_fingerprint` provenance token. Core NEVER interprets FIFA17 +//! wire ids, resourceIds, `nextItemId`, or the extension payload — the +//! `openfut-import-fifa17` adapter reads the Python profile, chooses every +//! `CardDefinitionId` and every opaque `OwnedItemId`, builds the squad +//! extension bytes, and hands Core this generic request. +//! +//! Invariants enforced here: +//! - Definition preflight: every incoming `card_id` MUST already resolve in the +//! loaded production content, so the transaction never creates ownership +//! pointing at absent content. +//! - Squad all-or-nothing: every active-squad `owned_item_id` MUST be among the +//! imported ownership set before the transaction begins. +//! - Rerun identity: identical `source_fingerprint` against an already-imported +//! game is an idempotent no-op; a differing token fails; a pre-existing +//! non-imported profile is never clobbered. +//! - The whole thing commits together or not at all. + +use crate::db::Pool; +use crate::models::game_ext::{MAX_EXT_NAMESPACE_LEN, MAX_EXT_PAYLOAD_BYTES}; +use crate::services::card_db::CardDb; +use crate::services::squad::squad_fingerprint; +use anyhow::{bail, Context, Result}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use uuid::Uuid; + +#[derive(Debug, Deserialize)] +pub struct ImportProfile { + pub username: String, + pub game_id: String, +} + +#[derive(Debug, Deserialize)] +pub struct ImportClub { + pub name: String, + #[serde(default)] + pub coins: i64, +} + +#[derive(Debug, Deserialize)] +pub struct ImportOwnedCard { + /// Opaque, stable Core OwnedItemId chosen by the adapter. Core never parses + /// why it is stable — it is a primary key, nothing more. + pub owned_item_id: String, + /// CardDefinitionId that MUST resolve in loaded production content. + pub card_id: String, +} + +#[derive(Debug, Deserialize)] +pub struct ImportSlot { + pub owned_item_id: String, + pub position_index: i64, + #[serde(default)] + pub is_captain: bool, + #[serde(default)] + pub is_on_bench: bool, +} + +#[derive(Debug, Deserialize)] +pub struct ImportExtension { + /// Opaque adapter key, e.g. "fifa17.squad.v1". + pub namespace: String, + /// Adapter payload version (distinct from DB storage schema). + pub schema_version: i64, + /// Uninterpreted bytes-as-text. Core enforces only generic size bounds. + pub payload: String, +} + +#[derive(Debug, Deserialize)] +pub struct ImportSquad { + pub formation: String, + #[serde(default = "default_squad_name")] + pub name: String, + pub slots: Vec, + pub extension: ImportExtension, +} + +fn default_squad_name() -> String { + "My Squad".to_string() +} + +#[derive(Debug, Deserialize)] +pub struct ProfileImportRequest { + /// Generic provenance/rerun-identity token. Core stores it verbatim. + pub source_fingerprint: String, + pub profile: ImportProfile, + pub club: ImportClub, + pub owned: Vec, + #[serde(default)] + pub squad: Option, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(tag = "outcome", rename_all = "snake_case")] +pub enum ImportOutcome { + /// A fresh import committed. + Imported { owned: usize, squad_slots: usize }, + /// The same fingerprint was already imported for this game — no-op. + AlreadyImported, +} + +/// Apply a generic transactional profile import. See module docs for invariants. +pub async fn apply_profile_import( + pool: &Pool, + card_db: &CardDb, + req: &ProfileImportRequest, +) -> Result { + // ── 0. generic input validation (no writes) ── + if req.source_fingerprint.trim().is_empty() { + bail!("source_fingerprint must be non-empty"); + } + if req.owned.is_empty() { + bail!("import request has zero owned cards; refusing to import an empty profile"); + } + + // ── 1. rerun identity / single-profile-per-game ── + let existing: Option<(String, Option)> = sqlx::query_as( + "SELECT id, import_fingerprint FROM profiles \ + WHERE game_id = ? ORDER BY created_at ASC LIMIT 1", + ) + .bind(&req.profile.game_id) + .fetch_optional(pool) + .await?; + if let Some((_id, fp)) = existing { + match fp { + Some(fp) if fp == req.source_fingerprint => return Ok(ImportOutcome::AlreadyImported), + Some(fp) => bail!( + "game '{}' already imported from a different source (stored fingerprint {fp}, \ + incoming {}); refusing to overwrite without an explicit update mode", + req.profile.game_id, + req.source_fingerprint + ), + None => bail!( + "game '{}' already has a non-imported profile; refusing to clobber it", + req.profile.game_id + ), + } + } + + // ── 2. definition preflight: every card_id MUST resolve in loaded content ── + let mut missing: Vec<&str> = req + .owned + .iter() + .filter(|o| card_db.get(&o.card_id).is_none()) + .map(|o| o.card_id.as_str()) + .collect(); + if !missing.is_empty() { + missing.sort_unstable(); + missing.dedup(); + let sample = &missing[..missing.len().min(5)]; + bail!( + "definition preflight failed: {} owned card(s) reference CardDefinitionId(s) not in \ + loaded content (e.g. {sample:?}); refusing to create ownership pointing at absent content", + missing.len() + ); + } + + // ── 3. owned-item-id uniqueness ── + let mut owned_ids: HashSet<&str> = HashSet::with_capacity(req.owned.len()); + for o in &req.owned { + if !owned_ids.insert(o.owned_item_id.as_str()) { + bail!( + "duplicate OwnedItemId in import request: {}", + o.owned_item_id + ); + } + } + + // ── 4. squad all-or-nothing + generic extension bounds (no writes) ── + if let Some(sq) = &req.squad { + let ns_len = sq.extension.namespace.len(); + if ns_len == 0 || ns_len > MAX_EXT_NAMESPACE_LEN { + bail!( + "extension namespace length {ns_len} out of bounds (1..={MAX_EXT_NAMESPACE_LEN})" + ); + } + if sq.extension.payload.len() > MAX_EXT_PAYLOAD_BYTES { + bail!( + "extension payload {} bytes exceeds MAX_EXT_PAYLOAD_BYTES ({MAX_EXT_PAYLOAD_BYTES})", + sq.extension.payload.len() + ); + } + for slot in &sq.slots { + if !owned_ids.contains(slot.owned_item_id.as_str()) { + bail!( + "active squad references OwnedItemId {} not present in imported ownership set; \ + squad import is all-or-nothing", + slot.owned_item_id + ); + } + } + } + + // ── 5. single transaction: everything commits together or not at all ── + let now = Utc::now().to_rfc3339(); + let profile_id = Uuid::new_v4().to_string(); + let club_id = Uuid::new_v4().to_string(); + let mut tx = pool.begin().await?; + + sqlx::query( + "INSERT INTO profiles (id, username, level, xp, game_id, created_at, updated_at, import_fingerprint) \ + VALUES (?, ?, 1, 0, ?, ?, ?, ?)", + ) + .bind(&profile_id) + .bind(&req.profile.username) + .bind(&req.profile.game_id) + .bind(&now) + .bind(&now) + .bind(&req.source_fingerprint) + .execute(&mut *tx) + .await + .context("insert profile")?; + + sqlx::query( + "INSERT INTO clubs (id, profile_id, name, coins, level, created_at, updated_at) \ + VALUES (?, ?, ?, ?, 1, ?, ?)", + ) + .bind(&club_id) + .bind(&profile_id) + .bind(&req.club.name) + .bind(req.club.coins) + .bind(&now) + .bind(&now) + .execute(&mut *tx) + .await + .context("insert club")?; + + for o in &req.owned { + sqlx::query( + "INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) \ + VALUES (?, ?, ?, 0, NULL, ?)", + ) + .bind(&o.owned_item_id) + .bind(&club_id) + .bind(&o.card_id) + .bind(&now) + .execute(&mut *tx) + .await + .with_context(|| format!("insert owned_card {}", o.owned_item_id))?; + } + + let mut squad_slots = 0usize; + if let Some(sq) = &req.squad { + let squad_id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(&squad_id) + .bind(&club_id) + .bind(&sq.name) + .bind(&sq.formation) + .bind(&now) + .bind(&now) + .execute(&mut *tx) + .await + .context("insert squad")?; + + for slot in &sq.slots { + sqlx::query( + "INSERT INTO squad_players (id, squad_id, owned_card_id, position_index, is_captain, is_on_bench) \ + VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(Uuid::new_v4().to_string()) + .bind(&squad_id) + .bind(&slot.owned_item_id) + .bind(slot.position_index) + .bind(slot.is_captain) + .bind(slot.is_on_bench) + .execute(&mut *tx) + .await + .context("insert squad_player")?; + } + squad_slots = sq.slots.len(); + + // Core computes the canonical fingerprint over the COMMITTED squad — never + // an adapter-supplied value — and persists the opaque extension atomically + // in the same tx, exactly as the live squad-write path does. + let canonical_fingerprint = squad_fingerprint( + &squad_id, + &sq.formation, + sq.slots.iter().map(|s| { + ( + s.position_index, + s.owned_item_id.as_str(), + s.is_captain, + s.is_on_bench, + ) + }), + ); + sqlx::query( + "INSERT OR REPLACE INTO game_entity_ext \ + (game_id, entity_kind, entity_id, namespace, schema_version, canonical_fingerprint, payload, updated_at) \ + VALUES (?, 'squad', ?, ?, ?, ?, ?, ?)", + ) + .bind(&req.profile.game_id) + .bind(&squad_id) + .bind(&sq.extension.namespace) + .bind(sq.extension.schema_version) + .bind(&canonical_fingerprint) + .bind(&sq.extension.payload) + .bind(&now) + .execute(&mut *tx) + .await + .context("insert game_entity_ext")?; + } + + tx.commit().await?; + Ok(ImportOutcome::Imported { + owned: req.owned.len(), + squad_slots, + }) +} diff --git a/src/services/mod.rs b/src/services/mod.rs index fd7cd0c..de97ec3 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -2,19 +2,20 @@ pub mod achievement; pub mod card_db; pub mod checkin; pub mod club; -pub mod notification; pub mod draft; pub mod event; pub mod fut_champs; pub mod game_ext; +pub mod import; pub mod inventory; -pub mod season; pub mod market; pub mod match_service; +pub mod notification; pub mod objective; pub mod pack; pub mod profile; pub mod sbc; +pub mod season; pub mod settings; pub mod squad; pub mod squad_rules; diff --git a/src/services/squad.rs b/src/services/squad.rs index e467e31..8780182 100644 --- a/src/services/squad.rs +++ b/src/services/squad.rs @@ -497,7 +497,7 @@ pub async fn replace_squad_with_extension( /// computed; non-cryptographic (FNV-1a-64) — a stale-extension guard, not a /// security boundary. The encoding is sorted + delimited so it never depends on /// row/iteration order. -fn squad_fingerprint<'a>( +pub(crate) fn squad_fingerprint<'a>( squad_id: &str, formation: &str, slots: impl Iterator, diff --git a/tests/import_service_test.rs b/tests/import_service_test.rs new file mode 100644 index 0000000..a41c6f6 --- /dev/null +++ b/tests/import_service_test.rs @@ -0,0 +1,256 @@ +//! Generic transactional profile import (services::import). These also serve as +//! the Core-level half of the migration mutation battery: each hostile input is +//! rejected BEFORE any partial write, and re-runs converge instead of duplicating. + +use openfut_core::services::card_db::CardDb; +use openfut_core::services::import::{ + apply_profile_import, ImportClub, ImportExtension, ImportOwnedCard, ImportProfile, ImportSlot, + ImportSquad, ProfileImportRequest, +}; + +async fn fresh_pool() -> sqlx::SqlitePool { + let p = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("in-memory sqlite"); + sqlx::migrate!("./migrations") + .run(&p) + .await + .expect("migrations"); + p +} + +fn valid_ids(n: usize) -> Vec { + let db = CardDb::load("data").expect("load data catalog"); + let ids: Vec = db.all().iter().take(n).map(|c| c.id.clone()).collect(); + assert!(ids.len() >= n, "data catalog too small for test"); + ids +} + +fn owned(ids: &[String]) -> Vec { + ids.iter() + .enumerate() + .map(|(i, id)| ImportOwnedCard { + owned_item_id: format!("oc-{i}"), + card_id: id.clone(), + }) + .collect() +} + +fn squad_over(owned: &[ImportOwnedCard]) -> ImportSquad { + ImportSquad { + formation: "f433".into(), + name: "OpenFUT".into(), + slots: owned + .iter() + .take(3) + .enumerate() + .map(|(i, o)| ImportSlot { + owned_item_id: o.owned_item_id.clone(), + position_index: i as i64, + is_captain: i == 0, + is_on_bench: false, + }) + .collect(), + extension: ImportExtension { + namespace: "fifa17.squad.v1".into(), + schema_version: 1, + payload: r#"{"custom":[]}"#.into(), + }, + } +} + +fn request( + game: &str, + fp: &str, + owned: Vec, + squad: Option, +) -> ProfileImportRequest { + ProfileImportRequest { + source_fingerprint: fp.into(), + profile: ImportProfile { + username: format!("CAGE-{game}"), + game_id: game.into(), + }, + club: ImportClub { + name: "OpenFUT".into(), + coins: 28_112_944, + }, + owned, + squad, + } +} + +async fn count(pool: &sqlx::SqlitePool, table: &str) -> i64 { + sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table}")) + .fetch_one(pool) + .await + .unwrap() +} + +#[tokio::test] +async fn imports_profile_club_owned_and_squad_in_one_shot() { + let pool = fresh_pool().await; + let db = CardDb::load("data").unwrap(); + let ids = valid_ids(5); + let ow = owned(&ids); + let sq = squad_over(&ow); + let req = request("g_happy", "fp-happy", ow, Some(sq)); + + let out = apply_profile_import(&pool, &db, &req) + .await + .expect("import"); + assert!(matches!( + out, + openfut_core::services::import::ImportOutcome::Imported { + owned: 5, + squad_slots: 3 + } + )); + + assert_eq!(count(&pool, "profiles").await, 1); + assert_eq!(count(&pool, "clubs").await, 1); + assert_eq!(count(&pool, "owned_cards").await, 5); + assert_eq!(count(&pool, "squad_players").await, 3); + // opaque extension persisted with a Core-computed fingerprint. + let fp: String = + sqlx::query_scalar("SELECT canonical_fingerprint FROM game_entity_ext LIMIT 1") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(fp.len(), 16, "16-hex FNV fingerprint"); + let stored_import_fp: String = + sqlx::query_scalar("SELECT import_fingerprint FROM profiles LIMIT 1") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(stored_import_fp, "fp-happy"); +} + +#[tokio::test] +async fn rerun_same_fingerprint_is_idempotent_noop() { + let pool = fresh_pool().await; + let db = CardDb::load("data").unwrap(); + let ids = valid_ids(4); + let mk = || { + request( + "g_rerun", + "fp-x", + owned(&ids), + Some(squad_over(&owned(&ids))), + ) + }; + + apply_profile_import(&pool, &db, &mk()) + .await + .expect("first"); + let out = apply_profile_import(&pool, &db, &mk()) + .await + .expect("second"); + assert_eq!( + out, + openfut_core::services::import::ImportOutcome::AlreadyImported + ); + // no duplication. + assert_eq!(count(&pool, "profiles").await, 1); + assert_eq!(count(&pool, "owned_cards").await, 4); +} + +#[tokio::test] +async fn different_fingerprint_on_imported_game_fails() { + let pool = fresh_pool().await; + let db = CardDb::load("data").unwrap(); + let ids = valid_ids(3); + apply_profile_import(&pool, &db, &request("g_diff", "fp-a", owned(&ids), None)) + .await + .expect("first"); + let err = apply_profile_import(&pool, &db, &request("g_diff", "fp-b", owned(&ids), None)) + .await + .expect_err("second, different fingerprint"); + assert!(format!("{err:#}").contains("different source"), "{err:#}"); + assert_eq!(count(&pool, "profiles").await, 1); +} + +#[tokio::test] +async fn missing_definition_fails_preflight_with_no_writes() { + let pool = fresh_pool().await; + let db = CardDb::load("data").unwrap(); + let mut ow = owned(&valid_ids(2)); + ow.push(ImportOwnedCard { + owned_item_id: "oc-bad".into(), + card_id: "fifa17_definitely_absent_999999".into(), + }); + let err = apply_profile_import(&pool, &db, &request("g_miss", "fp", ow, None)) + .await + .expect_err("missing definition must fail"); + assert!( + format!("{err:#}").contains("definition preflight failed"), + "{err:#}" + ); + // preflight is before the tx: nothing was written. + assert_eq!(count(&pool, "profiles").await, 0); + assert_eq!(count(&pool, "owned_cards").await, 0); +} + +#[tokio::test] +async fn squad_slot_not_in_ownership_fails() { + let pool = fresh_pool().await; + let db = CardDb::load("data").unwrap(); + let ids = valid_ids(3); + let ow = owned(&ids); + let mut sq = squad_over(&ow); + sq.slots[1].owned_item_id = "oc-not-owned".into(); + let err = apply_profile_import(&pool, &db, &request("g_sq", "fp", ow, Some(sq))) + .await + .expect_err("squad slot not owned must fail"); + assert!(format!("{err:#}").contains("all-or-nothing"), "{err:#}"); + assert_eq!(count(&pool, "profiles").await, 0); +} + +#[tokio::test] +async fn duplicate_owned_item_id_fails() { + let pool = fresh_pool().await; + let db = CardDb::load("data").unwrap(); + let ids = valid_ids(2); + let mut ow = owned(&ids); + ow[1].owned_item_id = ow[0].owned_item_id.clone(); + let err = apply_profile_import(&pool, &db, &request("g_dup", "fp", ow, None)) + .await + .expect_err("duplicate OwnedItemId must fail"); + assert!( + format!("{err:#}").contains("duplicate OwnedItemId"), + "{err:#}" + ); + assert_eq!(count(&pool, "profiles").await, 0); +} + +#[tokio::test] +async fn non_imported_profile_is_not_clobbered() { + let pool = fresh_pool().await; + let db = CardDb::load("data").unwrap(); + // simulate a gameplay/dev profile with NO import_fingerprint for this game. + sqlx::query( + "INSERT INTO profiles (id, username, level, xp, game_id, created_at, updated_at) \ + VALUES ('p0','someone',1,0,'g_clobber','2026-01-01T00:00:00Z','2026-01-01T00:00:00Z')", + ) + .execute(&pool) + .await + .unwrap(); + let ids = valid_ids(2); + let err = apply_profile_import(&pool, &db, &request("g_clobber", "fp", owned(&ids), None)) + .await + .expect_err("must refuse to clobber a non-imported profile"); + assert!(format!("{err:#}").contains("non-imported"), "{err:#}"); + assert_eq!(count(&pool, "owned_cards").await, 0); +} + +#[tokio::test] +async fn empty_owned_fails() { + let pool = fresh_pool().await; + let db = CardDb::load("data").unwrap(); + let err = apply_profile_import(&pool, &db, &request("g_empty", "fp", vec![], None)) + .await + .expect_err("empty owned must fail"); + assert!(format!("{err:#}").contains("zero owned cards"), "{err:#}"); +}