open_sesame/util/
lock.rs

1//! Single instance lock
2//!
3//! Ensures only one instance of open-sesame runs at a time.
4//! IPC is now handled by the ipc module using Unix domain sockets.
5
6use crate::util::paths;
7use crate::util::{Error, Result};
8use std::fs::{File, OpenOptions};
9use std::io::Write;
10use std::os::unix::fs::OpenOptionsExt;
11use std::path::PathBuf;
12
13/// Lock file for single instance enforcement
14pub struct InstanceLock {
15    _file: File,
16    path: PathBuf,
17}
18
19impl InstanceLock {
20    /// Attempts to acquire the instance lock.
21    ///
22    /// Returns Ok(lock) if successful, Err if another instance is running.
23    pub fn acquire() -> Result<Self> {
24        let path = Self::lock_path();
25
26        // Parent directory creation ensured
27        if let Some(parent) = path.parent() {
28            std::fs::create_dir_all(parent).ok();
29        }
30
31        // File opened without truncate to prevent PID wipe race condition
32        // Truncation occurs only after lock acquisition
33        let file = OpenOptions::new()
34            .read(true)
35            .write(true)
36            .create(true)
37            .truncate(false)
38            .mode(0o600)
39            .open(&path)
40            .map_err(|e| Error::Other(format!("Failed to open lock file: {}", e)))?;
41
42        // Exclusive lock acquisition attempted (non-blocking)
43        use std::os::unix::io::AsRawFd;
44        let fd = file.as_raw_fd();
45        let result = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
46
47        if result != 0 {
48            // Lock failed indicates another instance is running
49            return Err(Error::Other(
50                "Another instance is already running".to_string(),
51            ));
52        }
53
54        // Lock acquired successfully, truncate and write PID
55        let mut file = file;
56        file.set_len(0)
57            .map_err(|e| Error::Other(format!("Failed to truncate lock file: {}", e)))?;
58        use std::io::Seek;
59        file.seek(std::io::SeekFrom::Start(0))
60            .map_err(|e| Error::Other(format!("Failed to seek lock file: {}", e)))?;
61        writeln!(file, "{}", std::process::id())
62            .map_err(|e| Error::Other(format!("Failed to write PID: {}", e)))?;
63        file.flush()
64            .map_err(|e| Error::Other(format!("Failed to flush PID: {}", e)))?;
65
66        tracing::debug!(
67            "Lock acquired, PID {} written to {}",
68            std::process::id(),
69            path.display()
70        );
71
72        Ok(Self { _file: file, path })
73    }
74
75    /// Get the lock file path
76    ///
77    /// Uses ~/.cache/open-sesame/instance.lock with secure permissions.
78    /// Falls back to UID-based naming only if cache dir cannot be determined.
79    fn lock_path() -> PathBuf {
80        // Secure cache directory used
81        match paths::lock_file() {
82            Ok(path) => path,
83            Err(e) => {
84                // Rare occurrence - only when HOME is completely unset
85                // UID-based fallback provides minimal safety
86                tracing::error!(
87                    "Failed to get secure lock path: {}. Using UID-based fallback.",
88                    e
89                );
90                let uid = unsafe { libc::getuid() };
91                PathBuf::from(format!("/run/user/{}/open-sesame.lock", uid))
92            }
93        }
94    }
95}
96
97impl Drop for InstanceLock {
98    fn drop(&mut self) {
99        // Lock automatically released when file is closed
100        // Lock file optionally removed
101        std::fs::remove_file(&self.path).ok();
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn test_lock_path_returns_valid_path() {
111        let path = InstanceLock::lock_path();
112        // Path contains "open-sesame"
113        assert!(
114            path.to_string_lossy().contains("open-sesame"),
115            "Lock path should contain 'open-sesame': {:?}",
116            path
117        );
118        // Filename ends with .lock or instance.lock
119        let filename = path
120            .file_name()
121            .expect("lock path should have a filename component")
122            .to_string_lossy();
123        assert!(
124            filename.contains("lock"),
125            "Lock file should have 'lock' in name: {:?}",
126            path
127        );
128    }
129
130    #[test]
131    fn test_instance_lock_acquire_and_release() {
132        // Test creates and releases lock
133        // Unique path used to avoid conflicts with running instances
134
135        // Lock acquisition
136        let lock = InstanceLock::acquire();
137
138        // Acquisition failure acceptable for testing (indicates running instance)
139        if let Ok(_lock) = lock {
140            // Lock held
141            // Second acquisition attempt should fail
142            let lock2 = InstanceLock::acquire();
143            assert!(lock2.is_err(), "Double lock acquisition prevented");
144
145            // Lock released when _lock goes out of scope
146        }
147        // lock.is_err() indicates running instance (acceptable for test)
148    }
149}