Files
OpenFUT/openfut-adapter-fifa17/src/fut/store_session.rs
T
funman300 c7609252d2 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.
2026-08-13 16:52:13 +00:00

727 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}
}