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:
funman300
2026-08-13 16:52:13 +00:00
parent 4cf388dd3b
commit c7609252d2
2 changed files with 727 additions and 0 deletions
+1
View File
@@ -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 AR 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 AR
//! (`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);
}
}