use std::fs::{File, OpenOptions};
use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
pub const DEFAULT_SLEEP: Duration = Duration::from_millis(50);
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_NAME_LEN: usize = 255;
#[derive(Debug, thiserror::Error)]
pub enum LockError {
#[error("lock '{0}' busy after timeout")]
Busy(String),
#[error("lock name length {0} is invalid (must be 1..={MAX_NAME_LEN})")]
NameLength(usize),
#[error("lock name contains an invalid character (`/`, `\\`, or NUL): {0:?}")]
InvalidName(String),
#[error(transparent)]
Io(#[from] io::Error),
}
pub type LockResult<T> = Result<T, LockError>;
#[must_use = "RepoLock releases on drop; bind it to a name to keep the lock"]
#[derive(Debug)]
pub struct RepoLock {
file: Option<File>,
path: PathBuf,
}
impl RepoLock {
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
pub fn release(&mut self) {
if let Some(file) = self.file.take() {
let _ = file.unlock();
drop(file);
let _ = std::fs::remove_file(&self.path);
}
}
}
impl Drop for RepoLock {
fn drop(&mut self) {
self.release();
}
}
pub fn acquire(dir: &Path, name: &str, timeout: Duration) -> LockResult<RepoLock> {
if name.is_empty() || name.len() > MAX_NAME_LEN {
return Err(LockError::NameLength(name.len()));
}
if name.contains('/') || name.contains('\\') || name.contains('\0') {
return Err(LockError::InvalidName(name.to_string()));
}
let path = dir.join(name);
let start = Instant::now();
loop {
match OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(file) => {
file.lock()?;
return Ok(RepoLock {
file: Some(file),
path,
});
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
if start.elapsed() >= timeout {
return Err(LockError::Busy(name.to_string()));
}
std::thread::sleep(DEFAULT_SLEEP);
}
Err(e) => return Err(LockError::Io(e)),
}
}
}
pub fn acquire_default(dir: &Path, name: &str) -> LockResult<RepoLock> {
acquire(dir, name, DEFAULT_TIMEOUT)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn acquire_release_round_trip() {
let dir = TempDir::new().unwrap();
{
let lock = acquire_default(dir.path(), "index.lock").unwrap();
assert!(lock.path().is_file());
assert_eq!(lock.path().file_name().unwrap(), "index.lock");
}
assert!(!dir.path().join("index.lock").exists());
}
#[test]
fn second_acquire_after_release_succeeds() {
let dir = TempDir::new().unwrap();
let l1 = acquire_default(dir.path(), "index.lock").unwrap();
drop(l1);
let l2 = acquire_default(dir.path(), "index.lock").unwrap();
assert!(l2.path().is_file());
}
#[test]
fn acquire_while_held_returns_busy_after_short_timeout() {
let dir = TempDir::new().unwrap();
let _l1 = acquire_default(dir.path(), "index.lock").unwrap();
let err = acquire(dir.path(), "index.lock", Duration::from_millis(150)).unwrap_err();
assert!(matches!(err, LockError::Busy(_)));
}
#[test]
fn release_is_idempotent() {
let dir = TempDir::new().unwrap();
let mut lock = acquire_default(dir.path(), "index.lock").unwrap();
lock.release();
lock.release(); assert!(!dir.path().join("index.lock").exists());
}
#[test]
fn acquire_rejects_empty_name() {
let dir = TempDir::new().unwrap();
let err = acquire(dir.path(), "", DEFAULT_TIMEOUT).unwrap_err();
assert!(matches!(err, LockError::NameLength(0)));
}
#[test]
fn acquire_rejects_oversize_name() {
let dir = TempDir::new().unwrap();
let huge = "a".repeat(300);
let err = acquire(dir.path(), &huge, DEFAULT_TIMEOUT).unwrap_err();
assert!(matches!(err, LockError::NameLength(300)));
}
#[test]
fn acquire_rejects_separators() {
let dir = TempDir::new().unwrap();
assert!(matches!(
acquire(dir.path(), "../escape", DEFAULT_TIMEOUT).unwrap_err(),
LockError::InvalidName(_)
));
assert!(matches!(
acquire(dir.path(), "sub/lock", DEFAULT_TIMEOUT).unwrap_err(),
LockError::InvalidName(_)
));
}
#[test]
fn acquire_rejects_backslash_and_nul() {
let dir = TempDir::new().unwrap();
assert!(matches!(
acquire(dir.path(), "has\\backslash", DEFAULT_TIMEOUT).unwrap_err(),
LockError::InvalidName(_)
));
assert!(matches!(
acquire(dir.path(), "has\0nul", DEFAULT_TIMEOUT).unwrap_err(),
LockError::InvalidName(_)
));
}
#[test]
fn two_distinct_lock_names_coexist() {
let dir = TempDir::new().unwrap();
let _a = acquire_default(dir.path(), "a.lock").unwrap();
let _b = acquire_default(dir.path(), "b.lock").unwrap();
assert!(dir.path().join("a.lock").is_file());
assert!(dir.path().join("b.lock").is_file());
}
}