initial arhitectural overhaul
Signed-off-by: Sienna Meridian Satterwhite <sienna@r3t.io>
This commit is contained in:
465
crates/libmarathon/src/networking/session.rs
Normal file
465
crates/libmarathon/src/networking/session.rs
Normal file
@@ -0,0 +1,465 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user