Skip to main content

ipfrs_network/
peer_session.rs

1//! Authenticated peer session management with capability negotiation,
2//! session tokens, and expiry tracking.
3
4use std::collections::HashMap;
5
6/// Capabilities that can be negotiated for a peer session.
7#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
8pub enum SessionCapability {
9    /// Block exchange protocol support
10    BlockExchange,
11    /// Vector-based similarity search
12    VectorSearch,
13    /// Tensor logic operations
14    TensorLogic,
15    /// Gossip relay forwarding
16    GossipRelay,
17    /// Bootstrap coordination
18    Bootstrap,
19}
20
21/// Compute FNV-1a hash of a byte slice.
22fn fnv1a(data: &[u8]) -> u64 {
23    const OFFSET_BASIS: u64 = 14_695_981_039_346_656_037;
24    const PRIME: u64 = 1_099_511_628_211;
25    let mut hash = OFFSET_BASIS;
26    for &byte in data {
27        hash ^= u64::from(byte);
28        hash = hash.wrapping_mul(PRIME);
29    }
30    hash
31}
32
33/// Compute the token_id as FNV-1a hash of (peer_id + nonce string).
34fn compute_token_id(peer_id: &str, nonce: u64) -> u64 {
35    let combined = format!("{}{}", peer_id, nonce);
36    fnv1a(combined.as_bytes())
37}
38
39/// A session token encoding identity, timing, and nonce.
40#[derive(Clone, Debug, PartialEq, Eq)]
41pub struct SessionToken {
42    /// FNV-1a hash of (peer_id + nonce string)
43    pub token_id: u64,
44    /// Peer identifier
45    pub peer_id: String,
46    /// Random nonce for this session
47    pub nonce: u64,
48    /// Unix timestamp when the token was issued
49    pub issued_at_secs: u64,
50    /// Unix timestamp when the token expires
51    pub expires_at_secs: u64,
52}
53
54impl SessionToken {
55    /// Returns `true` if the token has expired relative to `now_secs`.
56    pub fn is_expired(&self, now_secs: u64) -> bool {
57        now_secs >= self.expires_at_secs
58    }
59
60    /// Returns the number of seconds until expiry (saturating at 0).
61    pub fn remaining_secs(&self, now_secs: u64) -> u64 {
62        self.expires_at_secs.saturating_sub(now_secs)
63    }
64}
65
66/// A live peer session with traffic statistics and negotiated capabilities.
67#[derive(Clone, Debug)]
68pub struct PeerSession {
69    /// Authentication token for this session
70    pub token: SessionToken,
71    /// Capabilities negotiated at session open time
72    pub capabilities: Vec<SessionCapability>,
73    /// Total bytes sent to the peer in this session
74    pub bytes_sent: u64,
75    /// Total bytes received from the peer in this session
76    pub bytes_received: u64,
77    /// Total number of messages exchanged
78    pub message_count: u64,
79}
80
81impl PeerSession {
82    /// Returns `true` if the session has the given capability.
83    pub fn has_capability(&self, cap: SessionCapability) -> bool {
84        self.capabilities.contains(&cap)
85    }
86}
87
88/// Configuration for `PeerSessionManager`.
89#[derive(Clone, Debug)]
90pub struct SessionManagerConfig {
91    /// Session time-to-live in seconds (default: 3600)
92    pub session_ttl_secs: u64,
93    /// Maximum number of concurrent sessions (default: 1000)
94    pub max_sessions: usize,
95    /// Starting nonce value — incremented for each new session
96    pub nonce_seed: u64,
97}
98
99impl Default for SessionManagerConfig {
100    fn default() -> Self {
101        Self {
102            session_ttl_secs: 3600,
103            max_sessions: 1000,
104            nonce_seed: 0,
105        }
106    }
107}
108
109/// Manages authenticated peer sessions with capability negotiation and expiry.
110pub struct PeerSessionManager {
111    /// Active sessions keyed by token_id
112    sessions: HashMap<u64, PeerSession>,
113    /// Manager configuration
114    config: SessionManagerConfig,
115    /// Monotonically increasing nonce counter
116    next_nonce: u64,
117}
118
119impl PeerSessionManager {
120    /// Create a new `PeerSessionManager` with the given configuration.
121    pub fn new(config: SessionManagerConfig) -> Self {
122        let next_nonce = config.nonce_seed;
123        Self {
124            sessions: HashMap::new(),
125            config,
126            next_nonce,
127        }
128    }
129
130    /// Open a new authenticated session for `peer_id` with `capabilities`.
131    ///
132    /// Returns `Ok(token_id)` on success or `Err("session limit reached")` when
133    /// the maximum number of concurrent sessions has been reached.
134    pub fn open_session(
135        &mut self,
136        peer_id: String,
137        capabilities: Vec<SessionCapability>,
138        now_secs: u64,
139    ) -> Result<u64, String> {
140        if self.sessions.len() >= self.config.max_sessions {
141            return Err("session limit reached".to_string());
142        }
143
144        let nonce = self.next_nonce;
145        self.next_nonce = self.next_nonce.wrapping_add(1);
146
147        let token_id = compute_token_id(&peer_id, nonce);
148        let expires_at_secs = now_secs.saturating_add(self.config.session_ttl_secs);
149
150        let token = SessionToken {
151            token_id,
152            peer_id,
153            nonce,
154            issued_at_secs: now_secs,
155            expires_at_secs,
156        };
157
158        let session = PeerSession {
159            token,
160            capabilities,
161            bytes_sent: 0,
162            bytes_received: 0,
163            message_count: 0,
164        };
165
166        self.sessions.insert(token_id, session);
167        Ok(token_id)
168    }
169
170    /// Look up an active session by its token_id.
171    pub fn get_session(&self, token_id: u64) -> Option<&PeerSession> {
172        self.sessions.get(&token_id)
173    }
174
175    /// Record traffic for a session: increments `bytes_sent`, `bytes_received`,
176    /// and `message_count` by 1 for each call.
177    pub fn record_traffic(&mut self, token_id: u64, sent: u64, received: u64) {
178        if let Some(session) = self.sessions.get_mut(&token_id) {
179            session.bytes_sent = session.bytes_sent.saturating_add(sent);
180            session.bytes_received = session.bytes_received.saturating_add(received);
181            session.message_count = session.message_count.saturating_add(1);
182        }
183    }
184
185    /// Close (remove) a session by token_id.  Returns `true` if a session was removed.
186    pub fn close_session(&mut self, token_id: u64) -> bool {
187        self.sessions.remove(&token_id).is_some()
188    }
189
190    /// Remove all sessions that have expired relative to `now_secs`.
191    /// Returns the number of sessions that were removed.
192    pub fn evict_expired(&mut self, now_secs: u64) -> usize {
193        let before = self.sessions.len();
194        self.sessions
195            .retain(|_, session| !session.token.is_expired(now_secs));
196        before - self.sessions.len()
197    }
198
199    /// Return references to all sessions that have not expired.
200    pub fn active_sessions(&self, now_secs: u64) -> Vec<&PeerSession> {
201        self.sessions
202            .values()
203            .filter(|s| !s.token.is_expired(now_secs))
204            .collect()
205    }
206
207    /// Return references to all non-expired sessions that have the given capability.
208    pub fn sessions_with_capability(
209        &self,
210        cap: SessionCapability,
211        now_secs: u64,
212    ) -> Vec<&PeerSession> {
213        self.sessions
214            .values()
215            .filter(|s| !s.token.is_expired(now_secs) && s.has_capability(cap))
216            .collect()
217    }
218}
219
220// ─────────────────────────────────────────────────────────────────────────────
221// Tests
222// ─────────────────────────────────────────────────────────────────────────────
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    fn default_manager() -> PeerSessionManager {
229        PeerSessionManager::new(SessionManagerConfig::default())
230    }
231
232    fn now() -> u64 {
233        1_700_000_000_u64
234    }
235
236    // ── 1. open_session returns a token_id ────────────────────────────────────
237
238    #[test]
239    fn open_session_returns_token_id() {
240        let mut mgr = default_manager();
241        let result = mgr.open_session(
242            "peer-alpha".to_string(),
243            vec![SessionCapability::BlockExchange],
244            now(),
245        );
246        assert!(result.is_ok());
247    }
248
249    // ── 2. token_id matches FNV-1a(peer_id + nonce) ───────────────────────────
250
251    #[test]
252    fn token_id_is_correct_fnv1a_hash() {
253        let mut mgr = PeerSessionManager::new(SessionManagerConfig {
254            nonce_seed: 42,
255            ..Default::default()
256        });
257        let token_id = mgr
258            .open_session("peer-x".to_string(), vec![], now())
259            .expect("open_session failed");
260        let expected = compute_token_id("peer-x", 42);
261        assert_eq!(token_id, expected);
262    }
263
264    // ── 3. get_session returns Some for existing session ──────────────────────
265
266    #[test]
267    fn get_session_returns_some_for_existing() {
268        let mut mgr = default_manager();
269        let tid = mgr
270            .open_session("peer-a".to_string(), vec![], now())
271            .expect("open_session failed");
272        assert!(mgr.get_session(tid).is_some());
273    }
274
275    // ── 4. get_session returns None for unknown token_id ─────────────────────
276
277    #[test]
278    fn get_session_returns_none_for_unknown() {
279        let mgr = default_manager();
280        assert!(mgr.get_session(9999).is_none());
281    }
282
283    // ── 5. session limit Err ──────────────────────────────────────────────────
284
285    #[test]
286    fn open_session_errors_when_at_limit() {
287        let mut mgr = PeerSessionManager::new(SessionManagerConfig {
288            max_sessions: 2,
289            ..Default::default()
290        });
291        mgr.open_session("p1".to_string(), vec![], now())
292            .expect("first session");
293        mgr.open_session("p2".to_string(), vec![], now())
294            .expect("second session");
295        let err = mgr.open_session("p3".to_string(), vec![], now());
296        assert_eq!(err, Err("session limit reached".to_string()));
297    }
298
299    // ── 6. has_capability true ────────────────────────────────────────────────
300
301    #[test]
302    fn has_capability_returns_true_when_present() {
303        let mut mgr = default_manager();
304        let tid = mgr
305            .open_session(
306                "peer-b".to_string(),
307                vec![
308                    SessionCapability::VectorSearch,
309                    SessionCapability::Bootstrap,
310                ],
311                now(),
312            )
313            .expect("open_session failed");
314        let session = mgr.get_session(tid).expect("session not found");
315        assert!(session.has_capability(SessionCapability::VectorSearch));
316        assert!(session.has_capability(SessionCapability::Bootstrap));
317    }
318
319    // ── 7. has_capability false ───────────────────────────────────────────────
320
321    #[test]
322    fn has_capability_returns_false_when_absent() {
323        let mut mgr = default_manager();
324        let tid = mgr
325            .open_session(
326                "peer-c".to_string(),
327                vec![SessionCapability::BlockExchange],
328                now(),
329            )
330            .expect("open_session failed");
331        let session = mgr.get_session(tid).expect("session not found");
332        assert!(!session.has_capability(SessionCapability::TensorLogic));
333    }
334
335    // ── 8. record_traffic updates bytes and message_count ────────────────────
336
337    #[test]
338    fn record_traffic_updates_bytes_and_count() {
339        let mut mgr = default_manager();
340        let tid = mgr
341            .open_session("peer-d".to_string(), vec![], now())
342            .expect("open_session failed");
343        mgr.record_traffic(tid, 100, 200);
344        mgr.record_traffic(tid, 50, 75);
345        let session = mgr.get_session(tid).expect("session not found");
346        assert_eq!(session.bytes_sent, 150);
347        assert_eq!(session.bytes_received, 275);
348        assert_eq!(session.message_count, 2);
349    }
350
351    // ── 9. record_traffic on unknown id is a no-op ────────────────────────────
352
353    #[test]
354    fn record_traffic_unknown_id_is_noop() {
355        let mut mgr = default_manager();
356        // Should not panic
357        mgr.record_traffic(0xdeadbeef, 1, 1);
358    }
359
360    // ── 10. close_session removes and returns true ────────────────────────────
361
362    #[test]
363    fn close_session_removes_session() {
364        let mut mgr = default_manager();
365        let tid = mgr
366            .open_session("peer-e".to_string(), vec![], now())
367            .expect("open_session failed");
368        assert!(mgr.close_session(tid));
369        assert!(mgr.get_session(tid).is_none());
370    }
371
372    // ── 11. close_session returns false for unknown id ────────────────────────
373
374    #[test]
375    fn close_session_returns_false_for_unknown() {
376        let mut mgr = default_manager();
377        assert!(!mgr.close_session(0xdeadbeef));
378    }
379
380    // ── 12. evict_expired removes only expired sessions ───────────────────────
381
382    #[test]
383    fn evict_expired_removes_only_expired() {
384        let mut mgr = PeerSessionManager::new(SessionManagerConfig {
385            session_ttl_secs: 100,
386            ..Default::default()
387        });
388        let t0 = 1_000_u64;
389        // Open two sessions at t0
390        let tid_a = mgr
391            .open_session("peer-f".to_string(), vec![], t0)
392            .expect("open a");
393        let tid_b = mgr
394            .open_session("peer-g".to_string(), vec![], t0)
395            .expect("open b");
396
397        // Advance time past both sessions' expiry
398        let t1 = t0 + 200;
399        // Also open a fresh session at t1
400        let tid_c = mgr
401            .open_session("peer-h".to_string(), vec![], t1)
402            .expect("open c");
403
404        let removed = mgr.evict_expired(t1);
405        assert_eq!(removed, 2);
406        assert!(mgr.get_session(tid_a).is_none());
407        assert!(mgr.get_session(tid_b).is_none());
408        assert!(mgr.get_session(tid_c).is_some());
409    }
410
411    // ── 13. evict_expired returns 0 when nothing expires ─────────────────────
412
413    #[test]
414    fn evict_expired_returns_zero_when_nothing_expired() {
415        let mut mgr = default_manager();
416        mgr.open_session("peer-i".to_string(), vec![], now())
417            .expect("open");
418        let removed = mgr.evict_expired(now());
419        assert_eq!(removed, 0);
420    }
421
422    // ── 14. active_sessions counts correctly ──────────────────────────────────
423
424    #[test]
425    fn active_sessions_counts_correctly() {
426        let mut mgr = PeerSessionManager::new(SessionManagerConfig {
427            session_ttl_secs: 50,
428            ..Default::default()
429        });
430        let t0 = 1_000_u64;
431        mgr.open_session("p1".to_string(), vec![], t0).expect("p1");
432        mgr.open_session("p2".to_string(), vec![], t0).expect("p2");
433
434        // At t0+30 both are still active
435        assert_eq!(mgr.active_sessions(t0 + 30).len(), 2);
436
437        // At t0+60 both have expired
438        assert_eq!(mgr.active_sessions(t0 + 60).len(), 0);
439    }
440
441    // ── 15. sessions_with_capability filters correctly ────────────────────────
442
443    #[test]
444    fn sessions_with_capability_filters_correctly() {
445        let mut mgr = default_manager();
446        let t = now();
447        mgr.open_session(
448            "p1".to_string(),
449            vec![SessionCapability::GossipRelay, SessionCapability::Bootstrap],
450            t,
451        )
452        .expect("p1");
453        mgr.open_session("p2".to_string(), vec![SessionCapability::BlockExchange], t)
454            .expect("p2");
455        mgr.open_session("p3".to_string(), vec![SessionCapability::GossipRelay], t)
456            .expect("p3");
457
458        let gossip_peers = mgr.sessions_with_capability(SessionCapability::GossipRelay, t);
459        assert_eq!(gossip_peers.len(), 2);
460
461        let block_peers = mgr.sessions_with_capability(SessionCapability::BlockExchange, t);
462        assert_eq!(block_peers.len(), 1);
463
464        let tensor_peers = mgr.sessions_with_capability(SessionCapability::TensorLogic, t);
465        assert_eq!(tensor_peers.len(), 0);
466    }
467
468    // ── 16. is_expired / remaining_secs ──────────────────────────────────────
469
470    #[test]
471    fn token_is_expired_and_remaining_secs() {
472        let token = SessionToken {
473            token_id: 1,
474            peer_id: "p".to_string(),
475            nonce: 0,
476            issued_at_secs: 1000,
477            expires_at_secs: 1100,
478        };
479
480        assert!(!token.is_expired(1099));
481        assert!(token.is_expired(1100));
482        assert!(token.is_expired(1200));
483
484        assert_eq!(token.remaining_secs(1000), 100);
485        assert_eq!(token.remaining_secs(1099), 1);
486        assert_eq!(token.remaining_secs(1100), 0);
487        // saturating_sub: past expiry → 0
488        assert_eq!(token.remaining_secs(1200), 0);
489    }
490
491    // ── 17. sessions_with_capability excludes expired sessions ────────────────
492
493    #[test]
494    fn sessions_with_capability_excludes_expired() {
495        let mut mgr = PeerSessionManager::new(SessionManagerConfig {
496            session_ttl_secs: 10,
497            ..Default::default()
498        });
499        let t0 = 500_u64;
500        mgr.open_session(
501            "old-peer".to_string(),
502            vec![SessionCapability::VectorSearch],
503            t0,
504        )
505        .expect("old peer");
506
507        // Advance past expiry
508        let t1 = t0 + 20;
509        mgr.open_session(
510            "new-peer".to_string(),
511            vec![SessionCapability::VectorSearch],
512            t1,
513        )
514        .expect("new peer");
515
516        let results = mgr.sessions_with_capability(SessionCapability::VectorSearch, t1);
517        assert_eq!(results.len(), 1);
518        assert_eq!(results[0].token.peer_id, "new-peer");
519    }
520
521    // ── 18. nonce increments per session ─────────────────────────────────────
522
523    #[test]
524    fn nonce_increments_per_session() {
525        let mut mgr = PeerSessionManager::new(SessionManagerConfig {
526            nonce_seed: 10,
527            ..Default::default()
528        });
529        let t = now();
530        let tid1 = mgr.open_session("pa".to_string(), vec![], t).expect("s1");
531        let tid2 = mgr.open_session("pa".to_string(), vec![], t).expect("s2");
532
533        // Same peer_id but different nonces → different token_ids
534        assert_ne!(tid1, tid2);
535
536        let s1 = mgr.get_session(tid1).expect("s1 not found");
537        let s2 = mgr.get_session(tid2).expect("s2 not found");
538        assert_eq!(s1.token.nonce, 10);
539        assert_eq!(s2.token.nonce, 11);
540    }
541
542    // ── 19. token issued_at and expires_at are set correctly ─────────────────
543
544    #[test]
545    fn token_timestamps_are_set_correctly() {
546        let mut mgr = PeerSessionManager::new(SessionManagerConfig {
547            session_ttl_secs: 7200,
548            ..Default::default()
549        });
550        let t = now();
551        let tid = mgr
552            .open_session("peer-ts".to_string(), vec![], t)
553            .expect("open");
554        let session = mgr.get_session(tid).expect("get");
555        assert_eq!(session.token.issued_at_secs, t);
556        assert_eq!(session.token.expires_at_secs, t + 7200);
557    }
558
559    // ── 20. after close_session, slot can be reused ───────────────────────────
560
561    #[test]
562    fn close_session_frees_slot_for_reuse() {
563        let mut mgr = PeerSessionManager::new(SessionManagerConfig {
564            max_sessions: 1,
565            ..Default::default()
566        });
567        let t = now();
568        let tid = mgr.open_session("p1".to_string(), vec![], t).expect("p1");
569        // At capacity
570        assert!(mgr.open_session("p2".to_string(), vec![], t).is_err());
571        // Free the slot
572        mgr.close_session(tid);
573        // Now it should succeed
574        assert!(mgr.open_session("p2".to_string(), vec![], t).is_ok());
575    }
576}