Skip to main content

bsv/auth/
session_manager.rs

1//! Session management for the BRC-31 authentication protocol.
2//!
3//! SessionManager tracks authenticated sessions by both session nonce
4//! (primary key) and peer identity key (secondary index), supporting
5//! multiple concurrent sessions per identity key.
6//!
7//! Translated from TS SDK SessionManager.ts and Go SDK session_manager.go.
8
9use std::collections::{HashMap, HashSet, VecDeque};
10
11use super::types::PeerSession;
12
13/// Default idle TTL for a session: 15 minutes.
14///
15/// A session untouched for longer than this is considered dead: lookups honor
16/// it via [`SessionManager::get_active_session`] (returning `None` so the peer
17/// simply re-handshakes) and [`SessionManager::reap_idle`] physically evicts it
18/// and frees its replay seen-set. 15 min is *far* larger than the normal
19/// sub-second idle gap between authenticated messages, so a live client never
20/// trips it; a stale/captured `yourNonce` no longer resolves a session forever.
21///
22/// **Intentional deviation from the TS SDK**: TS `SessionManager` never
23/// expires or reaps (`lastUpdate` is only a best-session tiebreaker), which
24/// leaks session + replay-set memory on long-lived processes. Rust keeps the
25/// reap as a memory-bound improvement. This is safe because expiry is
26/// enforced consistently on *both* the verify path (`get_active_session`) and
27/// the reuse paths (`Peer::get_authenticated_session`,
28/// `Peer::create_general_message`), and clients self-heal: `AuthFetch`
29/// detects the resulting `SessionNotFound` / unsigned-401 as a stale session
30/// and re-handshakes (mirroring TS AuthFetch.ts:268-283).
31pub const DEFAULT_SESSION_IDLE_TTL_MS: u64 = 15 * 60 * 1000;
32
33/// Default per-session cap on remembered message nonces (FIFO eviction).
34///
35/// Bounds memory for a long-lived busy session. Only signature-verified
36/// messages ever reach the seen-set, so an attacker cannot inflate it; a real
37/// peer would have to legitimately send this many messages to slide the window,
38/// and the idle TTL frees the whole set on reap.
39pub const DEFAULT_SEEN_NONCE_CAP: usize = 10_000;
40
41/// Outcome of an anti-replay check-and-insert.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum MarkSeen {
44    /// First time this `(session, message-nonce)` pair was seen — accept.
45    Fresh,
46    /// Byte-for-byte replay of an already-seen message nonce — reject.
47    Replay,
48    /// The session no longer exists (never added, or reaped) — reject.
49    SessionGone,
50}
51
52/// Per-session anti-replay + activity bookkeeping.
53///
54/// Kept out of [`PeerSession`] itself so the session stays cheap to `.clone()`
55/// out of the read lock on the hot verify path; this metadata is only touched
56/// under the (brief, `.await`-free) write lock during the replay check.
57struct SessionMeta {
58    /// Wall-clock ms of the last successful activity on this session.
59    last_used_ms: u64,
60    /// Membership set of message nonces for O(1) replay detection.
61    seen: HashSet<String>,
62    /// Insertion order, so the oldest nonce can be FIFO-evicted at the cap.
63    order: VecDeque<String>,
64}
65
66/// Manages authenticated peer sessions with dual-index tracking.
67///
68/// Sessions are indexed by:
69/// - Session nonce (primary key, unique per session)
70/// - Identity key (secondary index, one-to-many)
71///
72/// This allows lookup by either nonce or identity key, with the identity
73/// key lookup returning the "best" session (preferring authenticated ones).
74pub struct SessionManager {
75    /// Maps session_nonce -> PeerSession (primary index).
76    nonce_to_session: HashMap<String, PeerSession>,
77    /// Maps identity_key -> set of session nonces (secondary index).
78    identity_to_nonces: HashMap<String, HashSet<String>>,
79    /// Per-session replay + activity bookkeeping, keyed by session_nonce.
80    /// Created lazily on first `touch`/`mark_message_seen`; dropped whenever the
81    /// session is removed or reaped, which frees its seen-set (memory bound).
82    session_meta: HashMap<String, SessionMeta>,
83    /// Idle TTL in ms (see [`DEFAULT_SESSION_IDLE_TTL_MS`]).
84    idle_ttl_ms: u64,
85    /// Per-session remembered-nonce cap (see [`DEFAULT_SEEN_NONCE_CAP`]).
86    seen_nonce_cap: usize,
87}
88
89impl SessionManager {
90    /// Create a new empty SessionManager with default TTL / seen-set cap.
91    pub fn new() -> Self {
92        Self::with_config(DEFAULT_SESSION_IDLE_TTL_MS, DEFAULT_SEEN_NONCE_CAP)
93    }
94
95    /// Create a new empty SessionManager with an explicit idle TTL and
96    /// per-session replay seen-set cap. Used by tests and by callers that want
97    /// a tighter reaping policy.
98    pub fn with_config(idle_ttl_ms: u64, seen_nonce_cap: usize) -> Self {
99        SessionManager {
100            nonce_to_session: HashMap::new(),
101            identity_to_nonces: HashMap::new(),
102            session_meta: HashMap::new(),
103            idle_ttl_ms,
104            seen_nonce_cap,
105        }
106    }
107
108    /// The configured idle TTL in milliseconds.
109    pub fn idle_ttl_ms(&self) -> u64 {
110        self.idle_ttl_ms
111    }
112
113    /// Add a session to the manager.
114    ///
115    /// Indexes by session_nonce (primary) and peer_identity_key (secondary).
116    /// Does NOT overwrite existing sessions for the same identity key,
117    /// allowing multiple concurrent sessions per peer.
118    pub fn add_session(&mut self, session: PeerSession) {
119        let nonce = session.session_nonce.clone();
120        let identity = session.peer_identity_key.clone();
121
122        self.nonce_to_session.insert(nonce.clone(), session);
123
124        self.identity_to_nonces
125            .entry(identity)
126            .or_default()
127            .insert(nonce);
128    }
129
130    /// Get a session by nonce (immutable reference).
131    pub fn get_session(&self, nonce: &str) -> Option<&PeerSession> {
132        self.nonce_to_session.get(nonce)
133    }
134
135    /// Get a session by nonce (mutable reference).
136    pub fn get_session_mut(&mut self, nonce: &str) -> Option<&mut PeerSession> {
137        self.nonce_to_session.get_mut(nonce)
138    }
139
140    /// Get all sessions for a given identity key.
141    pub fn get_sessions_for_identity(&self, identity_key: &str) -> Vec<&PeerSession> {
142        match self.identity_to_nonces.get(identity_key) {
143            Some(nonces) => nonces
144                .iter()
145                .filter_map(|n| self.nonce_to_session.get(n))
146                .collect(),
147            None => Vec::new(),
148        }
149    }
150
151    /// Get the "best" session for an identity key (prefers authenticated).
152    ///
153    /// Matches TS SDK SessionManager.getSession() behavior: if the identifier
154    /// is a session nonce, returns that exact session. If it is an identity key,
155    /// returns the best (authenticated preferred) session.
156    pub fn get_session_by_identifier(&self, identifier: &str) -> Option<&PeerSession> {
157        // Try as direct nonce first
158        if let Some(session) = self.nonce_to_session.get(identifier) {
159            return Some(session);
160        }
161
162        // Try as identity key
163        let nonces = self.identity_to_nonces.get(identifier)?;
164        let mut best: Option<&PeerSession> = None;
165        for nonce in nonces {
166            if let Some(session) = self.nonce_to_session.get(nonce) {
167                match best {
168                    None => best = Some(session),
169                    Some(b) => {
170                        // Prefer authenticated sessions
171                        if session.is_authenticated && !b.is_authenticated {
172                            best = Some(session);
173                        }
174                    }
175                }
176            }
177        }
178        best
179    }
180
181    /// Check if a session exists for a given nonce.
182    pub fn has_session(&self, nonce: &str) -> bool {
183        self.nonce_to_session.contains_key(nonce)
184    }
185
186    /// Check if any session exists for a given identifier (nonce or identity key).
187    pub fn has_session_by_identifier(&self, identifier: &str) -> bool {
188        if self.nonce_to_session.contains_key(identifier) {
189            return true;
190        }
191        match self.identity_to_nonces.get(identifier) {
192            Some(nonces) => !nonces.is_empty(),
193            None => false,
194        }
195    }
196
197    /// Replace a session at the given nonce.
198    pub fn update_session(&mut self, nonce: &str, session: PeerSession) {
199        // Remove old identity mapping if the identity key changed
200        if let Some(old_session) = self.nonce_to_session.get(nonce) {
201            let old_identity = old_session.peer_identity_key.clone();
202            if old_identity != session.peer_identity_key {
203                if let Some(nonces) = self.identity_to_nonces.get_mut(&old_identity) {
204                    nonces.remove(nonce);
205                    if nonces.is_empty() {
206                        self.identity_to_nonces.remove(&old_identity);
207                    }
208                }
209            }
210        }
211
212        let new_identity = session.peer_identity_key.clone();
213        self.nonce_to_session.insert(nonce.to_string(), session);
214        self.identity_to_nonces
215            .entry(new_identity)
216            .or_default()
217            .insert(nonce.to_string());
218    }
219
220    /// Remove a session by nonce. Returns the removed session if found.
221    ///
222    /// Also drops the session's replay/activity metadata, freeing its seen-set.
223    pub fn remove_session(&mut self, nonce: &str) -> Option<PeerSession> {
224        // Drop replay/activity bookkeeping regardless of session presence.
225        self.session_meta.remove(nonce);
226        if let Some(session) = self.nonce_to_session.remove(nonce) {
227            // Clean up identity index
228            if let Some(nonces) = self.identity_to_nonces.get_mut(&session.peer_identity_key) {
229                nonces.remove(nonce);
230                if nonces.is_empty() {
231                    self.identity_to_nonces.remove(&session.peer_identity_key);
232                }
233            }
234            Some(session)
235        } else {
236            None
237        }
238    }
239
240    // -----------------------------------------------------------------------
241    // Anti-replay + idle-TTL eviction (receiver-side, wire-compatible).
242    //
243    // These are STRICTLY-BETTER additions over the TS reference, which tracks
244    // `lastUpdate` but never reaps and has no per-message replay set. The clock
245    // is injected (`now_ms`) so this module stays pure / wasm-safe — the
246    // network-gated `Peer` supplies the wall clock.
247    // -----------------------------------------------------------------------
248
249    /// Record activity on an existing session, creating its metadata if absent
250    /// and advancing `last_used_ms` to `now_ms`. No-op if the session is unknown.
251    ///
252    /// Call this right after a handshake adds/promotes a session so that idle
253    /// reaping has a baseline, and on every accepted message thereafter.
254    pub fn touch(&mut self, session_nonce: &str, now_ms: u64) {
255        if !self.nonce_to_session.contains_key(session_nonce) {
256            return;
257        }
258        self.session_meta
259            .entry(session_nonce.to_string())
260            .or_insert_with(|| SessionMeta {
261                last_used_ms: now_ms,
262                seen: HashSet::new(),
263                order: VecDeque::new(),
264            })
265            .last_used_ms = now_ms;
266    }
267
268    /// True if the session has recorded activity AND has been idle longer than
269    /// the configured TTL. A session with no recorded activity yet (freshly
270    /// added, not touched) is treated as fresh (not expired).
271    pub fn is_expired(&self, session_nonce: &str, now_ms: u64) -> bool {
272        match self.session_meta.get(session_nonce) {
273            Some(m) => now_ms.saturating_sub(m.last_used_ms) > self.idle_ttl_ms,
274            None => false,
275        }
276    }
277
278    /// Look up a session by identifier (nonce or identity key) honoring the
279    /// idle TTL: returns `None` if the session is missing OR has gone idle past
280    /// the TTL. The hot verify path uses this so a stale/captured `yourNonce`
281    /// stops resolving a live session once the TTL lapses.
282    pub fn get_active_session(&self, identifier: &str, now_ms: u64) -> Option<&PeerSession> {
283        let session = self.get_session_by_identifier(identifier)?;
284        if self.is_expired(&session.session_nonce, now_ms) {
285            return None;
286        }
287        Some(session)
288    }
289
290    /// Anti-replay check-and-insert for a single per-message nonce, scoped to a
291    /// session. Atomic under `&mut self` (the caller's brief write lock), so
292    /// concurrent verifies of the SAME captured message resolve to exactly one
293    /// [`MarkSeen::Fresh`] and the rest [`MarkSeen::Replay`].
294    ///
295    /// The effective replay key is `(peer_identity_key, session_nonce,
296    /// message_nonce)`: `session_nonce` selects the per-session set (which is
297    /// itself bound to one peer identity via the handshake), and `message_nonce`
298    /// is the fresh 32-byte random nonce an honest sender mints per outbound
299    /// message — so a legit sender never collides and only a byte-replay repeats.
300    ///
301    /// Advances `last_used_ms` and enforces the per-session FIFO cap. Only call
302    /// AFTER the message signature has verified, so the set is never poisoned by
303    /// unauthenticated input.
304    pub fn mark_message_seen(
305        &mut self,
306        session_nonce: &str,
307        message_nonce: &str,
308        now_ms: u64,
309    ) -> MarkSeen {
310        if !self.nonce_to_session.contains_key(session_nonce) {
311            return MarkSeen::SessionGone;
312        }
313        let cap = self.seen_nonce_cap;
314        let meta = self
315            .session_meta
316            .entry(session_nonce.to_string())
317            .or_insert_with(|| SessionMeta {
318                last_used_ms: now_ms,
319                seen: HashSet::new(),
320                order: VecDeque::new(),
321            });
322        meta.last_used_ms = now_ms;
323
324        if !meta.seen.insert(message_nonce.to_string()) {
325            return MarkSeen::Replay;
326        }
327        meta.order.push_back(message_nonce.to_string());
328        while meta.order.len() > cap {
329            if let Some(old) = meta.order.pop_front() {
330                meta.seen.remove(&old);
331            }
332        }
333        MarkSeen::Fresh
334    }
335
336    /// Evict every session idle longer than the configured TTL, freeing each
337    /// one's replay seen-set. Returns the number reaped.
338    ///
339    /// Intended to be called on the low-frequency handshake path (not the hot
340    /// verify path), which bounds memory without serializing message verifies.
341    pub fn reap_idle(&mut self, now_ms: u64) -> usize {
342        let ttl = self.idle_ttl_ms;
343        let expired: Vec<String> = self
344            .session_meta
345            .iter()
346            .filter(|(_, m)| now_ms.saturating_sub(m.last_used_ms) > ttl)
347            .map(|(nonce, _)| nonce.clone())
348            .collect();
349        let mut reaped = 0;
350        for nonce in expired {
351            if self.remove_session(&nonce).is_some() {
352                reaped += 1;
353            }
354        }
355        reaped
356    }
357
358    /// Test/introspection helper: number of remembered message nonces for a
359    /// session (0 if the session has no metadata).
360    #[doc(hidden)]
361    pub fn seen_nonce_count(&self, session_nonce: &str) -> usize {
362        self.session_meta
363            .get(session_nonce)
364            .map(|m| m.seen.len())
365            .unwrap_or(0)
366    }
367}
368
369impl Default for SessionManager {
370    fn default() -> Self {
371        Self::new()
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    fn make_session(nonce: &str, identity: &str, authenticated: bool) -> PeerSession {
380        PeerSession {
381            session_nonce: nonce.to_string(),
382            peer_identity_key: identity.to_string(),
383            peer_nonce: format!("peer_{nonce}"),
384            is_authenticated: authenticated,
385        }
386    }
387
388    #[test]
389    fn test_add_and_get_session() {
390        let mut mgr = SessionManager::new();
391        let session = make_session("nonce1", "id_key_A", true);
392        mgr.add_session(session.clone());
393
394        let retrieved = mgr.get_session("nonce1").unwrap();
395        assert_eq!(retrieved.session_nonce, "nonce1");
396        assert_eq!(retrieved.peer_identity_key, "id_key_A");
397        assert!(retrieved.is_authenticated);
398    }
399
400    #[test]
401    fn test_has_session() {
402        let mut mgr = SessionManager::new();
403        assert!(!mgr.has_session("nonce1"));
404
405        mgr.add_session(make_session("nonce1", "id_key_A", true));
406        assert!(mgr.has_session("nonce1"));
407        assert!(!mgr.has_session("nonce2"));
408    }
409
410    #[test]
411    fn test_remove_session() {
412        let mut mgr = SessionManager::new();
413        mgr.add_session(make_session("nonce1", "id_key_A", true));
414
415        let removed = mgr.remove_session("nonce1").unwrap();
416        assert_eq!(removed.session_nonce, "nonce1");
417        assert!(!mgr.has_session("nonce1"));
418
419        // Identity index should also be cleaned up
420        let sessions = mgr.get_sessions_for_identity("id_key_A");
421        assert!(sessions.is_empty());
422    }
423
424    #[test]
425    fn test_get_sessions_for_identity() {
426        let mut mgr = SessionManager::new();
427        mgr.add_session(make_session("nonce1", "id_key_A", true));
428        mgr.add_session(make_session("nonce2", "id_key_A", false));
429        mgr.add_session(make_session("nonce3", "id_key_B", true));
430
431        let a_sessions = mgr.get_sessions_for_identity("id_key_A");
432        assert_eq!(a_sessions.len(), 2);
433
434        let b_sessions = mgr.get_sessions_for_identity("id_key_B");
435        assert_eq!(b_sessions.len(), 1);
436
437        let c_sessions = mgr.get_sessions_for_identity("id_key_C");
438        assert!(c_sessions.is_empty());
439    }
440
441    #[test]
442    fn test_get_session_by_identifier() {
443        let mut mgr = SessionManager::new();
444        mgr.add_session(make_session("nonce1", "id_key_A", false));
445        mgr.add_session(make_session("nonce2", "id_key_A", true));
446
447        // Direct nonce lookup
448        let s = mgr.get_session_by_identifier("nonce1").unwrap();
449        assert_eq!(s.session_nonce, "nonce1");
450
451        // Identity key lookup should prefer authenticated session
452        let best = mgr.get_session_by_identifier("id_key_A").unwrap();
453        assert!(best.is_authenticated);
454    }
455
456    #[test]
457    fn test_has_session_by_identifier() {
458        let mut mgr = SessionManager::new();
459        mgr.add_session(make_session("nonce1", "id_key_A", true));
460
461        assert!(mgr.has_session_by_identifier("nonce1"));
462        assert!(mgr.has_session_by_identifier("id_key_A"));
463        assert!(!mgr.has_session_by_identifier("unknown"));
464    }
465
466    #[test]
467    fn test_update_session() {
468        let mut mgr = SessionManager::new();
469        mgr.add_session(make_session("nonce1", "id_key_A", false));
470
471        // Update to authenticated
472        let updated = make_session("nonce1", "id_key_A", true);
473        mgr.update_session("nonce1", updated);
474
475        let s = mgr.get_session("nonce1").unwrap();
476        assert!(s.is_authenticated);
477    }
478
479    #[test]
480    fn test_get_session_mut() {
481        let mut mgr = SessionManager::new();
482        mgr.add_session(make_session("nonce1", "id_key_A", false));
483
484        let s = mgr.get_session_mut("nonce1").unwrap();
485        s.is_authenticated = true;
486
487        let s2 = mgr.get_session("nonce1").unwrap();
488        assert!(s2.is_authenticated);
489    }
490
491    #[test]
492    fn test_remove_nonexistent() {
493        let mut mgr = SessionManager::new();
494        assert!(mgr.remove_session("nonexistent").is_none());
495    }
496
497    #[test]
498    fn test_identity_cleanup_on_remove_last_session() {
499        let mut mgr = SessionManager::new();
500        mgr.add_session(make_session("nonce1", "id_key_A", true));
501        mgr.add_session(make_session("nonce2", "id_key_A", true));
502
503        mgr.remove_session("nonce1");
504        // Still has one session for identity
505        assert!(mgr.has_session_by_identifier("id_key_A"));
506
507        mgr.remove_session("nonce2");
508        // Now identity should be cleaned up
509        assert!(!mgr.has_session_by_identifier("id_key_A"));
510    }
511
512    // -----------------------------------------------------------------------
513    // Anti-replay + idle-TTL eviction tests
514    // -----------------------------------------------------------------------
515
516    /// Item 1: a replayed (same session + same message nonce) is REJECTED while
517    /// a fresh per-message nonce always passes.
518    #[test]
519    fn test_replay_seen_set_rejects_duplicate_but_accepts_fresh() {
520        let mut mgr = SessionManager::new();
521        mgr.add_session(make_session("sess1", "id_key_A", true));
522        mgr.touch("sess1", 1_000);
523
524        // First sighting of msg nonce "m1" is fresh.
525        assert_eq!(mgr.mark_message_seen("sess1", "m1", 1_000), MarkSeen::Fresh);
526        // Byte-replay of the exact same message nonce is rejected.
527        assert_eq!(
528            mgr.mark_message_seen("sess1", "m1", 1_001),
529            MarkSeen::Replay
530        );
531        // A different fresh nonce still passes on the same session.
532        assert_eq!(mgr.mark_message_seen("sess1", "m2", 1_002), MarkSeen::Fresh);
533        // Unknown session cannot be marked.
534        assert_eq!(
535            mgr.mark_message_seen("ghost", "m1", 1_003),
536            MarkSeen::SessionGone
537        );
538    }
539
540    /// Item 2: a session past its TTL is evicted and its seen-set freed.
541    #[test]
542    fn test_reap_idle_evicts_expired_and_frees_seen_set() {
543        let ttl = 60_000; // 1 minute
544        let mut mgr = SessionManager::with_config(ttl, DEFAULT_SEEN_NONCE_CAP);
545        mgr.add_session(make_session("sess1", "id_key_A", true));
546        mgr.touch("sess1", 10_000);
547        mgr.mark_message_seen("sess1", "m1", 10_000);
548        mgr.mark_message_seen("sess1", "m2", 10_000);
549        assert_eq!(mgr.seen_nonce_count("sess1"), 2);
550
551        // Well within TTL: still present, still remembers nonces.
552        assert!(!mgr.is_expired("sess1", 10_000 + ttl));
553        assert_eq!(mgr.reap_idle(10_000 + ttl), 0);
554        assert!(mgr.has_session("sess1"));
555
556        // Past TTL: expired, reaped, seen-set freed.
557        assert!(mgr.is_expired("sess1", 10_000 + ttl + 1));
558        assert_eq!(mgr.reap_idle(10_000 + ttl + 1), 1);
559        assert!(!mgr.has_session("sess1"));
560        assert_eq!(mgr.seen_nonce_count("sess1"), 0);
561        // After eviction the captured nonce no longer resolves a session.
562        assert!(mgr.get_active_session("sess1", 10_000 + ttl + 2).is_none());
563    }
564
565    /// Item 3: two different sessions do not cross-contaminate their seen-sets.
566    #[test]
567    fn test_two_sessions_do_not_cross_contaminate() {
568        let mut mgr = SessionManager::new();
569        mgr.add_session(make_session("sessA", "id_key_A", true));
570        mgr.add_session(make_session("sessB", "id_key_B", true));
571
572        // Same message nonce value on two distinct sessions: both fresh.
573        assert_eq!(
574            mgr.mark_message_seen("sessA", "shared", 1_000),
575            MarkSeen::Fresh
576        );
577        assert_eq!(
578            mgr.mark_message_seen("sessB", "shared", 1_000),
579            MarkSeen::Fresh
580        );
581        // Replays are still caught per-session.
582        assert_eq!(
583            mgr.mark_message_seen("sessA", "shared", 1_001),
584            MarkSeen::Replay
585        );
586        assert_eq!(
587            mgr.mark_message_seen("sessB", "shared", 1_001),
588            MarkSeen::Replay
589        );
590    }
591
592    /// The per-session seen-set is bounded: it never exceeds the cap, and the
593    /// oldest nonce is FIFO-evicted (documents the finite replay window).
594    #[test]
595    fn test_seen_set_is_bounded_by_cap() {
596        let cap = 4;
597        let mut mgr = SessionManager::with_config(DEFAULT_SESSION_IDLE_TTL_MS, cap);
598        mgr.add_session(make_session("sess1", "id_key_A", true));
599
600        for i in 0..6 {
601            let n = format!("m{i}");
602            assert_eq!(mgr.mark_message_seen("sess1", &n, 1_000), MarkSeen::Fresh);
603        }
604        // Memory is bounded to `cap`.
605        assert_eq!(mgr.seen_nonce_count("sess1"), cap);
606        // The most recent `cap` nonces are still remembered (replay caught).
607        assert_eq!(
608            mgr.mark_message_seen("sess1", "m5", 1_000),
609            MarkSeen::Replay
610        );
611        // The oldest (m0, m1) fell out of the window (FIFO eviction).
612        assert_eq!(mgr.mark_message_seen("sess1", "m0", 1_000), MarkSeen::Fresh);
613    }
614
615    /// A freshly-added session with no recorded activity is never treated as
616    /// expired (avoids reaping a session before its first message).
617    #[test]
618    fn test_new_session_without_activity_is_not_expired() {
619        let mut mgr = SessionManager::with_config(1, DEFAULT_SEEN_NONCE_CAP);
620        mgr.add_session(make_session("sess1", "id_key_A", true));
621        // No touch yet -> no metadata -> not expired regardless of clock.
622        assert!(!mgr.is_expired("sess1", u64::MAX));
623        assert!(mgr.get_active_session("sess1", u64::MAX).is_some());
624        assert_eq!(mgr.reap_idle(u64::MAX), 0);
625    }
626}