1use std::fs::{File, OpenOptions, remove_file};
6use std::io::{Write, Read, Seek, SeekFrom};
7use std::path::{Path, PathBuf};
8use std::process;
9
10use fs2::FileExt;
11use serde::{Serialize, Deserialize};
12use thiserror::Error;
13use time::OffsetDateTime;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct LockInfo {
18 pub program: String,
20 pub pid: u32,
22 pub username: String,
24 pub started_at: String,
26}
27
28#[derive(Debug, Error)]
30pub enum LockError {
31 #[error("Directory is already locked by {program} (PID {pid}, user {username}, started at {started_at})")]
33 AlreadyLocked {
34 program: String,
35 pid: u32,
36 username: String,
37 started_at: String,
38 },
39 #[error("IO error: {0}")]
41 Io(#[from] std::io::Error),
42 #[error("JSON error: {0}")]
44 Json(#[from] serde_json::Error),
45 #[error("Failed to create .robit directory: {0}")]
47 CreateDir(std::io::Error),
48}
49
50pub struct DirectoryLock {
52 lock_path: PathBuf,
54 file: Option<File>,
56 info: LockInfo,
58}
59
60impl DirectoryLock {
61 pub fn acquire(workdir: &Path, program_name: &str) -> Result<Self, LockError> {
67 let robit_dir = workdir.join(".robit");
68 let lock_path = robit_dir.join("LOCK");
69
70 if !robit_dir.exists() {
72 std::fs::create_dir_all(&robit_dir)
73 .map_err(LockError::CreateDir)?;
74 }
75
76 let username = get_username().unwrap_or_else(|| "unknown".to_string());
78
79 let info = LockInfo {
81 program: program_name.to_string(),
82 pid: process::id(),
83 username,
84 started_at: OffsetDateTime::now_utc()
85 .format(&time::format_description::well_known::Rfc3339)
86 .unwrap_or_else(|_| "unknown".to_string()),
87 };
88
89 match Self::try_acquire(&lock_path, &info) {
91 Ok(lock) => Ok(lock),
92 Err(LockError::AlreadyLocked { program, pid, username, started_at }) => {
93 if !is_process_running(pid) {
95 tracing::warn!(
97 "Found stale lock file from {} (PID {}, user {}, started at {}), cleaning up",
98 program,
99 pid,
100 username,
101 started_at
102 );
103 let _ = remove_file(&lock_path);
104 Self::try_acquire(&lock_path, &info)
105 } else {
106 Err(LockError::AlreadyLocked { program, pid, username, started_at })
107 }
108 }
109 Err(e) => Err(e),
110 }
111 }
112
113 fn try_acquire(lock_path: &Path, info: &LockInfo) -> Result<Self, LockError> {
115 let mut file = OpenOptions::new()
117 .read(true)
118 .write(true)
119 .create(true)
120 .open(lock_path)?;
121
122 match file.try_lock_exclusive() {
124 Ok(_) => {
125 file.set_len(0)?;
127 file.seek(SeekFrom::Start(0))?;
128 serde_json::to_writer_pretty(&mut file, info)?;
129 file.flush()?;
130 file.sync_data()?;
131
132 Ok(Self {
133 lock_path: lock_path.to_path_buf(),
134 file: Some(file),
135 info: info.clone(),
136 })
137 }
138 Err(_) => {
139 let mut content = String::new();
141 file.seek(SeekFrom::Start(0))?;
142 file.read_to_string(&mut content)?;
143
144 let existing_info: LockInfo = match serde_json::from_str(&content) {
145 Ok(info) => info,
146 Err(_) => {
147 LockInfo {
149 program: "unknown".to_string(),
150 pid: 0,
151 username: "unknown".to_string(),
152 started_at: "unknown".to_string(),
153 }
154 }
155 };
156
157 Err(LockError::AlreadyLocked {
158 program: existing_info.program,
159 pid: existing_info.pid,
160 username: existing_info.username,
161 started_at: existing_info.started_at,
162 })
163 }
164 }
165 }
166
167 pub fn info(&self) -> &LockInfo {
169 &self.info
170 }
171
172 pub fn release(&mut self) {
174 if let Some(file) = self.file.take() {
175 let _ = file.unlock();
176 }
177 }
178}
179
180impl Drop for DirectoryLock {
181 fn drop(&mut self) {
182 if let Some(file) = self.file.take() {
183 let _ = file.unlock();
184 }
185 let _ = std::fs::remove_file(&self.lock_path);
187 }
188}
189
190fn is_process_running(pid: u32) -> bool {
192 #[cfg(unix)]
193 {
194 use std::ffi::CString;
195 use libc::{kill, c_int, EPERM, ESRCH};
196
197 unsafe {
198 let result = kill(pid as c_int, 0);
199 if result == 0 {
200 return true;
201 }
202 let errno = *libc::__errno_location();
203 errno == EPERM }
205 }
206
207 #[cfg(windows)]
208 {
209 use windows_sys::Win32::Foundation::{CloseHandle, ERROR_INVALID_PARAMETER, GetLastError};
210 use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
211
212 unsafe {
213 let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
214 if handle != 0 {
215 CloseHandle(handle);
216 return true;
217 }
218 GetLastError() != ERROR_INVALID_PARAMETER
219 }
220 }
221
222 #[cfg(not(any(unix, windows)))]
223 {
224 true
226 }
227}
228
229fn get_username() -> Option<String> {
231 if let Ok(name) = std::env::var("USER") {
233 if !name.is_empty() {
234 return Some(name);
235 }
236 }
237 if let Ok(name) = std::env::var("USERNAME") {
238 if !name.is_empty() {
239 return Some(name);
240 }
241 }
242
243 #[cfg(unix)]
245 {
246 use std::ffi::CStr;
247 use libc::{getpwuid, getuid};
248
249 unsafe {
250 let uid = getuid();
251 let passwd = getpwuid(uid);
252 if !passwd.is_null() {
253 let name = CStr::from_ptr((*passwd).pw_name);
254 return name.to_str().ok().map(|s| s.to_string());
255 }
256 }
257 }
258
259 None
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use tempfile::tempdir;
266
267 #[test]
268 fn test_lock_acquire_and_release() {
269 let dir = tempdir().unwrap();
270 let lock = DirectoryLock::acquire(dir.path(), "test-program").unwrap();
271 assert_eq!(lock.info().program, "test-program");
272 assert!(lock.info().pid > 0);
273 }
274
275 #[test]
276 fn test_username_is_set() {
277 let dir = tempdir().unwrap();
278 let lock = DirectoryLock::acquire(dir.path(), "test-program").unwrap();
279 assert!(!lock.info().username.is_empty());
280 }
281
282 #[test]
283 fn test_started_at_is_set() {
284 let dir = tempdir().unwrap();
285 let lock = DirectoryLock::acquire(dir.path(), "test-program").unwrap();
286 assert!(!lock.info().started_at.is_empty());
287 }
288
289 #[test]
290 fn test_is_process_running_with_our_pid() {
291 assert!(is_process_running(std::process::id()));
293 }
294
295 #[test]
296 fn test_is_process_running_with_invalid_pid() {
297 assert!(!is_process_running(u32::MAX));
300 }
301}