//! FIFA 17 empty-My-Packs capability negotiation — per-session state machine. //! //! This is the Rust production port of the *novel* session/capability logic that //! was proven live on staging and currently lives in the Python oracle //! (`fifa17-recon/tools/utas_server.py`, "FIFA17 empty-My-Packs capability //! negotiation"). Python remains the behavioural **reference/oracle**; this module //! is the intended production **authority** for the machinery so new FUT session //! behaviour stops accumulating in Python. //! //! ## Scope (deliberately bounded) //! //! This module owns the pure state machine only: //! * per-login session table keyed by the unique UTAS session id (`X-UT-SID`), //! * the single-use `(ip, persona)` launcher→session capability hand-off (pending), //! * capability binding (`bound` / `pending` / `ignored-late`), //! * the once-per-session empty-My-Packs freeze (`clean-v1` vs `sentinel`), //! * TTL reaping and fail-closed rules (unknown / expired / ambiguous / late / //! cross-session / sid-ip-mismatch ⇒ sentinel). //! //! It has **no** HTTP, Core, or IO dependencies, and it does **not** reproduce the //! delicate `_pack_body` UTAS wire shaping (utas_server.py:3474 — a type-sensitive, //! reverse-engineered body where a wrong scalar type silently breaks pack buying). //! That shaping, the `/ut/auth` persona-adoption, `/store/purchasegroup` catalogue //! assembly, and the store BUY path remain Python-owned until a separate, carefully //! differential-tested slice ports them. Wiring these three routes //! (`/ut/auth` SID, `/openfut/fifa17/capability`, `/store/purchasegroup`) into //! `openfut-utas-host` against this state machine — without dividing store authority //! (i.e. porting BUY too, so purchasegroup display and buy validation don't split) — //! is the remaining bounded gap toward full Rust authority. //! //! ## Purity / testability //! //! The monotonic clock is injected (`now: f64` seconds) rather than read from a //! global, so every A–R matrix scenario is a deterministic unit test. SID entropy //! is likewise injected ([`format_sid`]) — the host supplies a unique 64-bit value; //! the Python oracle notes uniqueness (not unpredictability) is the requirement. use std::collections::HashMap; /// The single capability this negotiation understands. pub const CAPABILITY_NAME: &str = "empty_mypacks_resolver"; /// The only accepted resolver version (FIFA17 CardsDLL guard at RVA `0x14858`). pub const EMPTY_MYPACKS_RESOLVER_VERSION: u32 = 1; /// Synthetic non-openable pack id — the universal P2 sentinel. Deliberately ABSENT /// from the store `PACK_CATALOG`, so it can never be bought/opened/granted. pub const SENTINEL_PACK_ID: u64 = 65534; /// A FIFA session is reaped after this many idle seconds. pub const SESSION_TTL_SECS: f64 = 3600.0; /// A launcher capability may await its session for this long before expiring. pub const PENDING_TTL_SECS: f64 = 120.0; /// The empty-My-Packs store topology, frozen once per session at its first store /// request and immutable thereafter. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StoreMode { /// Universal fallback: emit the synthetic non-openable [`SENTINEL_PACK_ID`] pack /// so the client's `mypacks` category resolves and does not crash. Sentinel, /// Verified patched client: emit NO `mypacks` group; the CardsDLL resolver guard /// routes the absent category to Browse. CleanV1, } impl StoreMode { /// The wire token the Python oracle logs/uses (`"sentinel"` / `"clean-v1"`). pub fn as_str(self) -> &'static str { match self { StoreMode::Sentinel => "sentinel", StoreMode::CleanV1 => "clean-v1", } } } /// Outcome of a launcher capability registration. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RegisterOutcome { /// Exactly one live, unfrozen, unbound session for `(ip, persona)` existed and /// was bound now (the common post-login case). Bound, /// No session for `(ip, persona)` yet (registration before login): staged as a /// single-use pending hand-off. Pending, /// A session for `(ip, persona)` exists but is frozen or ambiguous (>1 unbound): /// NOT staged, so no later/unverified process can inherit it. Fail-closed. IgnoredLate, } /// Rejection reason for a capability POST body (maps to HTTP 400 at the route). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CapabilityError { /// Capability name or version is not the supported `empty_mypacks_resolver` v1. Unsupported, } /// Validate a capability registration request body. Anything but the supported /// `empty_mypacks_resolver` at the current version is rejected — the session then /// simply stays on the sentinel fallback (the route records nothing). pub fn validate_capability(name: &str, version: u32) -> Result<(), CapabilityError> { if name == CAPABILITY_NAME && version == EMPTY_MYPACKS_RESOLVER_VERSION { Ok(()) } else { Err(CapabilityError::Unsupported) } } /// Format a unique per-login UTAS session id from a caller-supplied 64-bit value. /// Same shape/length as the legacy constant; uniqueness — not unpredictability — is /// what the binding needs, so the host may use any unique source (random or counter). pub fn format_sid(bits: u64) -> String { format!("OPENFUT-SID-{bits:016X}") } /// The identity of the synthetic empty-My-Packs sentinel pack (utas_server.py:3671). /// This is the *identity* only; the full UTAS `purchase[]` entry is produced by the /// Python `_pack_body` shaping, which is intentionally not ported here. #[derive(Debug, Clone, Copy, PartialEq)] pub struct SentinelPack { pub id: u64, pub price: u64, pub count: u64, pub gold: bool, pub special_chance: f64, } impl SentinelPack { /// The v1 sentinel: empty, free, non-openable. `_pack_body` additionally forces /// `state="active"` and `unopened=false` (owned) — documented here, applied by /// the shaping layer, not this module. pub fn v1() -> Self { SentinelPack { id: SENTINEL_PACK_ID, price: 0, count: 0, gold: true, special_chance: 0.0, } } } #[derive(Debug, Clone)] struct Session { ip: Option, persona: i64, resolver: Option, mode: Option, last_seen: f64, } #[derive(Debug, Clone)] struct Pending { resolver: u32, ts: f64, } /// The per-session capability/store-mode authority. Not `Sync` itself; the host /// wraps it in a `Mutex` exactly as the Python oracle guards its tables with a lock. #[derive(Debug, Default)] pub struct SessionStore { sessions: HashMap, pending: HashMap<(Option, i64), Pending>, } impl SessionStore { /// A fresh, empty store. pub fn new() -> Self { Self::default() } /// Reap idle sessions (> [`SESSION_TTL_SECS`]) and expired pendings /// (> [`PENDING_TTL_SECS`]). Called at the start of every mutating operation, /// mirroring the oracle's lazy reap. fn reap(&mut self, now: f64) { self.sessions .retain(|_, r| now - r.last_seen <= SESSION_TTL_SECS); self.pending.retain(|_, p| now - p.ts <= PENDING_TTL_SECS); } /// Single-use: remove and return a fresh pending resolver for `(ip, persona)`. fn take_pending(&mut self, ip: &Option, persona: i64, now: f64) -> Option { let key = (ip.clone(), persona); if let Some(p) = self.pending.get(&key) { if now - p.ts <= PENDING_TTL_SECS { let resolver = p.resolver; self.pending.remove(&key); return Some(resolver); } } None } /// `/ut/auth`: open a per-login session and bind any pending launcher capability /// for `(ip, persona)` that arrived before login. An empty `sid` is a no-op. pub fn open_session(&mut self, sid: &str, ip: Option, persona: i64, now: f64) { if sid.is_empty() { return; } self.reap(now); let resolver = self.take_pending(&ip, persona, now); self.sessions.insert( sid.to_string(), Session { ip, persona, resolver, mode: None, last_seen: now, }, ); } /// `/openfut/account/sync` hygiene: drop any stale pending for this machine so a /// new launch's unverified session cannot inherit a leftover capability. pub fn clear_pending(&mut self, ip: &str, now: f64) { self.reap(now); let ip_key = Some(ip.to_string()); self.pending.retain(|(k_ip, _), _| *k_ip != ip_key); } /// Launcher capability registration. Never authorizes more than one session: /// binds iff exactly one live, unfrozen, unbound session for `(ip, persona)` /// exists; otherwise stages a single-use pending (no session yet) or fails /// closed as ignored-late (a session exists but is frozen/ambiguous). pub fn register_capability( &mut self, ip: Option, persona: i64, version: u32, now: f64, ) -> RegisterOutcome { self.reap(now); let mut any_for_key = false; let mut candidate: Option = None; let mut candidate_count = 0usize; for (sid, r) in &self.sessions { if r.ip == ip && r.persona == persona { any_for_key = true; if r.mode.is_none() && r.resolver.is_none() { candidate = Some(sid.clone()); candidate_count += 1; } } } if candidate_count == 1 { let sid = candidate.expect("exactly one candidate"); self.sessions .get_mut(&sid) .expect("candidate session present") .resolver = Some(version); return RegisterOutcome::Bound; } if any_for_key { return RegisterOutcome::IgnoredLate; } self.pending.insert( (ip, persona), Pending { resolver: version, ts: now, }, ); RegisterOutcome::Pending } /// True if `sid` is a live session. (The legacy constant SID is accepted only by /// the retired security-question gate in Python — never used to grant clean /// mode — and is intentionally not modelled here.) pub fn session_known(&self, sid: &str) -> bool { self.sessions.contains_key(sid) } /// Freeze (once) and return the empty-My-Packs mode for FIFA session `sid`. /// Freeze point = the first `/store/purchasegroup` of the session. Fail-closed: /// an unknown session, or a `sid` presented from a different IP than it was /// opened on, resolves to [`StoreMode::Sentinel`] (and a mismatch does NOT freeze /// the real session, so a later correct-IP request can still freeze it). pub fn empty_mypacks_mode(&mut self, sid: &str, ip: Option<&str>, now: f64) -> StoreMode { self.reap(now); // Resolve/take pending without holding a mutable borrow across the call. let (session_ip, persona, already_frozen, resolver) = match self.sessions.get(sid) { None => return StoreMode::Sentinel, Some(r) => (r.ip.clone(), r.persona, r.mode, r.resolver), }; if let Some(r) = self.sessions.get_mut(sid) { r.last_seen = now; } // Fail-closed sid/ip sanity check (does not freeze). if let (Some(sess_ip), Some(req_ip)) = (session_ip.as_deref(), ip) { if sess_ip != req_ip { return StoreMode::Sentinel; } } if let Some(mode) = already_frozen { return mode; // immutable per SID } // First store request for this session: consume a still-pending capability // if the session was opened before the launcher registered, then freeze. let resolver = match resolver { Some(v) => Some(v), None => self.take_pending(&session_ip, persona, now), }; let mode = if resolver == Some(EMPTY_MYPACKS_RESOLVER_VERSION) { StoreMode::CleanV1 } else { StoreMode::Sentinel }; if let Some(r) = self.sessions.get_mut(sid) { r.resolver = resolver; r.mode = Some(mode); } mode } /// Store-catalogue decision for a `/store/purchasegroup` request. Mirrors the /// oracle's `if not owned_ids:` gate: with owned unopened packs the real /// `mypacks` group is served and no freeze occurs (`None`); only an *empty* /// My Packs freezes and returns the topology mode. pub fn store_empty_mypacks( &mut self, sid: &str, ip: Option<&str>, has_unopened_packs: bool, now: f64, ) -> Option { if has_unopened_packs { return None; } Some(self.empty_mypacks_mode(sid, ip, now)) } } #[cfg(test)] mod tests { //! Ports the Python capability-negotiation matrix A–R //! (`fifa17-recon/tools/test_capability_negotiation.py`). Python is the oracle; //! these assertions must stay in lockstep with it. use super::*; const IP1: &str = "10.10.0.105"; const IP2: &str = "10.10.0.106"; const PERSONA: i64 = 111001; fn ip(s: &str) -> Option { Some(s.to_string()) } // A: no-capability, zero packs -> sentinel #[test] fn a_no_capability_zero_packs_sentinel() { let mut s = SessionStore::new(); s.open_session("sidA", ip(IP1), PERSONA, 0.0); assert_eq!( s.store_empty_mypacks("sidA", Some(IP1), false, 1.0), Some(StoreMode::Sentinel) ); } // B: verified v1, zero packs -> clean #[test] fn b_verified_v1_zero_packs_clean() { let mut s = SessionStore::new(); s.open_session("sidB", ip(IP1), PERSONA, 0.0); assert_eq!( s.register_capability(ip(IP1), PERSONA, 1, 1.0), RegisterOutcome::Bound ); assert_eq!( s.store_empty_mypacks("sidB", Some(IP1), false, 2.0), Some(StoreMode::CleanV1) ); } // C: real unopened pack + no capability -> genuine packs (no freeze/sentinel) #[test] fn c_real_pack_no_capability_genuine() { let mut s = SessionStore::new(); s.open_session("sidC", ip(IP1), PERSONA, 0.0); assert_eq!(s.store_empty_mypacks("sidC", Some(IP1), true, 1.0), None); } // D: real unopened pack + capability -> genuine packs (no freeze) #[test] fn d_real_pack_with_capability_genuine() { let mut s = SessionStore::new(); s.open_session("sidD", ip(IP1), PERSONA, 0.0); assert_eq!( s.register_capability(ip(IP1), PERSONA, 1, 1.0), RegisterOutcome::Bound ); assert_eq!(s.store_empty_mypacks("sidD", Some(IP1), true, 2.0), None); } // E: unsupported version / capability -> endpoint rejects AND mode sentinel #[test] fn e_unsupported_capability_rejected_and_sentinel() { assert_eq!(validate_capability(CAPABILITY_NAME, 1), Ok(())); assert_eq!( validate_capability(CAPABILITY_NAME, 2), Err(CapabilityError::Unsupported) ); assert_eq!( validate_capability("something_else", 1), Err(CapabilityError::Unsupported) ); let mut s = SessionStore::new(); s.open_session("sidE", ip(IP1), PERSONA, 0.0); // A rejected registration records nothing, so the session stays sentinel. assert_eq!( s.empty_mypacks_mode("sidE", Some(IP1), 1.0), StoreMode::Sentinel ); } // F: late capability after sentinel freeze -> stays sentinel #[test] fn f_late_capability_after_sentinel_freeze_stays() { let mut s = SessionStore::new(); s.open_session("sidF", ip(IP1), PERSONA, 0.0); assert_eq!( s.empty_mypacks_mode("sidF", Some(IP1), 1.0), StoreMode::Sentinel ); assert_eq!( s.register_capability(ip(IP1), PERSONA, 1, 2.0), RegisterOutcome::IgnoredLate ); assert_eq!( s.empty_mypacks_mode("sidF", Some(IP1), 3.0), StoreMode::Sentinel ); } // G: capability "disappears" after clean freeze -> stays clean (immutable) #[test] fn g_clean_freeze_immutable() { let mut s = SessionStore::new(); s.open_session("sidG", ip(IP1), PERSONA, 0.0); assert_eq!( s.register_capability(ip(IP1), PERSONA, 1, 1.0), RegisterOutcome::Bound ); assert_eq!( s.empty_mypacks_mode("sidG", Some(IP1), 2.0), StoreMode::CleanV1 ); assert_eq!( s.empty_mypacks_mode("sidG", Some(IP1), 3.0), StoreMode::CleanV1 ); } // H: two IPs (A verified, B none) -> A clean, B sentinel (no global leak) #[test] fn h_two_ips_isolated() { let mut s = SessionStore::new(); s.open_session("sidHa", ip(IP1), PERSONA, 0.0); s.open_session("sidHb", ip(IP2), PERSONA, 0.0); assert_eq!( s.register_capability(ip(IP1), PERSONA, 1, 1.0), RegisterOutcome::Bound ); assert_eq!( s.empty_mypacks_mode("sidHa", Some(IP1), 2.0), StoreMode::CleanV1 ); assert_eq!( s.empty_mypacks_mode("sidHb", Some(IP2), 2.0), StoreMode::Sentinel ); } // I: new session after reset -> fresh unpatched -> sentinel #[test] fn i_fresh_session_sentinel() { let mut s = SessionStore::new(); s.open_session("sidI", ip(IP1), PERSONA, 10.0); assert_eq!( s.empty_mypacks_mode("sidI", Some(IP1), 11.0), StoreMode::Sentinel ); } // J: autopatch mismatch => never registers -> sentinel #[test] fn j_no_registration_sentinel() { let mut s = SessionStore::new(); s.open_session("sidJ", ip(IP1), PERSONA, 0.0); // (no register_capability call at all) assert_eq!( s.empty_mypacks_mode("sidJ", Some(IP1), 1.0), StoreMode::Sentinel ); } // K: SAME IP, two sessions (A patched, B not) -> A clean, B sentinel #[test] fn k_same_ip_two_sessions_isolated() { let mut s = SessionStore::new(); s.open_session("sidKa", ip(IP1), PERSONA, 0.0); assert_eq!( s.register_capability(ip(IP1), PERSONA, 1, 1.0), RegisterOutcome::Bound ); s.open_session("sidKb", ip(IP1), PERSONA, 2.0); assert_eq!( s.empty_mypacks_mode("sidKa", Some(IP1), 3.0), StoreMode::CleanV1 ); assert_eq!( s.empty_mypacks_mode("sidKb", Some(IP1), 3.0), StoreMode::Sentinel ); } // L: SAME IP+persona relaunch (old ok, new not) -> new session sentinel #[test] fn l_same_ip_persona_relaunch() { let mut s = SessionStore::new(); s.open_session("sidLold", ip(IP1), PERSONA, 0.0); assert_eq!( s.register_capability(ip(IP1), PERSONA, 1, 1.0), RegisterOutcome::Bound ); assert_eq!( s.empty_mypacks_mode("sidLold", Some(IP1), 2.0), StoreMode::CleanV1 ); s.open_session("sidLnew", ip(IP1), PERSONA, 3.0); assert_eq!( s.empty_mypacks_mode("sidLnew", Some(IP1), 4.0), StoreMode::Sentinel ); assert_eq!( s.empty_mypacks_mode("sidLold", Some(IP1), 5.0), StoreMode::CleanV1 ); } // M: SAME IP, failed-patch second session -> first clean, second sentinel #[test] fn m_same_ip_failed_patch_second() { let mut s = SessionStore::new(); s.open_session("sidMa", ip(IP1), PERSONA, 0.0); assert_eq!( s.register_capability(ip(IP1), PERSONA, 1, 1.0), RegisterOutcome::Bound ); s.open_session("sidMb", ip(IP1), PERSONA, 2.0); // failed patch: never registers assert_eq!( s.empty_mypacks_mode("sidMa", Some(IP1), 3.0), StoreMode::CleanV1 ); assert_eq!( s.empty_mypacks_mode("sidMb", Some(IP1), 3.0), StoreMode::Sentinel ); } // N: late registration when sessions are frozen -> does not modify active, // and does not stage a pending that a later session could inherit. #[test] fn n_late_registration_no_effect() { let mut s = SessionStore::new(); s.open_session("sidN", ip(IP1), PERSONA, 0.0); assert_eq!( s.empty_mypacks_mode("sidN", Some(IP1), 1.0), StoreMode::Sentinel ); assert_eq!( s.register_capability(ip(IP1), PERSONA, 1, 2.0), RegisterOutcome::IgnoredLate ); assert_eq!( s.empty_mypacks_mode("sidN", Some(IP1), 3.0), StoreMode::Sentinel ); // No pending was staged, so a brand-new session cannot inherit it. s.open_session("sidN2", ip(IP1), PERSONA, 4.0); assert_eq!( s.empty_mypacks_mode("sidN2", Some(IP1), 5.0), StoreMode::Sentinel ); } // O: session cleanup / TTL expiry -> capability gone, sentinel #[test] fn o_ttl_expiry() { // Pending expiry: registered before login, but login arrives too late. let mut s = SessionStore::new(); assert_eq!( s.register_capability(ip(IP1), PERSONA, 1, 0.0), RegisterOutcome::Pending ); s.open_session("sidO", ip(IP1), PERSONA, PENDING_TTL_SECS + 1.0); assert_eq!( s.empty_mypacks_mode("sidO", Some(IP1), PENDING_TTL_SECS + 2.0), StoreMode::Sentinel ); // Session expiry: a known session reaped after idle TTL becomes unknown. let mut s2 = SessionStore::new(); s2.open_session("sidO2", ip(IP1), PERSONA, 0.0); assert_eq!( s2.empty_mypacks_mode("sidO2", Some(IP1), SESSION_TTL_SECS + 1.0), StoreMode::Sentinel ); assert!(!s2.session_known("sidO2")); } // P: duplicate registration for a session -> idempotent; no post-freeze change #[test] fn p_duplicate_registration_idempotent() { let mut s = SessionStore::new(); s.open_session("sidP", ip(IP1), PERSONA, 0.0); assert_eq!( s.register_capability(ip(IP1), PERSONA, 1, 1.0), RegisterOutcome::Bound ); // Second registration: the session is now bound (resolver set), so it is no // longer an unbound candidate -> ignored-late, and the outcome is unchanged. assert_eq!( s.register_capability(ip(IP1), PERSONA, 1, 2.0), RegisterOutcome::IgnoredLate ); assert_eq!( s.empty_mypacks_mode("sidP", Some(IP1), 3.0), StoreMode::CleanV1 ); } // Q: register-before-login (pending consumed) -> clean; single-use #[test] fn q_register_before_login_pending_consumed() { let mut s = SessionStore::new(); assert_eq!( s.register_capability(ip(IP1), PERSONA, 1, 0.0), RegisterOutcome::Pending ); s.open_session("sidQ", ip(IP1), PERSONA, 1.0); assert_eq!( s.empty_mypacks_mode("sidQ", Some(IP1), 2.0), StoreMode::CleanV1 ); // Single-use: a second login for the same (ip,persona) gets no capability. s.open_session("sidQ2", ip(IP1), PERSONA, 3.0); assert_eq!( s.empty_mypacks_mode("sidQ2", Some(IP1), 4.0), StoreMode::Sentinel ); } // R: topology freeze immutable per SID -> no flip either way; new SID fresh #[test] fn r_topology_freeze_immutable_per_sid() { let mut s = SessionStore::new(); s.open_session("sidR", ip(IP1), PERSONA, 0.0); assert_eq!( s.register_capability(ip(IP1), PERSONA, 1, 1.0), RegisterOutcome::Bound ); assert_eq!( s.empty_mypacks_mode("sidR", Some(IP1), 2.0), StoreMode::CleanV1 ); assert_eq!( s.empty_mypacks_mode("sidR", Some(IP1), 3.0), StoreMode::CleanV1 ); // A brand-new SID from the same client is a fresh, unverified session. s.open_session("sidRnew", ip(IP1), PERSONA, 4.0); assert_eq!( s.empty_mypacks_mode("sidRnew", Some(IP1), 5.0), StoreMode::Sentinel ); } // Extra: sid/ip mismatch is fail-closed and does NOT freeze the real session. #[test] fn sid_ip_mismatch_fails_closed_without_freezing() { let mut s = SessionStore::new(); s.open_session("sidX", ip(IP1), PERSONA, 0.0); assert_eq!( s.register_capability(ip(IP1), PERSONA, 1, 1.0), RegisterOutcome::Bound ); // Presented from the wrong IP: sentinel, but the session is not frozen. assert_eq!( s.empty_mypacks_mode("sidX", Some(IP2), 2.0), StoreMode::Sentinel ); // The genuine client (correct IP) still freezes clean. assert_eq!( s.empty_mypacks_mode("sidX", Some(IP1), 3.0), StoreMode::CleanV1 ); } // Extra: account_sync clears a stale pending for the machine. #[test] fn clear_pending_drops_stale_hand_off() { let mut s = SessionStore::new(); assert_eq!( s.register_capability(ip(IP1), PERSONA, 1, 0.0), RegisterOutcome::Pending ); s.clear_pending(IP1, 1.0); s.open_session("sidC1", ip(IP1), PERSONA, 2.0); assert_eq!( s.empty_mypacks_mode("sidC1", Some(IP1), 3.0), StoreMode::Sentinel ); } // Extra: format_sid shape matches the legacy constant length. #[test] fn format_sid_shape() { assert_eq!( format_sid(0x42C15A6F78DC6E74), "OPENFUT-SID-42C15A6F78DC6E74" ); assert_eq!(format_sid(0).len(), "OPENFUT-SID-".len() + 16); } }