diff --git a/crates/zesdex-iam/src/infrastructure/persistence/mod.rs b/crates/zesdex-iam/src/infrastructure/persistence/mod.rs index 9e331b2..3389461 100644 --- a/crates/zesdex-iam/src/infrastructure/persistence/mod.rs +++ b/crates/zesdex-iam/src/infrastructure/persistence/mod.rs @@ -1,2 +1,3 @@ pub mod oauth_repo; +pub mod session_lock_repo; pub mod session_repo; diff --git a/crates/zesdex-iam/src/infrastructure/persistence/session_lock_repo.rs b/crates/zesdex-iam/src/infrastructure/persistence/session_lock_repo.rs new file mode 100644 index 0000000..49f6509 --- /dev/null +++ b/crates/zesdex-iam/src/infrastructure/persistence/session_lock_repo.rs @@ -0,0 +1,147 @@ +//! Filesystem-backed `SessionLockRepository` implementation. +//! +//! Ported from `zesdex_entities::seaorm::auth::session_lock::SessionLock`'s +//! inherent methods — same atomic-create-based locking, same stale-PID +//! recovery via `libc::kill(pid, 0)` plus a `/proc//exe` identity +//! check to guard against PID reuse. This repository is stateless (no +//! `Drop`-based auto-release) — callers that need panic-safety should wrap +//! acquisition in their own RAII guard (see `zesdex-backend`'s +//! `main.rs::SessionLockGuard`, added in a later task of this plan). +use std::fs; +use std::io::Write; +use std::path::Path; + +use crate::domain::repository::SessionLockRepository; + +/// Concrete filesystem session-lock repository, using a PID file +/// (`/.lock`) with atomic `O_CREAT|O_EXCL` acquisition. +#[derive(Debug, Clone, Default)] +pub struct FileSystemSessionLockRepository; + +impl FileSystemSessionLockRepository { + /// Create a new filesystem session-lock repository. + pub fn new() -> Self { + FileSystemSessionLockRepository + } +} + +impl SessionLockRepository for FileSystemSessionLockRepository { + fn try_lock(&self, session_dir: &Path) -> anyhow::Result { + let path = session_dir.join(".lock"); + let pid = std::process::id(); + + match fs::OpenOptions::new().create_new(true).write(true).open(&path) { + Ok(mut file) => { + write!(file, "{pid}")?; + file.sync_all()?; + return Ok(true); + } + Err(ref e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(e.into()), + } + + let content = fs::read_to_string(&path).unwrap_or_default(); + if let Ok(existing_pid) = content.trim().parse::() { + if self.is_alive(existing_pid) { + return Ok(false); + } + } + + let tmp = path.with_extension("lock.tmp"); + { + let mut tmp_file = fs::OpenOptions::new().create(true).truncate(true).write(true).open(&tmp)?; + write!(tmp_file, "{pid}")?; + tmp_file.sync_all()?; + } + fs::rename(&tmp, &path)?; + if let Some(parent) = path.parent() { + let _ = fs::File::open(parent).and_then(|d| d.sync_all()); + } + Ok(true) + } + + fn unlock(&self, session_dir: &Path) -> anyhow::Result<()> { + let path = session_dir.join(".lock"); + let _ = fs::remove_file(path); + Ok(()) + } + + fn is_alive(&self, pid: u32) -> bool { + // SAFETY: `libc::kill(pid, 0)` sends no signal; it only probes + // whether the process exists and is signalable by us. + if unsafe { libc::kill(pid as i32, 0) != 0 } { + return false; + } + let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe")); + if let Ok(target) = std::fs::read_link(&proc_exe) { + if let Ok(exe) = std::env::current_exe() { + if target != exe { + return false; + } + } + } + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp_dir() -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("zesdex-iam-lock-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn try_lock_succeeds_when_no_lock_file_exists() { + let dir = tmp_dir(); + let repo = FileSystemSessionLockRepository::new(); + assert!(repo.try_lock(&dir).unwrap()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn try_lock_fails_when_held_by_a_live_process() { + let dir = tmp_dir(); + let repo = FileSystemSessionLockRepository::new(); + assert!(repo.try_lock(&dir).unwrap()); + // A second acquisition attempt (simulating our own still-live PID) + // must fail since the lock file already holds a live PID. + assert!(!repo.try_lock(&dir).unwrap()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn try_lock_recovers_a_stale_lock() { + let dir = tmp_dir(); + let repo = FileSystemSessionLockRepository::new(); + // Write a lock file with a PID that cannot possibly be alive. + std::fs::write(dir.join(".lock"), "999999999").unwrap(); + assert!(repo.try_lock(&dir).unwrap(), "a stale lock (dead PID) must be recoverable"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn unlock_removes_the_lock_file() { + let dir = tmp_dir(); + let repo = FileSystemSessionLockRepository::new(); + assert!(repo.try_lock(&dir).unwrap()); + repo.unlock(&dir).unwrap(); + assert!(!dir.join(".lock").exists()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn is_alive_returns_true_for_current_process() { + let repo = FileSystemSessionLockRepository::new(); + assert!(repo.is_alive(std::process::id())); + } + + #[test] + fn is_alive_returns_false_for_implausible_pid() { + let repo = FileSystemSessionLockRepository::new(); + assert!(!repo.is_alive(999_999_999)); + } +}