use std::path::{Path, PathBuf};
use crate::Result;
#[cfg(unix)]
const PRIMARY_LOCK_DIR: &str = "/var/lock";
#[cfg(unix)]
const FALLBACK_LOCK_DIR: &str = "/tmp";
#[derive(Debug)]
pub struct UucpLock {
path: PathBuf,
active: bool,
}
impl UucpLock {
pub fn acquire(device_path: &str) -> Result<Self> {
#[cfg(not(unix))]
{
let _ = device_path;
Ok(Self {
path: PathBuf::new(),
active: false,
})
}
#[cfg(unix)]
{
match Self::acquire_in(device_path, Path::new(PRIMARY_LOCK_DIR)) {
Ok(lock) => Ok(lock),
Err(crate::Error::Io(err)) if can_fallback(&err) => {
tracing::warn!(
primary = PRIMARY_LOCK_DIR,
fallback = FALLBACK_LOCK_DIR,
error = %err,
"UUCP lock falling back to per-user directory",
);
Self::acquire_in(device_path, Path::new(FALLBACK_LOCK_DIR))
}
Err(other) => Err(other),
}
}
}
#[cfg_attr(not(unix), allow(unused_variables))]
pub fn acquire_in(device_path: &str, lock_dir: &Path) -> Result<Self> {
#[cfg(not(unix))]
{
Ok(Self {
path: PathBuf::new(),
active: false,
})
}
#[cfg(unix)]
{
use std::fs::{self, OpenOptions};
use std::io::Write;
let basename = basename_of(device_path);
let lock_path = lock_dir.join(format!("LCK..{basename}"));
if lock_path.exists() {
match read_pid(&lock_path) {
Ok(pid) if pid_is_alive(pid) => {
return Err(crate::Error::AlreadyLocked {
device: device_path.to_string(),
pid,
lock_file: lock_path,
});
}
_ => {
let _ = fs::remove_file(&lock_path);
}
}
}
let mut f = OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock_path)?;
#[allow(clippy::cast_possible_wrap)]
let pid = std::process::id() as i32;
writeln!(f, "{pid:>10}")?;
f.sync_all()?;
Ok(Self {
path: lock_path,
active: true,
})
}
}
#[must_use]
#[allow(clippy::missing_const_for_fn)]
pub fn lock_file_path(&self) -> &Path {
&self.path
}
}
impl Drop for UucpLock {
fn drop(&mut self) {
if self.active {
let _ = std::fs::remove_file(&self.path);
}
}
}
#[cfg(unix)]
fn basename_of(path: &str) -> &str {
path.rsplit('/').next().unwrap_or(path)
}
#[cfg(unix)]
fn read_pid(lock_path: &Path) -> Result<i32> {
let content = std::fs::read_to_string(lock_path)?;
content
.trim()
.parse::<i32>()
.map_err(|err| crate::Error::InvalidLock(format!("{err} in {content:?}")))
}
#[cfg(unix)]
fn pid_is_alive(pid: i32) -> bool {
use nix::sys::signal::kill;
use nix::unistd::Pid;
matches!(kill(Pid::from_raw(pid), None), Ok(()))
}
#[cfg(unix)]
fn can_fallback(err: &std::io::Error) -> bool {
matches!(
err.kind(),
std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::NotFound
)
}
#[cfg(test)]
#[cfg(unix)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn basename_strips_directory_components() {
assert_eq!(basename_of("/dev/ttyUSB0"), "ttyUSB0");
assert_eq!(basename_of("ttyS1"), "ttyS1");
assert_eq!(basename_of("/a/b/c/d"), "d");
}
#[test]
fn acquire_in_creates_lock_file_at_uucp_path() {
let dir = tempdir().unwrap();
let lock = UucpLock::acquire_in("/dev/ttyUSB0", dir.path()).unwrap();
let expected = dir.path().join("LCK..ttyUSB0");
assert_eq!(lock.lock_file_path(), &expected);
assert!(expected.exists(), "lock file should exist on disk");
}
#[test]
fn acquire_in_writes_pid_in_uucp_format() {
let dir = tempdir().unwrap();
let _lock = UucpLock::acquire_in("/dev/ttyUSB0", dir.path()).unwrap();
let content = fs::read_to_string(dir.path().join("LCK..ttyUSB0")).unwrap();
assert_eq!(content.len(), 11, "expected 10 PID chars + LF: {content:?}");
assert!(content.ends_with('\n'), "trailing LF: {content:?}");
let parsed: i32 = content.trim().parse().expect("decimal PID");
#[allow(clippy::cast_possible_wrap)]
let our_pid = std::process::id() as i32;
assert_eq!(parsed, our_pid);
}
#[test]
fn drop_removes_lock_file() {
let dir = tempdir().unwrap();
let path = {
let lock = UucpLock::acquire_in("/dev/ttyUSB0", dir.path()).unwrap();
lock.lock_file_path().to_path_buf()
};
assert!(!path.exists(), "lock file should be removed on Drop");
}
#[test]
fn second_acquire_for_same_device_reports_already_locked() {
let dir = tempdir().unwrap();
let _first = UucpLock::acquire_in("/dev/ttyUSB0", dir.path()).unwrap();
let err = UucpLock::acquire_in("/dev/ttyUSB0", dir.path()).unwrap_err();
match err {
crate::Error::AlreadyLocked { device, pid, .. } => {
assert_eq!(device, "/dev/ttyUSB0");
#[allow(clippy::cast_possible_wrap)]
let our_pid = std::process::id() as i32;
assert_eq!(pid, our_pid);
}
other => panic!("expected AlreadyLocked, got {other:?}"),
}
}
#[test]
fn stale_lock_with_dead_pid_is_overwritten() {
let dir = tempdir().unwrap();
let lock_path = dir.path().join("LCK..ttyUSB0");
fs::write(&lock_path, "1999999999\n").unwrap();
let lock = UucpLock::acquire_in("/dev/ttyUSB0", dir.path()).unwrap();
let content = fs::read_to_string(lock.lock_file_path()).unwrap();
let parsed: i32 = content.trim().parse().unwrap();
#[allow(clippy::cast_possible_wrap)]
let our_pid = std::process::id() as i32;
assert_eq!(parsed, our_pid);
}
#[test]
fn stale_lock_with_garbage_content_is_overwritten() {
let dir = tempdir().unwrap();
let lock_path = dir.path().join("LCK..ttyUSB0");
fs::write(&lock_path, "not-a-pid\n").unwrap();
let lock = UucpLock::acquire_in("/dev/ttyUSB0", dir.path()).unwrap();
#[allow(clippy::cast_possible_wrap)]
let our_pid = std::process::id() as i32;
let content = fs::read_to_string(lock.lock_file_path()).unwrap();
assert_eq!(content.trim().parse::<i32>().unwrap(), our_pid);
}
#[test]
fn unrelated_lock_files_are_left_alone() {
let dir = tempdir().unwrap();
let other = dir.path().join("LCK..ttyS9");
fs::write(&other, "1\n").unwrap();
let _lock = UucpLock::acquire_in("/dev/ttyUSB0", dir.path()).unwrap();
assert!(other.exists(), "we must not touch unrelated lock files");
}
}