2026-07-16 12:32:17 +07:00
|
|
|
#![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 `<base_dir>/sessions/<id>/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<Vec<Session>> {
|
|
|
|
|
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<Session> {
|
|
|
|
|
// Directory-traversal prevention.
|
|
|
|
|
if id.contains('/') || id.contains('\\') || id.contains("..") {
|
2026-07-17 06:44:31 +07:00
|
|
|
anyhow::bail!("invalid session id '{id}': must not contain path separators");
|
2026-07-16 12:32:17 +07:00
|
|
|
}
|
|
|
|
|
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("..") {
|
2026-07-17 06:44:31 +07:00
|
|
|
anyhow::bail!("invalid session id '{id}': must not contain path separators");
|
2026-07-16 12:32:17 +07:00
|
|
|
}
|
|
|
|
|
let dir = base_dir.join("sessions").join(id);
|
|
|
|
|
if dir.exists() {
|
|
|
|
|
std::fs::remove_dir_all(&dir)?;
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|