#![allow( clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap )] //! Filesystem-backed `SessionRepository` implementation. //! //! Each session is stored as `/sessions//session.json`. //! Writes use a write-then-rename + fsync pattern for crash safety. use std::path::Path; use crate::domain::repository::SessionRepository; use crate::domain::session::Session; /// Concrete filesystem session repository. #[derive(Debug, Clone, Default)] pub struct FileSystemSessionRepository; impl FileSystemSessionRepository { /// Create a new filesystem session repository. pub fn new() -> Self { FileSystemSessionRepository } } impl SessionRepository for FileSystemSessionRepository { fn list_sessions(&self, base_dir: &Path) -> anyhow::Result> { let sessions_dir = base_dir.join("sessions"); let Ok(entries) = std::fs::read_dir(&sessions_dir) else { return Ok(Vec::new()); }; let mut sessions = Vec::new(); for entry in entries.flatten() { if !entry.path().is_dir() { continue; } let id = entry.file_name().to_string_lossy().to_string(); if let Ok(session) = self.load_session(base_dir, &id) { sessions.push(session); } } Ok(sessions) } fn load_session(&self, base_dir: &Path, id: &str) -> anyhow::Result { // Directory-traversal prevention. if id.contains('/') || id.contains('\\') || id.contains("..") { anyhow::bail!("invalid session id '{id}': must not contain path separators"); } let path = base_dir.join("sessions").join(id).join("session.json"); if !path.exists() { anyhow::bail!("session not found: {id}"); } let data = std::fs::read_to_string(&path)?; let session: Session = serde_json::from_str(&data)?; Ok(session) } fn save_session(&self, base_dir: &Path, session: &Session) -> anyhow::Result<()> { let dir = session.session_dir(base_dir); std::fs::create_dir_all(&dir)?; let path = dir.join("session.json"); let data = serde_json::to_string_pretty(session)?; let tmp = dir.join("session.json.tmp"); std::fs::write(&tmp, data)?; // fsync before rename ensures the data is on disk. let f = std::fs::File::open(&tmp)?; f.sync_all()?; std::fs::rename(&tmp, path)?; // fsync the parent directory so the rename survives a crash. if let Some(parent) = dir.parent() { let _ = std::fs::File::open(parent).and_then(|d| d.sync_all()); } Ok(()) } fn delete_session(&self, base_dir: &Path, id: &str) -> anyhow::Result<()> { if id.contains('/') || id.contains('\\') || id.contains("..") { anyhow::bail!("invalid session id '{id}': must not contain path separators"); } let dir = base_dir.join("sessions").join(id); if dir.exists() { std::fs::remove_dir_all(&dir)?; } Ok(()) } }