//! Durable FIFA 17 club identity backed by the shared `fut_account.json` shape. //! //! The Python oracle and the launcher use snake-case keys in one account file. //! Rust owns the rename mutation now, but preserves every unrelated account key //! so Blaze/POW and rollback keep reading the same source of truth. use std::fs::{self, OpenOptions}; use std::io::Write; use std::os::unix::fs::MetadataExt; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use parking_lot::Mutex; use serde_json::{Map, Value}; pub const DEFAULT_CLUB_NAME: &str = "OpenFUT"; pub const DEFAULT_CLUB_ABBR: &str = "OFC"; pub const DEFAULT_ESTABLISHED: &str = "2026"; const CLUB_NAME_MIN: usize = 5; const CLUB_NAME_MAX: usize = 15; const CLUB_ABBR_MIN: usize = 1; const CLUB_ABBR_MAX: usize = 3; /// The FIFA-facing club fields read by `user`, `userMassInfo`, and account sync. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ClubIdentity { pub name: String, pub abbr: String, pub established: String, } type FileSignature = (u64, u64, i64, i64, u64); #[derive(Debug)] struct AccountState { raw: Map, club: ClubIdentity, signature: Option, } /// Result of applying a rename body. Every result maps to the protocol's `200 {}`; /// the distinction exists for logging and tests, not for changing wire behavior. #[derive(Debug, Clone, PartialEq, Eq)] pub enum RenameOutcome { Updated, Unchanged, Rejected(&'static str), PersistFailed(String), } /// Thread-safe account state persisted by atomic replacement on a valid rename. pub struct AccountStore { path: PathBuf, state: Mutex, } impl AccountStore { /// Load the shared account file. Missing or malformed files use the proven /// production defaults; the first valid rename creates/repairs the file. pub fn open(path: impl Into) -> Self { let path = path.into(); let raw = read_raw(&path); let club = club_from_raw(&raw); let signature = file_signature(&path); Self { path, state: Mutex::new(AccountState { raw, club, signature, }), } } pub fn club(&self) -> ClubIdentity { let mut state = self.state.lock(); self.refresh_if_changed(&mut state); state.club.clone() } /// Apply `{clubName, clubAbbr}` with the exact Python-oracle semantics: /// either field may be omitted/null, invalid input is ignored, and the caller /// always acknowledges with `200 {}`. A valid change is durable before it /// becomes visible to Rust readers. pub fn rename_from_body(&self, body: &[u8]) -> RenameOutcome { let value: Value = match serde_json::from_slice(body) { Ok(value) => value, Err(_) => return RenameOutcome::Rejected("malformed_json"), }; let Value::Object(request) = value else { return RenameOutcome::Rejected("body_not_object"); }; let mut state = self.state.lock(); self.refresh_if_changed(&mut state); let name = match optional_string(&request, "clubName") { Ok(Some(value)) => value.trim().to_owned(), Ok(None) => state.club.name.clone(), Err(()) => return RenameOutcome::Rejected("club_name_not_string"), }; let abbr = match optional_string(&request, "clubAbbr") { Ok(Some(value)) => value.trim().to_owned(), Ok(None) => state.club.abbr.clone(), Err(()) => return RenameOutcome::Rejected("club_abbr_not_string"), }; if !valid_name(&name) { return RenameOutcome::Rejected("club_name_length"); } if !valid_abbr(&abbr) { return RenameOutcome::Rejected("club_abbr_length"); } if name == state.club.name && abbr == state.club.abbr { return RenameOutcome::Unchanged; } let mut updated = state.raw.clone(); updated.insert("club_name".into(), Value::String(name.clone())); updated.insert("club_abbr".into(), Value::String(abbr.clone())); let bytes = match serde_json::to_vec_pretty(&updated) { Ok(bytes) => bytes, Err(error) => return RenameOutcome::PersistFailed(error.to_string()), }; if let Err(error) = persist_atomically(&self.path, &bytes) { return RenameOutcome::PersistFailed(error.to_string()); } state.raw = updated; state.club.name = name; state.club.abbr = abbr; state.signature = file_signature(&self.path); RenameOutcome::Updated } fn refresh_if_changed(&self, state: &mut AccountState) { let signature = file_signature(&self.path); if signature == state.signature { return; } let raw = read_raw(&self.path); state.club = club_from_raw(&raw); state.raw = raw; state.signature = file_signature(&self.path); } } fn read_raw(path: &Path) -> Map { fs::read(path) .ok() .and_then(|bytes| serde_json::from_slice(&bytes).ok()) .unwrap_or_default() } fn club_from_raw(raw: &Map) -> ClubIdentity { ClubIdentity { name: valid_stored(raw, "club_name", valid_name) .unwrap_or(DEFAULT_CLUB_NAME) .to_owned(), abbr: valid_stored(raw, "club_abbr", valid_abbr) .unwrap_or(DEFAULT_CLUB_ABBR) .to_owned(), established: stored_established(raw).unwrap_or_else(|| DEFAULT_ESTABLISHED.to_owned()), } } fn file_signature(path: &Path) -> Option { let metadata = fs::metadata(path).ok()?; Some(( metadata.dev(), metadata.ino(), metadata.mtime(), metadata.mtime_nsec(), metadata.len(), )) } fn optional_string<'a>(object: &'a Map, key: &str) -> Result, ()> { match object.get(key) { None | Some(Value::Null) => Ok(None), Some(Value::String(value)) => Ok(Some(value)), Some(_) => Err(()), } } fn valid_stored<'a>( raw: &'a Map, key: &str, valid: fn(&str) -> bool, ) -> Option<&'a str> { raw.get(key) .and_then(Value::as_str) .filter(|value| valid(value)) } fn stored_established(raw: &Map) -> Option { match raw.get("established")? { Value::String(value) if valid_established(value) => Some(value.clone()), Value::Number(value) => value.as_u64().map(|year| year.to_string()), _ => None, } } fn valid_name(value: &str) -> bool { let len = value.chars().count(); (CLUB_NAME_MIN..=CLUB_NAME_MAX).contains(&len) && value == value.trim() } fn valid_abbr(value: &str) -> bool { let len = value.chars().count(); (CLUB_ABBR_MIN..=CLUB_ABBR_MAX).contains(&len) && value == value.trim() } fn valid_established(value: &str) -> bool { !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) } fn persist_atomically(path: &Path, bytes: &[u8]) -> std::io::Result<()> { if let Some(parent) = path .parent() .filter(|parent| !parent.as_os_str().is_empty()) { fs::create_dir_all(parent)?; } static COUNTER: AtomicU64 = AtomicU64::new(0); let temp = path.with_extension(format!( "openfut-tmp-{}-{}", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed) )); let result = (|| { let mut file = OpenOptions::new() .write(true) .create_new(true) .open(&temp)?; file.write_all(bytes)?; file.sync_all()?; drop(file); fs::rename(&temp, path) })(); if result.is_err() { let _ = fs::remove_file(&temp); } result } #[cfg(test)] mod tests { use super::*; use serde_json::json; fn temp_path(tag: &str) -> PathBuf { std::env::temp_dir().join(format!( "openfut-account-store-{}-{tag}.json", std::process::id() )) } #[test] fn valid_rename_preserves_unrelated_account_fields_and_reopens() { let path = temp_path("persist"); let _ = fs::remove_file(&path); fs::write( &path, serde_json::to_vec(&json!({ "persona_id": 33068179, "persona_name": "CAGE", "club_name": "OpenFUT", "club_abbr": "OFC", "established": 2016, "pow_level": 7 })) .unwrap(), ) .unwrap(); let store = AccountStore::open(&path); assert_eq!( store.rename_from_body(br#"{"clubName":" Real FUT ","clubAbbr":"RF"}"#), RenameOutcome::Updated ); assert_eq!( store.club(), ClubIdentity { name: "Real FUT".into(), abbr: "RF".into(), established: "2016".into() } ); let raw: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); assert_eq!(raw["persona_id"], 33_068_179); assert_eq!(raw["persona_name"], "CAGE"); assert_eq!(raw["pow_level"], 7); assert_eq!(raw["club_name"], "Real FUT"); assert_eq!(raw["club_abbr"], "RF"); assert_eq!(AccountStore::open(&path).club(), store.club()); let _ = fs::remove_file(path); } #[test] fn external_atomic_replacement_is_reloaded_before_read_and_rename() { let path = temp_path("external-replace"); let _ = fs::remove_file(&path); fs::write( &path, serde_json::to_vec(&json!({ "club_name": "OpenFUT", "club_abbr": "OFC", "pow_level": 7 })) .unwrap(), ) .unwrap(); let store = AccountStore::open(&path); let replacement = serde_json::to_vec(&json!({ "club_name": "Other Club", "club_abbr": "OC", "established": "2015", "pow_level": 11 })) .unwrap(); persist_atomically(&path, &replacement).unwrap(); assert_eq!( store.club(), ClubIdentity { name: "Other Club".into(), abbr: "OC".into(), established: "2015".into() } ); assert_eq!( store.rename_from_body(br#"{"clubName":"Third Club"}"#), RenameOutcome::Updated ); let raw: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); assert_eq!(raw["club_name"], "Third Club"); assert_eq!(raw["club_abbr"], "OC"); assert_eq!(raw["pow_level"], 11); let _ = fs::remove_file(path); } #[test] fn partial_invalid_and_malformed_renames_match_oracle_no_change_semantics() { let path = temp_path("reject"); let _ = fs::remove_file(&path); let store = AccountStore::open(&path); assert_eq!( store.rename_from_body(br#"{"clubName":"Open FUT"}"#), RenameOutcome::Updated ); assert_eq!(store.club().abbr, DEFAULT_CLUB_ABBR); let before = store.club(); assert_eq!( store.rename_from_body(br#"{"clubName":"x","clubAbbr":"TOOLONG"}"#), RenameOutcome::Rejected("club_name_length") ); assert_eq!( store.rename_from_body(br#"{"clubName":3}"#), RenameOutcome::Rejected("club_name_not_string") ); assert_eq!( store.rename_from_body(b"not-json"), RenameOutcome::Rejected("malformed_json") ); assert_eq!(store.club(), before); let _ = fs::remove_file(path); } }