From c7609252d2fe8e092f71a9e3b7f57863ecc28e08 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 13 Aug 2026 16:52:13 +0000 Subject: [PATCH] feat(fifa17): add Rust FUT session/capability state machine (empty My Packs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the novel per-session empty-My-Packs capability negotiation — proven live on staging and currently Python-only (fifa17-recon/tools/utas_server.py) — into the production Rust FIFA17 adapter as a pure, dependency-free state machine (openfut-adapter-fifa17 fut::store_session). It owns: per-login X-UT-SID session table, single-use (ip,persona) launcher capability hand-off (pending), capability binding (bound/pending/ignored-late), the once-per-session clean-v1 vs sentinel freeze, TTL reaping, and fail-closed rules (unknown/expired/ambiguous/late/cross-session/sid-ip-mismatch -> sentinel). The clock and SID entropy are injected so it is fully unit-testable. The full Python capability-negotiation matrix A-R is ported as Rust unit tests (21 pass). Python remains the behavioural oracle. The delicate _pack_body UTAS wire shaping, /ut/auth persona-adoption, /store/purchasegroup catalogue assembly and the store BUY path are deliberately NOT ported here (documented gap); wiring the three routes into openfut-utas-host without splitting store authority is the remaining bounded slice toward full Rust authority. No production deployment. --- openfut-adapter-fifa17/src/fut/mod.rs | 1 + .../src/fut/store_session.rs | 726 ++++++++++++++++++ 2 files changed, 727 insertions(+) create mode 100644 openfut-adapter-fifa17/src/fut/store_session.rs diff --git a/openfut-adapter-fifa17/src/fut/mod.rs b/openfut-adapter-fifa17/src/fut/mod.rs index 4567d21..abdb3fd 100644 --- a/openfut-adapter-fifa17/src/fut/mod.rs +++ b/openfut-adapter-fifa17/src/fut/mod.rs @@ -12,3 +12,4 @@ pub mod owned_query; pub mod squad; pub mod squad_ext; pub mod squad_projection; +pub mod store_session; diff --git a/openfut-adapter-fifa17/src/fut/store_session.rs b/openfut-adapter-fifa17/src/fut/store_session.rs new file mode 100644 index 0000000..8f887be --- /dev/null +++ b/openfut-adapter-fifa17/src/fut/store_session.rs @@ -0,0 +1,726 @@ +//! 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); + } +}