//! Filesystem-backed `SessionLockRepository` implementation. //! //! Ported from `zesdex_entities::domain::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`). //! //! # Flow //! //! 1. **`try_lock`** — attempt `O_CREAT|O_EXCL` open on `/.lock`; //! if it already exists, check PID liveness; if stale, overwrite atomically. //! 2. **`unlock`** — remove the `.lock` file. //! 3. **`is_alive`** — `libc::kill(pid, 0)` + `/proc//exe` identity check. //! //! # Components //! //! - `FileSystemSessionLockRepository` — stateless singleton implementing //! `SessionLockRepository` use std::convert::TryInto; use std::fs; use std::io::Write; use std::path::Path; use tracing; use crate::domain::error::RepositoryError; 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) -> 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}")?; // → RepositoryError via From file.sync_all()?; // → RepositoryError via From tracing::debug!(path = %path.display(), pid, "session lock acquired"); return Ok(true); } Err(ref e) if e.kind() == std::io::ErrorKind::AlreadyExists => { tracing::debug!(path = %path.display(), "lock file exists, checking staleness"); } Err(e) => return Err(RepositoryError::Io(e)), } let content = fs::read_to_string(&path).unwrap_or_default(); if let Ok(existing_pid) = content.trim().parse::() { if self.is_alive(existing_pid) { tracing::warn!(existing_pid, path = %path.display(), "session lock held by live process"); return Ok(false); } tracing::debug!(existing_pid, "stale lock detected, overwriting"); } let tmp = path.with_extension("lock.tmp"); { let mut tmp_file = fs::OpenOptions::new() .create(true) .truncate(true) .write(true) .open(&tmp)?; // → RepositoryError via From write!(tmp_file, "{pid}")?; // → RepositoryError tmp_file.sync_all()?; // → RepositoryError } fs::rename(&tmp, &path)?; // → RepositoryError 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) -> Result<(), RepositoryError> { 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. // PIDs on Linux fit in i32 (default pid_max ≈ 4 million). let pid_signed: i32 = pid.try_into() .expect("PID exceeds i32 range — kernel pid_max > 2^31"); if unsafe { libc::kill(pid_signed, 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)); } }