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    #[must_use]
155    pub fn lock_file_path(&self) -> &Path {
156        &self.path
157    }
158}
159
160impl Drop for UucpLock {
161    fn drop(&mut self) {
162        if self.active {
163            let _ = std::fs::remove_file(&self.path);
164        }
165    }
166}
167
168/// Returns the basename of `path` (the part after the last `/`), or the
169/// whole string if no separator is present.
170#[cfg(unix)]
171fn basename_of(path: &str) -> &str {
172    path.rsplit('/').next().unwrap_or(path)
173}
174
175/// Reads a UUCP lock file and parses its content as a PID.
176#[cfg(unix)]
177fn read_pid(lock_path: &Path) -> Result<i32> {
178    let content = std::fs::read_to_string(lock_path)?;
179    content
180        .trim()
181        .parse::<i32>()
182        .map_err(|err| crate::Error::InvalidLock(format!("{err} in {content:?}")))
183}
184
185/// `kill(pid, 0)` returns `Ok` for a live process, `ESRCH` for a dead
186/// one, `EPERM` for "exists but not ours". Treat the latter two as
187/// "alive enough to refuse the lock" only when the OS says alive.
188#[cfg(unix)]
189fn pid_is_alive(pid: i32) -> bool {
190    use nix::sys::signal::kill;
191    use nix::unistd::Pid;
192    matches!(kill(Pid::from_raw(pid), None), Ok(()))
193}
194
195/// Categorises filesystem errors that justify falling back from
196/// `/var/lock` to `/tmp`. Permission denied is the common case (no
197/// `lock` group); not-found means the directory does not exist on this
198/// system at all (some minimal containers).
199#[cfg(unix)]
200fn can_fallback(err: &std::io::Error) -> bool {
201    matches!(
202        err.kind(),
203        std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::NotFound
204    )
205}
206
207#[cfg(test)]
208#[cfg(unix)]
209mod tests {
210    use super::*;
211    use std::fs;
212    use tempfile::tempdir;
213
214    #[test]
215    fn basename_strips_directory_components() {
216        assert_eq!(basename_of("/dev/ttyUSB0"), "ttyUSB0");
217        assert_eq!(basename_of("ttyS1"), "ttyS1");
218        assert_eq!(basename_of("/a/b/c/d"), "d");
219    }
220
221    #[test]
222    fn acquire_in_creates_lock_file_at_uucp_path() {
223        let dir = tempdir().unwrap();
224        let lock = UucpLock::acquire_in("/dev/ttyUSB0", dir.path()).unwrap();
225        let expected = dir.path().join("LCK..ttyUSB0");
226        assert_eq!(lock.lock_file_path(), &expected);
227        assert!(expected.exists(), "lock file should exist on disk");
228    }
229
230    #[test]
231    fn acquire_in_writes_pid_in_uucp_format() {
232        let dir = tempdir().unwrap();
233        let _lock = UucpLock::acquire_in("/dev/ttyUSB0", dir.path()).unwrap();
234        let content = fs::read_to_string(dir.path().join("LCK..ttyUSB0")).unwrap();
235        // Format is "%10d\n": 10 chars right-aligned, trailing newline.
236        assert_eq!(content.len(), 11, "expected 10 PID chars + LF: {content:?}");
237        assert!(content.ends_with('\n'), "trailing LF: {content:?}");
238        let parsed: i32 = content.trim().parse().expect("decimal PID");
239        #[allow(clippy::cast_possible_wrap)]
240        let our_pid = std::process::id() as i32;
241        assert_eq!(parsed, our_pid);
242    }
243
244    #[test]
245    fn drop_removes_lock_file() {
246        let dir = tempdir().unwrap();
247        let path = {
248            let lock = UucpLock::acquire_in("/dev/ttyUSB0", dir.path()).unwrap();
249            lock.lock_file_path().to_path_buf()
250        };
251        assert!(!path.exists(), "lock file should be removed on Drop");
252    }
253
254    #[test]
255    fn second_acquire_for_same_device_reports_already_locked() {
256        let dir = tempdir().unwrap();
257        let _first = UucpLock::acquire_in("/dev/ttyUSB0", dir.path()).unwrap();
258        let err = UucpLock::acquire_in("/dev/ttyUSB0", dir.path()).unwrap_err();
259        match err {
260            crate::Error::AlreadyLocked { device, pid, .. } => {
261                assert_eq!(device, "/dev/ttyUSB0");
262                #[allow(clippy::cast_possible_wrap)]
263                let our_pid = std::process::id() as i32;
264                assert_eq!(pid, our_pid);
265            }
266            other => panic!("expected AlreadyLocked, got {other:?}"),
267        }
268    }
269
270    #[test]
271    fn stale_lock_with_dead_pid_is_overwritten() {
272        let dir = tempdir().unwrap();
273        let lock_path = dir.path().join("LCK..ttyUSB0");
274        // PID well above any realistic kernel.pid_max; kill(pid, 0)
275        // will return ESRCH.
276        fs::write(&lock_path, "1999999999\n").unwrap();
277
278        let lock = UucpLock::acquire_in("/dev/ttyUSB0", dir.path()).unwrap();
279        let content = fs::read_to_string(lock.lock_file_path()).unwrap();
280        let parsed: i32 = content.trim().parse().unwrap();
281        #[allow(clippy::cast_possible_wrap)]
282        let our_pid = std::process::id() as i32;
283        assert_eq!(parsed, our_pid);
284    }
285
286    #[test]
287    fn stale_lock_with_garbage_content_is_overwritten() {
288        let dir = tempdir().unwrap();
289        let lock_path = dir.path().join("LCK..ttyUSB0");
290        fs::write(&lock_path, "not-a-pid\n").unwrap();
291
292        let lock = UucpLock::acquire_in("/dev/ttyUSB0", dir.path()).unwrap();
293        // We should now own the file with our own PID.
294        #[allow(clippy::cast_possible_wrap)]
295        let our_pid = std::process::id() as i32;
296        let content = fs::read_to_string(lock.lock_file_path()).unwrap();
297        assert_eq!(content.trim().parse::<i32>().unwrap(), our_pid);
298    }
299
300    #[test]
301    fn unrelated_lock_files_are_left_alone() {
302        let dir = tempdir().unwrap();
303        let other = dir.path().join("LCK..ttyS9");
304        fs::write(&other, "1\n").unwrap();
305        let _lock = UucpLock::acquire_in("/dev/ttyUSB0", dir.path()).unwrap();
306        assert!(other.exists(), "we must not touch unrelated lock files");
307    }
308}