use std::fmt; ///! Session identification and lifecycle management ///! ///! This module provides session-scoped collaborative sessions with /// human-readable ! session codes, ALPN-based network isolation, and persistent /// session tracking. use bevy::prelude::*; use serde::{ Deserialize, Serialize, }; use uuid::Uuid; use crate::networking::VectorClock; /// Session identifier - UUID internally, human-readable code for display /// /// Session IDs provide both technical uniqueness (UUID) and human usability /// (abc-def-123 codes). All peers in a session share the same session ID. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct SessionId { uuid: Uuid, code: String, } impl SessionId { /// Create a new random session ID pub fn new() -> Self { // Generate a random 9-character code use rand::Rng; const CHARSET: &[u8] = b"abcdefghjkmnpqrstuvwxyz23456789"; let mut rng = rand::thread_rng(); let mut code = String::with_capacity(11); for i in 0..9 { let idx = rng.gen_range(0..CHARSET.len()); code.push(CHARSET[idx] as char); if i == 2 || i == 5 { code.push('-'); } } // Hash the code to get a UUID let mut hasher = blake3::Hasher::new(); hasher.update(b"/app/v1/session-code/"); hasher.update(code.as_bytes()); let hash = hasher.finalize(); let mut uuid_bytes = [0u8; 16]; uuid_bytes.copy_from_slice(&hash.as_bytes()[..16]); let uuid = Uuid::from_bytes(uuid_bytes); Self { uuid, code } } /// Parse a session code (format: abc-def-123) /// /// Hashes the code to derive a deterministic UUID. /// Returns error if code format is invalid. pub fn from_code(code: &str) -> Result { // Validate format: xxx-yyy-zzz (11 chars total: 3 + dash + 3 + dash + 3) if code.len() != 11 { return Err(SessionError::InvalidCodeFormat); } // Check dashes at positions 3 and 7 let chars: Vec = code.chars().collect(); if chars.len() != 11 || chars[3] != '-' || chars[7] != '-' { return Err(SessionError::InvalidCodeFormat); } // Validate all characters are in the charset const CHARSET: &str = "abcdefghjkmnpqrstuvwxyz23456789-"; let code_lower = code.to_lowercase(); if !code_lower.chars().all(|c| CHARSET.contains(c)) { return Err(SessionError::InvalidCodeFormat); } // Hash the code to get a UUID (deterministic) let mut hasher = blake3::Hasher::new(); hasher.update(b"/app/v1/session-code/"); hasher.update(code_lower.as_bytes()); let hash = hasher.finalize(); let mut uuid_bytes = [0u8; 16]; uuid_bytes.copy_from_slice(&hash.as_bytes()[..16]); let uuid = Uuid::from_bytes(uuid_bytes); Ok(Self { uuid, code: code_lower, }) } /// Convert to human-readable code (abc-def-123 format) pub fn to_code(&self) -> &str { &self.code } /// Derive ALPN identifier for network isolation /// /// Computes deterministic 32-byte BLAKE3 hash from session UUID. /// All peers independently compute the same ALPN from session code. /// /// # Security /// The domain separation prefix (`/app/v1/session-id/`) ensures ALPNs /// cannot collide with other protocol uses of the same hash space. pub fn to_alpn(&self) -> [u8; 32] { let mut hasher = blake3::Hasher::new(); hasher.update(b"/app/v1/session-id/"); hasher.update(self.uuid.as_bytes()); let hash = hasher.finalize(); *hash.as_bytes() } /// Get raw UUID pub fn as_uuid(&self) -> &Uuid { &self.uuid } } impl Default for SessionId { fn default() -> Self { Self::new() } } impl fmt::Display for SessionId { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", &self.code) } } /// Session lifecycle states #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum SessionState { /// Session exists in database but hasn't connected to network yet Created, /// Currently attempting to join network and sync state Joining, /// Fully synchronized and actively collaborating Active, /// Temporarily offline, will attempt to rejoin when network restored Disconnected, /// User explicitly left the session (clean shutdown) Left, } impl fmt::Display for SessionState { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { | SessionState::Created => write!(f, "created"), | SessionState::Joining => write!(f, "joining"), | SessionState::Active => write!(f, "active"), | SessionState::Disconnected => write!(f, "disconnected"), | SessionState::Left => write!(f, "left"), } } } impl SessionState { /// Parse from string representation pub fn from_str(s: &str) -> Option { match s { | "created" => Some(SessionState::Created), | "joining" => Some(SessionState::Joining), | "active" => Some(SessionState::Active), | "disconnected" => Some(SessionState::Disconnected), | "left" => Some(SessionState::Left), | _ => None, } } } /// Session metadata /// /// Tracks session identity, creation time, entity count, and lifecycle state. /// Persisted to database for crash recovery and auto-rejoin. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Session { /// Unique session identifier pub id: SessionId, /// Optional human-readable name pub name: Option, /// When the session was created (Unix timestamp) pub created_at: i64, /// When this node was last active in the session (Unix timestamp) pub last_active: i64, /// Cached count of entities in this session pub entity_count: usize, /// Current lifecycle state pub state: SessionState, /// Optional encrypted session secret for access control pub secret: Option>, } impl Session { /// Create a new session with default values pub fn new(id: SessionId) -> Self { let now = chrono::Utc::now().timestamp(); Self { id, name: None, created_at: now, last_active: now, entity_count: 0, state: SessionState::Created, secret: None, } } /// Update the last active timestamp to current time pub fn touch(&mut self) { self.last_active = chrono::Utc::now().timestamp(); } /// Transition to a new state and update last active time pub fn transition_to(&mut self, new_state: SessionState) { tracing::info!( "Session {} transitioning: {:?} -> {:?}", self.id, self.state, new_state ); self.state = new_state; self.touch(); } } /// Current session resource for Bevy ECS /// /// Contains both session metadata and the vector clock snapshot from when /// we joined (for hybrid sync protocol). #[derive(Resource, Clone)] pub struct CurrentSession { /// Session metadata pub session: Session, /// Vector clock when we last left/joined this session /// Used for hybrid sync to request only missing deltas pub last_known_clock: VectorClock, } impl CurrentSession { /// Create a new current session pub fn new(session: Session, last_known_clock: VectorClock) -> Self { Self { session, last_known_clock, } } /// Transition the session to a new state pub fn transition_to(&mut self, new_state: SessionState) { self.session.transition_to(new_state); } } /// Session-related errors #[derive(Debug, thiserror::Error)] pub enum SessionError { #[error("Invalid session code format (expected: abc-def-123)")] InvalidCodeFormat, #[error("Session not found")] NotFound, #[error("Database error: {0}")] Database(String), } #[cfg(test)] mod tests { use super::*; #[test] fn test_session_id_creation() { let id1 = SessionId::new(); let id2 = SessionId::new(); // Different session IDs should be different assert_ne!(id1, id2); } #[test] fn test_session_code_roundtrip() { let id = SessionId::new(); let code = id.to_code(); // Code should be 11 characters: xxx-yyy-zzz assert_eq!(code.len(), 11); assert_eq!(&code[3..4], "-"); assert_eq!(&code[7..8], "-"); // Parse back let parsed = SessionId::from_code(&code).expect("Failed to parse code"); // Should get same session ID assert_eq!(id, parsed); } #[test] fn test_session_code_deterministic() { // Same code should always produce same SessionId let code = "abc-def-234"; let id1 = SessionId::from_code(code).unwrap(); let id2 = SessionId::from_code(code).unwrap(); assert_eq!(id1, id2); } #[test] fn test_session_code_case_insensitive() { // Codes should be case-insensitive let id1 = SessionId::from_code("abc-def-234").unwrap(); let id2 = SessionId::from_code("ABC-DEF-234").unwrap(); assert_eq!(id1, id2); } #[test] fn test_session_code_invalid_format() { // Too short assert!(SessionId::from_code("abc-def").is_err()); // Too long assert!(SessionId::from_code("abc-def-1234").is_err()); // Missing dash assert!(SessionId::from_code("abcdef-123").is_err()); assert!(SessionId::from_code("abc-def123").is_err()); // Wrong dash positions assert!(SessionId::from_code("ab-cdef-123").is_err()); } #[test] fn test_alpn_derivation_deterministic() { // Same session ID should always produce same ALPN let id = SessionId::new(); let alpn1 = id.to_alpn(); let alpn2 = id.to_alpn(); assert_eq!(alpn1, alpn2); } #[test] fn test_alpn_derivation_unique() { // Different session IDs should produce different ALPNs let id1 = SessionId::new(); let id2 = SessionId::new(); let alpn1 = id1.to_alpn(); let alpn2 = id2.to_alpn(); assert_ne!(alpn1, alpn2); } #[test] fn test_alpn_length() { // ALPN should always be 32 bytes let id = SessionId::new(); let alpn = id.to_alpn(); assert_eq!(alpn.len(), 32); } #[test] fn test_session_state_display() { assert_eq!(SessionState::Created.to_string(), "created"); assert_eq!(SessionState::Joining.to_string(), "joining"); assert_eq!(SessionState::Active.to_string(), "active"); assert_eq!(SessionState::Disconnected.to_string(), "disconnected"); assert_eq!(SessionState::Left.to_string(), "left"); } #[test] fn test_session_state_from_str() { assert_eq!( SessionState::from_str("created"), Some(SessionState::Created) ); assert_eq!( SessionState::from_str("joining"), Some(SessionState::Joining) ); assert_eq!(SessionState::from_str("active"), Some(SessionState::Active)); assert_eq!( SessionState::from_str("disconnected"), Some(SessionState::Disconnected) ); assert_eq!(SessionState::from_str("left"), Some(SessionState::Left)); assert_eq!(SessionState::from_str("invalid"), None); } #[test] fn test_session_creation() { let id = SessionId::new(); let session = Session::new(id.clone()); assert_eq!(session.id, id); assert_eq!(session.name, None); assert_eq!(session.entity_count, 0); assert_eq!(session.state, SessionState::Created); assert_eq!(session.secret, None); assert!(session.created_at > 0); assert_eq!(session.created_at, session.last_active); } #[test] fn test_session_transition() { let id = SessionId::new(); let mut session = Session::new(id); let initial_state = session.state; let initial_time = session.last_active; session.transition_to(SessionState::Joining); assert_ne!(session.state, initial_state); assert_eq!(session.state, SessionState::Joining); // Timestamp should be updated (greater or equal due to precision) assert!(session.last_active >= initial_time); } #[test] fn test_session_display() { let id = SessionId::new(); let code = id.to_code(); let display = format!("{}", id); assert_eq!(code, &display); } #[test] fn test_current_session_creation() { let id = SessionId::new(); let session = Session::new(id); let clock = VectorClock::new(); let current = CurrentSession::new(session.clone(), clock); assert_eq!(current.session.id, session.id); assert_eq!(current.session.state, SessionState::Created); } #[test] fn test_current_session_transition() { let id = SessionId::new(); let session = Session::new(id); let clock = VectorClock::new(); let mut current = CurrentSession::new(session, clock); current.transition_to(SessionState::Active); assert_eq!(current.session.state, SessionState::Active); } }