feat(fifa17): add Rust FUT session/capability state machine (empty My Packs)
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.
This commit is contained in:
@@ -12,3 +12,4 @@ pub mod owned_query;
|
||||
pub mod squad;
|
||||
pub mod squad_ext;
|
||||
pub mod squad_projection;
|
||||
pub mod store_session;
|
||||
|
||||
@@ -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<String>,
|
||||
persona: i64,
|
||||
resolver: Option<u32>,
|
||||
mode: Option<StoreMode>,
|
||||
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<String, Session>,
|
||||
pending: HashMap<(Option<String>, 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<String>, persona: i64, now: f64) -> Option<u32> {
|
||||
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<String>, 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<String>,
|
||||
persona: i64,
|
||||
version: u32,
|
||||
now: f64,
|
||||
) -> RegisterOutcome {
|
||||
self.reap(now);
|
||||
let mut any_for_key = false;
|
||||
let mut candidate: Option<String> = 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<StoreMode> {
|
||||
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<String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user