Skip to main content

rtcom_core/
lock.rs

1//! UUCP-style lock files.
2//!
3//! Multiple programs sharing a serial port is a recipe for corrupt data
4//! and confused users. The UUCP convention — adopted by `uucp`,
5//! `picocom`, `tio`, `minicom`, and most modems-with-fortunes utilities
6//! since the 1980s — solves this with a per-device flag file:
7//!
8//! - **Path**: `/var/lock/LCK..<basename>` for `<basename>` derived from
9//!   the device path (`/dev/ttyUSB0` → `ttyUSB0`).
10//! - **Content**: the owning process's PID, formatted as 10 ASCII
11//!   characters right-aligned with leading spaces, followed by `\n`.
12//! - **Stale-lock recovery**: a process opening the device reads the
13//!   PID, sends signal 0 with `kill(pid, 0)`. `Ok` means the holder is
14//!   alive, refuse to open. `ESRCH` means the holder is gone, remove
15//!   the file and proceed.
16//!
17//! `/var/lock` is typically `root:lock 1775` on modern distros, so an
18//! unprivileged user without group `lock` cannot create files there.
19//! [`UucpLock::acquire`] falls back to `/tmp` in that case (with a
20//! `tracing::warn`) — the lock is then per-user instead of system-wide,
21//! but still protects the same user from racing themselves.
22//!
23//! On Windows, `UucpLock` is a no-op shim — the OS already serialises
24//! `CreateFile` on a COM port via `SHARE_MODE = 0`. v0.1 leaves the
25//! Windows path empty so the call site stays cross-platform.
26//!
27use std::path::{Path, PathBuf};
28
29use crate::Result;
30
31/// Default system-wide UUCP lock directory.
32#[cfg(unix)]
33const PRIMARY_LOCK_DIR: &str = "/var/lock";
34
35/// Per-user fallback directory used when [`PRIMARY_LOCK_DIR`] is
36/// unwritable.
37#[cfg(unix)]
38const FALLBACK_LOCK_DIR: &str = "/tmp";
39
40/// RAII handle for a UUCP lock file. Drops it when this value goes out
41/// of scope.
42#[derive(Debug)]
43pub struct UucpLock {
44    path: PathBuf,
45    /// `false` on Windows / non-Unix targets where the lock is a no-op.
46    /// Drop must skip `remove_file` for these.
47    active: bool,
48}
49
50impl UucpLock {
51    /// Acquires a lock for `device_path`, trying `/var/lock` first and
52    /// falling back to `/tmp` on permission / not-found errors. On
53    /// non-Unix targets this is a no-op.
54    ///
55    /// # Errors
56    ///
57    /// - [`Error::AlreadyLocked`](crate::Error::AlreadyLocked) if a
58    ///   live process already owns the device.
59    /// - [`Error::Io`](crate::Error::Io) for filesystem failures the
60    ///   fallback cannot recover from.
61    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    /// Acquires a lock for `device_path` in the explicit `lock_dir`.
89    /// Tests use this entry to point the lock at a temporary directory
90    /// instead of the system-wide path. On non-Unix targets this is a
91    /// no-op.
92    ///
93    /// # Errors
94    ///
95    /// Same as [`UucpLock::acquire`] but without the `/tmp` fallback.
96    #[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            // Pre-existing lock: probe liveness, take over if stale.
114            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                    // Stale (dead PID) or unreadable (garbage / parse error):
124                    // remove and continue. We deliberately swallow the
125                    // remove error — if it fails, OpenOptions::create_new
126                    // below will fail with EEXIST and we surface that.
127                    _ => {
128                        let _ = fs::remove_file(&lock_path);
129                    }
130                }
131            }
132
133            // O_CREAT | O_EXCL — atomic against a racing process that
134            // landed between our exists() check and this open call.
135            let mut f = OpenOptions::new()
136                .write(true)
137                .create_new(true)
138                .open(&lock_path)?;
139
140            // UUCP convention: 10-character right-aligned ASCII PID + LF.
141            #[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    /// Returns the lock file path this guard owns.
154    //
155    // Not `const fn` because `&self.path` (`PathBuf` → `&Path`) goes
156    // through deref coercion, which is not yet allowed in const
157    // context (Rust 1.86). The clippy `missing_const_for_fn` lint
158    // suggestion is wrong here — the body fails to compile as const.
159    #[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/// Returns the basename of `path` (the part after the last `/`), or the
175/// whole string if no separator is present.
176#[cfg(unix)]
177fn basename_of(path: &str) -> &str {
178    path.rsplit('/').next().unwrap_or(path)
179}
180
181/// Reads a UUCP lock file and parses its content as a PID.
182#[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/// `kill(pid, 0)` returns `Ok` for a live process, `ESRCH` for a dead
192/// one, `EPERM` for "exists but not ours". Treat the latter two as
193/// "alive enough to refuse the lock" only when the OS says alive.
194#[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/// Categorises filesystem errors that justify falling back from
202/// `/var/lock` to `/tmp`. Permission denied is the common case (no
203/// `lock` group); not-found means the directory does not exist on this
204/// system at all (some minimal containers).
205#[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        // Format is "%10d\n": 10 chars right-aligned, trailing newline.
242        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        // PID well above any realistic kernel.pid_max; kill(pid, 0)
281        // will return ESRCH.
282        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        // We should now own the file with our own PID.
300        #[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}