use std::fmt;
use std::fs::File;
use std::io::{self, ErrorKind, Read as _, Write as _};
use std::os::fd::OwnedFd;
use std::os::unix::fs::MetadataExt as _;
use std::path::{Path, PathBuf};
use rustix::fs::{AtFlags, FlockOperation, Mode, OFlags};
use rustix::io::Errno;
pub const STATE_DIR: &str = ".overbrainer";
const LOCK_FILE: &str = "lock";
const PID_READ_LIMIT: u64 = 32;
#[derive(Debug, thiserror::Error)]
pub enum LockError {
#[error("another overbrainer ({}) is using this project", holder(*.pid))]
Held {
pid: Option<u32>,
},
#[error("refusing to use {}: it is not a safe lock path", path.display())]
UnsafePath {
path: PathBuf,
},
#[error("cannot lock {}", path.display())]
Io {
path: PathBuf,
source: io::Error,
},
}
#[derive(Debug)]
struct UnsafePath(PathBuf);
impl fmt::Display for UnsafePath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"refusing to use {}: it is not a safe path",
self.0.display()
)
}
}
impl std::error::Error for UnsafePath {}
fn unsafe_path_error(path: &Path) -> io::Error {
io::Error::new(ErrorKind::InvalidInput, UnsafePath(path.to_path_buf()))
}
fn unsafe_or_io_error(path: &Path, errno: Errno) -> io::Error {
if errno == Errno::LOOP || errno == Errno::NOTDIR {
unsafe_path_error(path)
} else {
errno.into()
}
}
pub(crate) fn open_state_file(
project_dir: &Path,
name: &str,
flags: OFlags,
create_dir: bool,
) -> io::Result<File> {
let project = rustix::fs::open(
project_dir,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
Mode::empty(),
)?;
if create_dir {
match rustix::fs::mkdirat(&project, STATE_DIR, Mode::from_raw_mode(0o777)) {
Ok(()) => {},
Err(e) if e == Errno::EXIST => {},
Err(e) => return Err(e.into()),
}
}
let dir_path = project_dir.join(STATE_DIR);
let state = open_state_dir(&project).map_err(|e| unsafe_or_io_error(&dir_path, e))?;
open_in_state(&state, &dir_path, name, flags)
}
fn open_in_state(state: &OwnedFd, dir_path: &Path, name: &str, flags: OFlags) -> io::Result<File> {
let mode = if flags.contains(OFlags::CREATE) {
Mode::from_raw_mode(0o644)
} else {
Mode::empty()
};
let file_path = dir_path.join(name);
let fd = rustix::fs::openat(
state,
name,
flags | OFlags::NOFOLLOW | OFlags::CLOEXEC,
mode,
)
.map_err(|e| unsafe_or_io_error(&file_path, e))?;
let file = File::from(fd);
let metadata = file.metadata()?;
if !metadata.is_file() || metadata.nlink() != 1 {
return Err(unsafe_path_error(&file_path));
}
Ok(file)
}
pub(crate) fn remove_state_file(project_dir: &Path, name: &str) -> io::Result<()> {
let project = rustix::fs::open(
project_dir,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
Mode::empty(),
)?;
let dir_path = project_dir.join(STATE_DIR);
let state = open_state_dir(&project).map_err(|e| unsafe_or_io_error(&dir_path, e))?;
rustix::fs::unlinkat(&state, name, AtFlags::empty())
.map_err(|e| unsafe_or_io_error(&dir_path.join(name), e))
}
pub(crate) fn replace_state_file(project_dir: &Path, name: &str, content: &[u8]) -> io::Result<()> {
let project = rustix::fs::open(
project_dir,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
Mode::empty(),
)?;
match rustix::fs::mkdirat(&project, STATE_DIR, Mode::from_raw_mode(0o777)) {
Ok(()) => {},
Err(e) if e == Errno::EXIST => {},
Err(e) => return Err(e.into()),
}
let dir_path = project_dir.join(STATE_DIR);
let state = open_state_dir(&project).map_err(|e| unsafe_or_io_error(&dir_path, e))?;
let tmp = format!("{name}.tmp");
match rustix::fs::unlinkat(&state, tmp.as_str(), AtFlags::empty()) {
Ok(()) => {},
Err(e) if e == Errno::NOENT => {},
Err(e) => return Err(unsafe_or_io_error(&dir_path.join(&tmp), e)),
}
let mut file = open_in_state(
&state,
&dir_path,
&tmp,
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL,
)?;
file.write_all(content)?;
file.sync_all()?;
rustix::fs::renameat(&state, tmp.as_str(), &state, name)?;
rustix::fs::fsync(&state)?;
Ok(())
}
fn to_lock_error(fallback: &Path, error: io::Error) -> LockError {
if error.kind() == ErrorKind::InvalidInput {
let path = error
.get_ref()
.and_then(|source| source.downcast_ref::<UnsafePath>())
.map_or_else(
|| fallback.to_path_buf(),
|unsafe_path| unsafe_path.0.clone(),
);
return LockError::UnsafePath { path };
}
LockError::Io {
path: fallback.to_path_buf(),
source: error,
}
}
fn io_error(path: &Path, errno: Errno) -> LockError {
LockError::Io {
path: path.to_path_buf(),
source: errno.into(),
}
}
fn open_state_dir(project: &OwnedFd) -> Result<OwnedFd, Errno> {
rustix::fs::openat(
project,
STATE_DIR,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)
}
fn read_pid(project: &OwnedFd) -> Option<u32> {
let state = open_state_dir(project).ok()?;
let fd = rustix::fs::openat(
&state,
LOCK_FILE,
OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
Mode::empty(),
)
.ok()?;
let mut text = String::new();
File::from(fd)
.take(PID_READ_LIMIT)
.read_to_string(&mut text)
.ok()?;
text.trim().parse().ok()
}
fn holder(pid: Option<u32>) -> String {
pid.map_or_else(|| "unknown pid".to_string(), |pid| format!("pid {pid}"))
}
#[derive(Debug)]
pub struct ProjectLock {
project: OwnedFd,
}
impl ProjectLock {
pub fn acquire(project_dir: &Path) -> Result<Self, LockError> {
let dir = project_dir.join(STATE_DIR);
let pid_path = dir.join(LOCK_FILE);
let project = rustix::fs::open(
project_dir,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
Mode::empty(),
)
.map_err(|e| io_error(project_dir, e))?;
match rustix::fs::flock(&project, FlockOperation::NonBlockingLockExclusive) {
Ok(()) => {},
Err(e) if e == Errno::WOULDBLOCK => {
return Err(LockError::Held {
pid: read_pid(&project),
});
},
Err(e) => return Err(io_error(project_dir, e)),
}
let mut pid_file =
open_state_file(project_dir, LOCK_FILE, OFlags::RDWR | OFlags::CREATE, true)
.map_err(|e| to_lock_error(&pid_path, e))?;
let pid_io = |source| LockError::Io {
path: pid_path.clone(),
source,
};
pid_file.set_len(0).map_err(pid_io)?;
write!(pid_file, "{}", std::process::id()).map_err(pid_io)?;
pid_file.flush().map_err(pid_io)?;
Ok(Self { project })
}
}
impl Drop for ProjectLock {
fn drop(&mut self) {
let _ = rustix::fs::flock(&self.project, FlockOperation::Unlock);
}
}
#[cfg(test)]
mod tests {
use super::*;
type TestResult = Result<(), Box<dyn std::error::Error>>;
#[test]
fn a_second_acquire_is_refused_with_the_holder_pid() -> TestResult {
let dir = tempfile::tempdir()?;
let _held = ProjectLock::acquire(dir.path())?;
match ProjectLock::acquire(dir.path()) {
Err(LockError::Held { pid }) => assert_eq!(pid, Some(std::process::id())),
other => return Err(format!("expected Held, got {other:?}").into()),
}
Ok(())
}
#[test]
fn the_lock_is_released_on_drop() -> TestResult {
let dir = tempfile::tempdir()?;
drop(ProjectLock::acquire(dir.path())?);
ProjectLock::acquire(dir.path())?;
Ok(())
}
#[test]
fn the_lock_is_released_on_drop_while_a_dup_of_its_fd_lives() -> TestResult {
let dir = tempfile::tempdir()?;
let lock = ProjectLock::acquire(dir.path())?;
let _dup = lock.project.try_clone()?;
drop(lock);
ProjectLock::acquire(dir.path())?;
Ok(())
}
#[test]
fn removing_the_pid_file_does_not_free_the_lock() -> TestResult {
let dir = tempfile::tempdir()?;
let _held = ProjectLock::acquire(dir.path())?;
std::fs::remove_file(dir.path().join(STATE_DIR).join(LOCK_FILE))?;
match ProjectLock::acquire(dir.path()) {
Err(LockError::Held { pid }) => assert_eq!(pid, None),
other => return Err(format!("expected Held, got {other:?}").into()),
}
Ok(())
}
#[test]
fn renaming_the_state_directory_does_not_free_the_lock() -> TestResult {
let dir = tempfile::tempdir()?;
let _held = ProjectLock::acquire(dir.path())?;
std::fs::rename(dir.path().join(STATE_DIR), dir.path().join("moved"))?;
match ProjectLock::acquire(dir.path()) {
Err(LockError::Held { pid }) => assert_eq!(pid, None),
other => return Err(format!("expected Held, got {other:?}").into()),
}
assert!(!dir.path().join(STATE_DIR).exists());
Ok(())
}
#[test]
fn a_missing_project_directory_is_never_created() -> TestResult {
let dir = tempfile::tempdir()?;
let missing = dir.path().join("missing");
match ProjectLock::acquire(&missing) {
Err(error @ LockError::Io { .. }) => {
assert!(error.to_string().starts_with("cannot lock "), "{error}");
},
other => return Err(format!("expected Io, got {other:?}").into()),
}
assert!(!missing.exists());
Ok(())
}
#[test]
fn a_symlinked_state_directory_is_refused() -> TestResult {
let dir = tempfile::tempdir()?;
let elsewhere = tempfile::tempdir()?;
let state = dir.path().join(STATE_DIR);
std::os::unix::fs::symlink(elsewhere.path(), &state)?;
match ProjectLock::acquire(dir.path()) {
Err(error @ LockError::UnsafePath { .. }) => assert_eq!(
error.to_string(),
format!(
"refusing to use {}: it is not a safe lock path",
state.display()
)
),
other => return Err(format!("expected UnsafePath, got {other:?}").into()),
}
assert!(!elsewhere.path().join(LOCK_FILE).exists());
Ok(())
}
#[test]
fn a_symlinked_lock_file_is_refused_and_its_target_kept() -> TestResult {
let dir = tempfile::tempdir()?;
let elsewhere = tempfile::tempdir()?;
let target = elsewhere.path().join("precious");
std::fs::write(&target, "keep me")?;
std::fs::create_dir(dir.path().join(STATE_DIR))?;
let lock = dir.path().join(STATE_DIR).join(LOCK_FILE);
std::os::unix::fs::symlink(&target, &lock)?;
match ProjectLock::acquire(dir.path()) {
Err(LockError::UnsafePath { path }) => assert_eq!(path, lock),
other => return Err(format!("expected UnsafePath, got {other:?}").into()),
}
assert_eq!(std::fs::read_to_string(&target)?, "keep me");
Ok(())
}
#[test]
fn a_hard_linked_lock_file_is_refused_and_its_target_kept() -> TestResult {
let dir = tempfile::tempdir()?;
let target = dir.path().join("precious");
std::fs::write(&target, "keep me")?;
std::fs::create_dir(dir.path().join(STATE_DIR))?;
let lock = dir.path().join(STATE_DIR).join(LOCK_FILE);
std::fs::hard_link(&target, &lock)?;
match ProjectLock::acquire(dir.path()) {
Err(LockError::UnsafePath { path }) => assert_eq!(path, lock),
other => return Err(format!("expected UnsafePath, got {other:?}").into()),
}
assert_eq!(std::fs::read_to_string(&target)?, "keep me");
Ok(())
}
#[test]
fn the_message_names_the_pid() {
let error = LockError::Held { pid: Some(42) };
assert_eq!(
error.to_string(),
"another overbrainer (pid 42) is using this project"
);
}
#[test]
fn replace_state_file_replaces_the_file_and_leaves_no_temporary() -> TestResult {
let dir = tempfile::tempdir()?;
replace_state_file(dir.path(), "version", b"1\n")?;
replace_state_file(dir.path(), "version", b"2\n")?;
let state = dir.path().join(STATE_DIR);
assert_eq!(std::fs::read_to_string(state.join("version"))?, "2\n");
assert!(!state.join("version.tmp").exists());
Ok(())
}
#[test]
fn replace_state_file_replaces_a_symlink_without_writing_its_target() -> TestResult {
let dir = tempfile::tempdir()?;
let elsewhere = tempfile::tempdir()?;
let target = elsewhere.path().join("precious");
std::fs::write(&target, "keep me")?;
std::fs::create_dir(dir.path().join(STATE_DIR))?;
let file = dir.path().join(STATE_DIR).join("version");
std::os::unix::fs::symlink(&target, &file)?;
replace_state_file(dir.path(), "version", b"1\n")?;
assert_eq!(std::fs::read_to_string(&target)?, "keep me");
assert!(!std::fs::symlink_metadata(&file)?.file_type().is_symlink());
Ok(())
}
#[test]
fn replace_state_file_never_writes_through_a_hard_linked_stale_temporary() -> TestResult {
let dir = tempfile::tempdir()?;
let target = dir.path().join("precious");
std::fs::write(&target, "keep me")?;
std::fs::create_dir(dir.path().join(STATE_DIR))?;
let state = dir.path().join(STATE_DIR);
std::fs::hard_link(&target, state.join("version.tmp"))?;
replace_state_file(dir.path(), "version", b"1\n")?;
assert_eq!(std::fs::read_to_string(&target)?, "keep me");
assert_eq!(std::fs::read_to_string(state.join("version"))?, "1\n");
assert!(!state.join("version.tmp").exists());
Ok(())
}
#[test]
fn remove_state_file_removes_the_file() -> TestResult {
let dir = tempfile::tempdir()?;
open_state_file(
dir.path(),
"logs-x.log",
OFlags::WRONLY | OFlags::CREATE,
true,
)?;
let path = dir.path().join(STATE_DIR).join("logs-x.log");
assert!(path.exists());
remove_state_file(dir.path(), "logs-x.log")?;
assert!(!path.exists());
Ok(())
}
#[test]
fn remove_state_file_refuses_a_symlinked_state_directory() -> TestResult {
let dir = tempfile::tempdir()?;
let elsewhere = tempfile::tempdir()?;
std::fs::write(elsewhere.path().join("logs-x.log"), "keep me")?;
std::os::unix::fs::symlink(elsewhere.path(), dir.path().join(STATE_DIR))?;
match remove_state_file(dir.path(), "logs-x.log") {
Err(error) => assert_eq!(error.kind(), ErrorKind::InvalidInput),
other => return Err(format!("expected an error, got {other:?}").into()),
}
assert!(elsewhere.path().join("logs-x.log").exists());
Ok(())
}
}