466 lines
13 KiB
Rust
466 lines
13 KiB
Rust
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<Self, SessionError> {
|
|
// 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<char> = 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<Self> {
|
|
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<String>,
|
|
|
|
/// 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<Vec<u8>>,
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|