373 lines
12 KiB
Rust
373 lines
12 KiB
Rust
//! 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<String, Value>,
|
|
club: ClubIdentity,
|
|
signature: Option<FileSignature>,
|
|
}
|
|
|
|
/// 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<AccountState>,
|
|
}
|
|
|
|
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<PathBuf>) -> 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<String, Value> {
|
|
fs::read(path)
|
|
.ok()
|
|
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn club_from_raw(raw: &Map<String, Value>) -> 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<FileSignature> {
|
|
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<String, Value>, key: &str) -> Result<Option<&'a str>, ()> {
|
|
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<String, Value>,
|
|
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<String, Value>) -> Option<String> {
|
|
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);
|
|
}
|
|
}
|