1use std::path::{Path, PathBuf};
28
29use crate::Result;
30
31#[cfg(unix)]
33const PRIMARY_LOCK_DIR: &str = "/var/lock";
34
35#[cfg(unix)]
38const FALLBACK_LOCK_DIR: &str = "/tmp";
39
40#[derive(Debug)]
43pub struct UucpLock {
44 path: PathBuf,
45 active: bool,
48}
49
50impl UucpLock {
51 pub fn acquire(device_path: &str) -> Result<Self> {
62 #[cfg(not(unix))]
63 {
64 let _ = device_path;
65 Ok(Self {
66 path: PathBuf::new(),
67 active: false,
68 })
69 }
70 #[cfg(unix)]
71 {
72 match Self::acquire_in(device_path, Path::new(PRIMARY_LOCK_DIR)) {
73 Ok(lock) => Ok(lock),
74 Err(crate::Error::Io(err)) if can_fallback(&err) => {
75 tracing::warn!(
76 primary = PRIMARY_LOCK_DIR,
77 fallback = FALLBACK_LOCK_DIR,
78 error = %err,
79 "UUCP lock falling back to per-user directory",
80 );
81 Self::acquire_in(device_path, Path::new(FALLBACK_LOCK_DIR))
82 }
83 Err(other) => Err(other),
84 }
85 }
86 }
87
88 #[cfg_attr(not(unix), allow(unused_variables))]
97 pub fn acquire_in(device_path: &str, lock_dir: &Path) -> Result<Self> {
98 #[cfg(not(unix))]
99 {
100 Ok(Self {
101 path: PathBuf::new(),
102 active: false,
103 })
104 }
105 #[cfg(unix)]
106 {
107 use std::fs::{self, OpenOptions};
108 use std::io::Write;
109
110 let basename = basename_of(device_path);
111 let lock_path = lock_dir.join(format!("LCK..{basename}"));
112
113 if lock_path.exists() {
115 match read_pid(&lock_path) {
116 Ok(pid) if pid_is_alive(pid) => {
117 return Err(crate::Error::AlreadyLocked {
118 device: device_path.to_string(),
119 pid,
120 lock_file: lock_path,
121 });
122 }
123 _ => {
128 let _ = fs::remove_file(&lock_path);
129 }
130 }
131 }
132
133 let mut f = OpenOptions::new()
136 .write(true)
137 .create_new(true)
138 .open(&lock_path)?;
139
140 #[allow(clippy::cast_possible_wrap)]
142 let pid = std::process::id() as i32;
143 writeln!(f, "{pid:>10}")?;
144 f.sync_all()?;
145
146 Ok(Self {
147 path: lock_path,
148 active: true,
149 })
150 }
151 }
152
153 #[must_use]
160 #[allow(clippy::missing_const_for_fn)]
161 pub fn lock_file_path(&self) -> &Path {
162 &self.path
163 }
164}
165
166impl Drop for UucpLock {
167 fn drop(&mut self) {
168 if self.active {
169 let _ = std::fs::remove_file(&self.path);
170 }
171 }
172}
173
174#[cfg(unix)]
177fn basename_of(path: &str) -> &str {
178 path.rsplit('/').next().unwrap_or(path)
179}
180
181#[cfg(unix)]
183fn read_pid(lock_path: &Path) -> Result<i32> {
184 let content = std::fs::read_to_string(lock_path)?;
185 content
186 .trim()
187 .parse::<i32>()
188 .map_err(|err| crate::Error::InvalidLock(format!("{err} in {content:?}")))
189}
190
191#[cfg(unix)]
195fn pid_is_alive(pid: i32) -> bool {
196 use nix::sys::signal::kill;
197 use nix::unistd::Pid;
198 matches!(kill(Pid::from_raw(pid), None), Ok(()))
199}
200
201#[cfg(unix)]
206fn can_fallback(err: &std::io::Error) -> bool {
207 matches!(
208 err.kind(),
209 std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::NotFound
210 )
211}
212
213#[cfg(test)]
214#[cfg(unix)]
215mod tests {
216 use super::*;
217 use std::fs;
218 use tempfile::tempdir;
219
220 #[test]
221 fn basename_strips_directory_components() {
222 assert_eq!(basename_of("/dev/ttyUSB0"), "ttyUSB0");
223 assert_eq!(basename_of("ttyS1"), "ttyS1");
224 assert_eq!(basename_of("/a/b/c/d"), "d");
225 }
226
227 #[test]
228 fn acquire_in_creates_lock_file_at_uucp_path() {
229 let dir = tempdir().unwrap();
230 let lock = UucpLock::acquire_in("/dev/ttyUSB0", dir.path()).unwrap();
231 let expected = dir.path().join("LCK..ttyUSB0");
232 assert_eq!(lock.lock_file_path(), &expected);
233 assert!(expected.exists(), "lock file should exist on disk");
234 }
235
236 #[test]
237 fn acquire_in_writes_pid_in_uucp_format() {
238 let dir = tempdir().unwrap();
239 let _lock = UucpLock::acquire_in("/dev/ttyUSB0", dir.path()).unwrap();
240 let content = fs::read_to_string(dir.path().join("LCK..ttyUSB0")).unwrap();
241 assert_eq!(content.len(), 11, "expected 10 PID chars + LF: {content:?}");
243 assert!(content.ends_with('\n'), "trailing LF: {content:?}");
244 let parsed: i32 = content.trim().parse().expect("decimal PID");
245 #[allow(clippy::cast_possible_wrap)]
246 let our_pid = std::process::id() as i32;
247 assert_eq!(parsed, our_pid);
248 }
249
250 #[test]
251 fn drop_removes_lock_file() {
252 let dir = tempdir().unwrap();
253 let path = {
254 let lock = UucpLock::acquire_in("/dev/ttyUSB0", dir.path()).unwrap();
255 lock.lock_file_path().to_path_buf()
256 };
257 assert!(!path.exists(), "lock file should be removed on Drop");
258 }
259
260 #[test]
261 fn second_acquire_for_same_device_reports_already_locked() {
262 let dir = tempdir().unwrap();
263 let _first = UucpLock::acquire_in("/dev/ttyUSB0", dir.path()).unwrap();
264 let err = UucpLock::acquire_in("/dev/ttyUSB0", dir.path()).unwrap_err();
265 match err {
266 crate::Error::AlreadyLocked { device, pid, .. } => {
267 assert_eq!(device, "/dev/ttyUSB0");
268 #[allow(clippy::cast_possible_wrap)]
269 let our_pid = std::process::id() as i32;
270 assert_eq!(pid, our_pid);
271 }
272 other => panic!("expected AlreadyLocked, got {other:?}"),
273 }
274 }
275
276 #[test]
277 fn stale_lock_with_dead_pid_is_overwritten() {
278 let dir = tempdir().unwrap();
279 let lock_path = dir.path().join("LCK..ttyUSB0");
280 fs::write(&lock_path, "1999999999\n").unwrap();
283
284 let lock = UucpLock::acquire_in("/dev/ttyUSB0", dir.path()).unwrap();
285 let content = fs::read_to_string(lock.lock_file_path()).unwrap();
286 let parsed: i32 = content.trim().parse().unwrap();
287 #[allow(clippy::cast_possible_wrap)]
288 let our_pid = std::process::id() as i32;
289 assert_eq!(parsed, our_pid);
290 }
291
292 #[test]
293 fn stale_lock_with_garbage_content_is_overwritten() {
294 let dir = tempdir().unwrap();
295 let lock_path = dir.path().join("LCK..ttyUSB0");
296 fs::write(&lock_path, "not-a-pid\n").unwrap();
297
298 let lock = UucpLock::acquire_in("/dev/ttyUSB0", dir.path()).unwrap();
299 #[allow(clippy::cast_possible_wrap)]
301 let our_pid = std::process::id() as i32;
302 let content = fs::read_to_string(lock.lock_file_path()).unwrap();
303 assert_eq!(content.trim().parse::<i32>().unwrap(), our_pid);
304 }
305
306 #[test]
307 fn unrelated_lock_files_are_left_alone() {
308 let dir = tempdir().unwrap();
309 let other = dir.path().join("LCK..ttyS9");
310 fs::write(&other, "1\n").unwrap();
311 let _lock = UucpLock::acquire_in("/dev/ttyUSB0", dir.path()).unwrap();
312 assert!(other.exists(), "we must not touch unrelated lock files");
313 }
314}