Skip to main content

tldr_cli/commands/daemon/
pid.rs

1//! PID file locking for daemon singleton enforcement
2//!
3//! This module provides cross-platform file locking to ensure only one daemon
4//! instance runs per project. It addresses these security mitigations:
5//!
6//! - TIGER-P1-01: Atomic lock acquisition before PID write (prevents startup race)
7//! - TIGER-P3-02: Acquire lock BEFORE reading existing PID (prevents TOCTOU attacks)
8//!
9//! # Security Pattern
10//!
11//! The lock acquisition follows this secure pattern:
12//! 1. Create/open PID file
13//! 2. Acquire exclusive non-blocking lock FIRST (before any reads)
14//! 3. If lock fails, read PID and check if process is running
15//! 4. If lock succeeds, truncate and write our PID
16//! 5. Return guard that releases lock on drop
17//!
18//! This order is critical - acquiring the lock before reading prevents TOCTOU
19//! (time-of-check to time-of-use) vulnerabilities where an attacker could
20//! manipulate the PID file between our check and lock acquisition.
21
22use std::fs::{File, OpenOptions};
23use std::io::{Read, Seek, SeekFrom, Write};
24use std::path::{Path, PathBuf};
25
26use crate::commands::daemon::error::{DaemonError, DaemonResult};
27
28// =============================================================================
29// Path Computation
30// =============================================================================
31
32/// Compute a deterministic hash for a project path.
33///
34/// Uses MD5 hash of the canonicalized path, truncated to 8 hex characters.
35/// This ensures the same project always gets the same PID/socket files.
36pub fn compute_hash(project: &Path) -> String {
37    // Canonicalize path if possible, otherwise use as-is
38    let project_str = project
39        .canonicalize()
40        .unwrap_or_else(|_| project.to_path_buf())
41        .to_string_lossy()
42        .to_string();
43
44    let digest = md5::compute(project_str.as_bytes());
45
46    // Take first 8 hex characters
47    format!("{:x}", digest)[..8].to_string()
48}
49
50/// Compute the PID file path for a project.
51///
52/// Path format: `{temp_dir}/tldr-{hash}.pid`
53/// where hash = MD5(canonicalized_project_path)[:8]
54pub fn compute_pid_path(project: &Path) -> PathBuf {
55    let hash = compute_hash(project);
56    let tmp_dir = std::env::temp_dir();
57    tmp_dir.join(format!("tldr-{}.pid", hash))
58}
59
60/// Compute the socket path for a project (Unix).
61///
62/// Path format: `{temp_dir}/tldr-{hash}.sock`
63/// Uses same hash as PID file for consistency.
64#[cfg(unix)]
65pub fn compute_socket_path(project: &Path) -> PathBuf {
66    let hash = compute_hash(project);
67    let tmp_dir = std::env::temp_dir();
68    tmp_dir.join(format!("tldr-{}.sock", hash))
69}
70
71/// Compute the TCP port for a project (Windows).
72///
73/// Port range: 49152-59151 (dynamic/private port range)
74/// Uses hash to deterministically map project to port.
75#[cfg(windows)]
76pub fn compute_tcp_port(project: &Path) -> u16 {
77    let hash = compute_hash(project);
78    let hash_int = u64::from_str_radix(&hash, 16).unwrap_or(0);
79    49152 + (hash_int % 10000) as u16
80}
81
82// For cross-platform code that needs socket path on all platforms
83#[cfg(not(unix))]
84pub fn compute_socket_path(project: &Path) -> PathBuf {
85    // On Windows, return a path that won't be used (TCP is used instead)
86    let hash = compute_hash(project);
87    let tmp_dir = std::env::temp_dir();
88    tmp_dir.join(format!("tldr-{}.sock", hash))
89}
90
91// =============================================================================
92// PID Guard (RAII lock holder)
93// =============================================================================
94
95/// Guard that holds the PID file lock and releases it on drop.
96///
97/// The guard ensures:
98/// - Lock is held for the daemon's entire lifetime
99/// - PID file is properly cleaned up on normal shutdown
100/// - Lock is automatically released even on panic
101pub struct PidGuard {
102    /// The locked file handle
103    _file: File,
104    /// Path to the PID file (for cleanup)
105    path: PathBuf,
106    /// Our PID
107    pid: u32,
108}
109
110impl PidGuard {
111    /// Get the PID stored in this guard
112    pub fn pid(&self) -> u32 {
113        self.pid
114    }
115
116    /// Get the path to the PID file
117    pub fn path(&self) -> &Path {
118        &self.path
119    }
120}
121
122impl Drop for PidGuard {
123    fn drop(&mut self) {
124        // Try to remove the PID file on cleanup
125        // Ignore errors - the file might already be gone
126        let _ = std::fs::remove_file(&self.path);
127
128        // Lock is automatically released when file handle is dropped
129    }
130}
131
132// =============================================================================
133// Process Detection
134// =============================================================================
135
136/// Check if a process with the given PID is currently running.
137///
138/// # Platform-specific behavior
139/// - Unix: Uses `kill(pid, 0)` which checks process existence without sending a signal
140/// - Windows: Uses `OpenProcess` with limited query rights
141#[cfg(unix)]
142pub fn is_process_running(pid: u32) -> bool {
143    // Signal 0 checks if process exists without actually sending a signal
144    // Returns 0 on success (process exists), -1 on error
145    unsafe { libc::kill(pid as i32, 0) == 0 }
146}
147
148#[cfg(windows)]
149pub fn is_process_running(pid: u32) -> bool {
150    use windows_sys::Win32::Foundation::CloseHandle;
151    use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
152
153    unsafe {
154        let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
155        if handle == 0 {
156            return false;
157        }
158        CloseHandle(handle);
159        true
160    }
161}
162
163// =============================================================================
164// Lock Acquisition
165// =============================================================================
166
167/// Try to acquire an exclusive lock on the PID file.
168///
169/// # Security Pattern (TIGER-P1-01, TIGER-P3-02)
170///
171/// This function follows a secure lock acquisition pattern:
172/// 1. Create/open file with read+write
173/// 2. Acquire exclusive non-blocking lock FIRST
174/// 3. If lock fails, read existing PID and check process status
175/// 4. If lock succeeds, truncate file and write our PID
176/// 5. Return guard that releases lock on drop
177///
178/// # Errors
179///
180/// - `AlreadyRunning { pid }` - Another daemon is running
181/// - `LockFailed` - Could not acquire lock for other reasons
182/// - `Io` - File system errors
183pub fn try_acquire_lock(pid_path: &Path) -> DaemonResult<PidGuard> {
184    // Ensure parent directory exists
185    if let Some(parent) = pid_path.parent() {
186        std::fs::create_dir_all(parent)?;
187    }
188
189    // Open or create the PID file
190    let file = OpenOptions::new()
191        .read(true)
192        .write(true)
193        .create(true)
194        .truncate(false) // Don't truncate yet - we might fail to lock
195        .open(pid_path)?;
196
197    // Try to acquire exclusive lock FIRST (before reading)
198    // This is critical for security - prevents TOCTOU attacks
199    match try_lock_file(&file) {
200        Ok(()) => {
201            // Lock acquired successfully
202            let our_pid = std::process::id();
203
204            // Now safe to truncate and write our PID
205            let mut file = file;
206            file.set_len(0)?;
207            file.seek(SeekFrom::Start(0))?;
208            writeln!(file, "{}", our_pid)?;
209            file.sync_all()?;
210
211            Ok(PidGuard {
212                _file: file,
213                path: pid_path.to_path_buf(),
214                pid: our_pid,
215            })
216        }
217        Err(_) => {
218            // Lock failed - another process holds it
219            // Read the PID to report in error
220            let existing_pid = read_pid_from_file(&file).unwrap_or(0);
221
222            // Double-check the process is actually running
223            if existing_pid > 0 && is_process_running(existing_pid) {
224                Err(DaemonError::AlreadyRunning { pid: existing_pid })
225            } else {
226                // Stale lock - this shouldn't normally happen since we check the lock
227                // But the process might have just died. Report as stale.
228                Err(DaemonError::StalePidFile { pid: existing_pid })
229            }
230        }
231    }
232}
233
234/// Read PID from an already-open file
235fn read_pid_from_file(file: &File) -> Option<u32> {
236    let mut file = file;
237    let mut content = String::new();
238
239    // Seek to start before reading
240    if file.seek(SeekFrom::Start(0)).is_err() {
241        return None;
242    }
243
244    if file.read_to_string(&mut content).is_err() {
245        return None;
246    }
247
248    content.trim().parse().ok()
249}
250
251// =============================================================================
252// Platform-specific locking
253// =============================================================================
254
255/// Try to acquire an exclusive non-blocking lock on a file.
256#[cfg(unix)]
257fn try_lock_file(file: &File) -> Result<(), std::io::Error> {
258    use std::os::unix::io::AsRawFd;
259
260    let fd = file.as_raw_fd();
261    let result = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
262
263    if result == 0 {
264        Ok(())
265    } else {
266        Err(std::io::Error::last_os_error())
267    }
268}
269
270#[cfg(windows)]
271fn try_lock_file(file: &File) -> Result<(), std::io::Error> {
272    use std::os::windows::io::AsRawHandle;
273    use windows_sys::Win32::Foundation::HANDLE;
274    use windows_sys::Win32::Storage::FileSystem::{
275        LockFileEx, LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY,
276    };
277    use windows_sys::Win32::System::IO::OVERLAPPED;
278
279    let handle = file.as_raw_handle() as HANDLE;
280
281    let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() };
282
283    let result = unsafe {
284        LockFileEx(
285            handle,
286            LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY,
287            0,
288            1, // Lock 1 byte
289            0,
290            &mut overlapped,
291        )
292    };
293
294    if result != 0 {
295        Ok(())
296    } else {
297        Err(std::io::Error::last_os_error())
298    }
299}
300
301// =============================================================================
302// Stale Detection
303// =============================================================================
304
305/// Check if a PID file contains a stale PID (process no longer running).
306///
307/// Returns `true` if the file exists and contains a PID of a non-running process.
308/// Returns `false` if file doesn't exist, is empty, or process is running.
309pub fn check_stale_pid(pid_path: &Path) -> DaemonResult<bool> {
310    // Try to read existing PID file
311    let content = match std::fs::read_to_string(pid_path) {
312        Ok(c) => c,
313        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
314        Err(e) => return Err(DaemonError::Io(e)),
315    };
316
317    // Parse PID
318    let pid: u32 = match content.trim().parse() {
319        Ok(p) => p,
320        Err(_) => return Ok(true), // Unparseable = stale
321    };
322
323    // Check if process is running
324    Ok(!is_process_running(pid))
325}
326
327/// Clean up a stale PID file if it exists.
328///
329/// Only removes the file if it contains a PID of a non-running process.
330/// This is safe to call even if the daemon is running - it will only
331/// remove truly stale files.
332pub fn cleanup_stale_pid(pid_path: &Path) -> DaemonResult<bool> {
333    if check_stale_pid(pid_path)? {
334        std::fs::remove_file(pid_path)?;
335        Ok(true)
336    } else {
337        Ok(false)
338    }
339}
340
341// =============================================================================
342// Tests
343// =============================================================================
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use std::path::PathBuf;
349    use tempfile::TempDir;
350
351    #[test]
352    fn test_compute_hash_deterministic() {
353        let project = PathBuf::from("/test/project");
354        let hash1 = compute_hash(&project);
355        let hash2 = compute_hash(&project);
356        assert_eq!(hash1, hash2);
357        assert_eq!(hash1.len(), 8);
358    }
359
360    #[test]
361    fn test_compute_hash_different_projects() {
362        let project1 = PathBuf::from("/test/project1");
363        let project2 = PathBuf::from("/test/project2");
364        let hash1 = compute_hash(&project1);
365        let hash2 = compute_hash(&project2);
366        assert_ne!(hash1, hash2);
367    }
368
369    #[test]
370    fn test_compute_pid_path_format() {
371        let project = PathBuf::from("/test/project");
372        let pid_path = compute_pid_path(&project);
373
374        let filename = pid_path.file_name().unwrap().to_str().unwrap();
375        assert!(filename.starts_with("tldr-"));
376        assert!(filename.ends_with(".pid"));
377    }
378
379    #[test]
380    fn test_compute_socket_path_format() {
381        let project = PathBuf::from("/test/project");
382        let socket_path = compute_socket_path(&project);
383
384        let filename = socket_path.file_name().unwrap().to_str().unwrap();
385        assert!(filename.starts_with("tldr-"));
386        assert!(filename.ends_with(".sock"));
387    }
388
389    #[test]
390    fn test_pid_and_socket_share_hash() {
391        let project = PathBuf::from("/test/project");
392        let pid_path = compute_pid_path(&project);
393        let socket_path = compute_socket_path(&project);
394
395        // Extract hash from filenames
396        let pid_name = pid_path.file_name().unwrap().to_str().unwrap();
397        let socket_name = socket_path.file_name().unwrap().to_str().unwrap();
398
399        // tldr-XXXXXXXX.pid -> XXXXXXXX
400        let pid_hash = &pid_name[5..13];
401        // tldr-XXXXXXXX.sock -> XXXXXXXX
402        let socket_hash = &socket_name[5..13];
403
404        assert_eq!(pid_hash, socket_hash);
405    }
406
407    #[test]
408    fn test_try_acquire_lock_success() {
409        let temp = TempDir::new().unwrap();
410        let pid_path = temp.path().join("test.pid");
411
412        let guard = try_acquire_lock(&pid_path).unwrap();
413
414        // Verify PID was written
415        let content = std::fs::read_to_string(&pid_path).unwrap();
416        let written_pid: u32 = content.trim().parse().unwrap();
417        assert_eq!(written_pid, std::process::id());
418        assert_eq!(guard.pid(), std::process::id());
419    }
420
421    #[test]
422    fn test_try_acquire_lock_already_locked() {
423        let temp = TempDir::new().unwrap();
424        let pid_path = temp.path().join("test.pid");
425
426        // First lock
427        let _guard1 = try_acquire_lock(&pid_path).unwrap();
428
429        // Second lock attempt should fail
430        let result = try_acquire_lock(&pid_path);
431        assert!(result.is_err());
432        match result {
433            Err(DaemonError::AlreadyRunning { pid }) => {
434                assert_eq!(pid, std::process::id());
435            }
436            _ => panic!("Expected AlreadyRunning error"),
437        }
438    }
439
440    #[test]
441    fn test_guard_cleanup_on_drop() {
442        let temp = TempDir::new().unwrap();
443        let pid_path = temp.path().join("test.pid");
444
445        {
446            let _guard = try_acquire_lock(&pid_path).unwrap();
447            assert!(pid_path.exists());
448        }
449
450        // After guard is dropped, PID file should be removed
451        assert!(!pid_path.exists());
452    }
453
454    #[test]
455    fn test_is_process_running_self() {
456        let our_pid = std::process::id();
457        assert!(is_process_running(our_pid));
458    }
459
460    #[test]
461    fn test_is_process_running_nonexistent() {
462        // Use a very high PID that's unlikely to exist
463        // PID 4194304 is above typical kernel max
464        assert!(!is_process_running(4194304));
465    }
466
467    #[test]
468    fn test_check_stale_pid_nonexistent_file() {
469        let temp = TempDir::new().unwrap();
470        let pid_path = temp.path().join("nonexistent.pid");
471
472        let result = check_stale_pid(&pid_path).unwrap();
473        assert!(!result); // File doesn't exist = not stale
474    }
475
476    #[test]
477    fn test_check_stale_pid_running_process() {
478        let temp = TempDir::new().unwrap();
479        let pid_path = temp.path().join("test.pid");
480
481        // Write our own PID (definitely running)
482        std::fs::write(&pid_path, format!("{}", std::process::id())).unwrap();
483
484        let result = check_stale_pid(&pid_path).unwrap();
485        assert!(!result); // Our process is running = not stale
486    }
487
488    #[test]
489    fn test_check_stale_pid_dead_process() {
490        let temp = TempDir::new().unwrap();
491        let pid_path = temp.path().join("test.pid");
492
493        // Write a PID that doesn't exist
494        std::fs::write(&pid_path, "4194304").unwrap();
495
496        let result = check_stale_pid(&pid_path).unwrap();
497        assert!(result); // Process not running = stale
498    }
499
500    #[test]
501    fn test_cleanup_stale_pid() {
502        let temp = TempDir::new().unwrap();
503        let pid_path = temp.path().join("test.pid");
504
505        // Write a stale PID
506        std::fs::write(&pid_path, "4194304").unwrap();
507        assert!(pid_path.exists());
508
509        let cleaned = cleanup_stale_pid(&pid_path).unwrap();
510        assert!(cleaned);
511        assert!(!pid_path.exists());
512    }
513
514    #[test]
515    fn test_cleanup_stale_pid_not_stale() {
516        let temp = TempDir::new().unwrap();
517        let pid_path = temp.path().join("test.pid");
518
519        // Write our own PID (not stale)
520        std::fs::write(&pid_path, format!("{}", std::process::id())).unwrap();
521
522        let cleaned = cleanup_stale_pid(&pid_path).unwrap();
523        assert!(!cleaned);
524        assert!(pid_path.exists());
525    }
526
527    #[cfg(windows)]
528    #[test]
529    fn test_compute_tcp_port_range() {
530        let project = PathBuf::from("/test/project");
531        let port = compute_tcp_port(&project);
532        assert!(port >= 49152);
533        assert!(port < 59152);
534    }
535
536    #[cfg(windows)]
537    #[test]
538    fn test_compute_tcp_port_deterministic() {
539        let project = PathBuf::from("/test/project");
540        let port1 = compute_tcp_port(&project);
541        let port2 = compute_tcp_port(&project);
542        assert_eq!(port1, port2);
543    }
544}