Skip to main content

auths_cli/commands/agent/
process.rs

1//! Standalone process lifecycle functions for the SSH agent daemon.
2//!
3//! Each function handles a single concern (PID files, signal checks, process
4//! spawning, termination) and is independently testable without a running
5//! Unix socket.
6
7#[cfg(not(unix))]
8use anyhow::anyhow;
9use anyhow::{Context, Result};
10use std::fs;
11use std::path::Path;
12use std::time::Duration;
13
14#[cfg(unix)]
15use nix::sys::signal::{self, Signal};
16#[cfg(unix)]
17use nix::unistd::Pid;
18
19/// Write a process ID to a PID file with restricted permissions.
20///
21/// Args:
22/// * `path`: Filesystem path for the PID file.
23/// * `pid`: Process ID to write.
24///
25/// Usage:
26/// ```ignore
27/// write_pid_file(&pid_path, std::process::id())?;
28/// ```
29pub fn write_pid_file(path: &Path, pid: u32) -> Result<()> {
30    crate::core::fs::write_sensitive_file(path, pid.to_string())
31        .with_context(|| format!("Failed to write PID file: {:?}", path))
32}
33
34/// Read a process ID from a PID file, returning `None` if the file does not exist.
35///
36/// Args:
37/// * `path`: Filesystem path of the PID file.
38///
39/// Usage:
40/// ```ignore
41/// if let Some(pid) = read_pid_file(&pid_path)? {
42///     println!("Agent running as PID {}", pid);
43/// }
44/// ```
45pub fn read_pid_file(path: &Path) -> Result<Option<u32>> {
46    if !path.exists() {
47        return Ok(None);
48    }
49
50    let content =
51        fs::read_to_string(path).with_context(|| format!("Failed to read PID file: {:?}", path))?;
52
53    let pid: u32 = content
54        .trim()
55        .parse()
56        .with_context(|| format!("Invalid PID in file: {}", content.trim()))?;
57
58    Ok(Some(pid))
59}
60
61/// Check whether a process with the given PID is running.
62///
63/// On Unix, sends signal 0 (a no-op signal that checks process existence).
64/// On non-Unix platforms, always returns `false`.
65///
66/// Args:
67/// * `pid`: Process ID to check.
68///
69/// Usage:
70/// ```ignore
71/// if is_process_running(pid) {
72///     println!("Process {} is alive", pid);
73/// }
74/// ```
75#[cfg(unix)]
76pub fn is_process_running(pid: u32) -> bool {
77    signal::kill(Pid::from_raw(pid as i32), None).is_ok()
78}
79
80#[cfg(not(unix))]
81pub fn is_process_running(_pid: u32) -> bool {
82    false
83}
84
85/// Test whether a Unix domain socket at `path` accepts connections.
86///
87/// Preferred over `path.exists()` because it avoids TOCTOU race conditions:
88/// a socket file can exist but be stale (no listener).
89///
90/// Args:
91/// * `path`: Filesystem path of the Unix domain socket.
92///
93/// Usage:
94/// ```ignore
95/// if socket_is_connectable(&socket_path) {
96///     println!("Agent socket is live");
97/// }
98/// ```
99#[cfg(unix)]
100pub fn socket_is_connectable(path: &Path) -> bool {
101    std::os::unix::net::UnixStream::connect(path).is_ok()
102}
103
104#[cfg(not(unix))]
105pub fn socket_is_connectable(_path: &Path) -> bool {
106    false
107}
108
109/// Spawn a detached daemon process by re-executing the current binary.
110///
111/// Creates a new session via `setsid()` so the child survives parent exit.
112/// Stdout and stderr are redirected to `log_path`.
113///
114/// Args:
115/// * `args`: Command-line arguments for the spawned process.
116/// * `log_path`: Path for stdout/stderr redirection.
117///
118/// Usage:
119/// ```ignore
120/// let pid = spawn_detached(
121///     &["agent", "start", "--foreground", "--socket", socket_str],
122///     &log_path,
123/// )?;
124/// ```
125#[cfg(unix)]
126pub fn spawn_detached(args: &[&str], log_path: &Path) -> Result<u32> {
127    use std::os::unix::process::CommandExt;
128    use std::process::Command;
129
130    let exe = std::env::current_exe().context("Failed to get current executable path")?;
131
132    let log_file = fs::File::create(log_path)
133        .with_context(|| format!("Failed to create log file: {:?}", log_path))?;
134    let log_file_err = log_file
135        .try_clone()
136        .context("Failed to clone log file handle")?;
137
138    let mut cmd = Command::new(&exe);
139    for arg in args {
140        cmd.arg(arg);
141    }
142    cmd.stdout(log_file).stderr(log_file_err);
143
144    // SAFETY: setsid() is async-signal-safe and called between fork and exec.
145    unsafe {
146        cmd.pre_exec(|| {
147            nix::unistd::setsid().map_err(std::io::Error::other)?;
148            Ok(())
149        });
150    }
151
152    let child = cmd.spawn().context("Failed to spawn daemon process")?;
153    Ok(child.id())
154}
155
156#[cfg(not(unix))]
157pub fn spawn_detached(_args: &[&str], _log_path: &Path) -> Result<u32> {
158    Err(anyhow!(
159        "Daemonization not supported on this platform. Use --foreground."
160    ))
161}
162
163/// Send SIGTERM to a process and wait for it to exit, escalating to SIGKILL
164/// if it does not terminate within `timeout`.
165///
166/// Args:
167/// * `pid`: Process ID to terminate.
168/// * `timeout`: Maximum time to wait for graceful shutdown before SIGKILL.
169///
170/// Usage:
171/// ```ignore
172/// terminate_process(pid, Duration::from_secs(5))?;
173/// ```
174#[cfg(unix)]
175pub fn terminate_process(pid: u32, timeout: Duration) -> Result<()> {
176    signal::kill(Pid::from_raw(pid as i32), Signal::SIGTERM)
177        .with_context(|| format!("Failed to send SIGTERM to PID {}", pid))?;
178
179    let start = std::time::Instant::now();
180    while start.elapsed() < timeout {
181        if !is_process_running(pid) {
182            return Ok(());
183        }
184        std::thread::sleep(Duration::from_millis(100));
185    }
186
187    if is_process_running(pid) {
188        eprintln!("Process did not terminate gracefully, sending SIGKILL...");
189        let _ = signal::kill(Pid::from_raw(pid as i32), Signal::SIGKILL);
190    }
191
192    Ok(())
193}
194
195#[cfg(not(unix))]
196pub fn terminate_process(_pid: u32, _timeout: Duration) -> Result<()> {
197    Err(anyhow!("Stopping agent not supported on this platform"))
198}
199
200/// Remove stale daemon files (PID file, socket, environment file).
201///
202/// Args:
203/// * `paths`: Slice of file paths to clean up. Missing files are silently ignored.
204///
205/// Usage:
206/// ```ignore
207/// cleanup_stale_files(&[&pid_path, &socket_path, &env_path]);
208/// ```
209pub fn cleanup_stale_files(paths: &[&Path]) {
210    for path in paths {
211        let _ = fs::remove_file(path);
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use tempfile::TempDir;
219
220    #[test]
221    fn write_and_read_pid_file_round_trip() {
222        let dir = TempDir::new().unwrap();
223        let pid_path = dir.path().join("test.pid");
224
225        write_pid_file(&pid_path, 42).unwrap();
226        let read_back = read_pid_file(&pid_path).unwrap();
227        assert_eq!(read_back, Some(42));
228    }
229
230    #[test]
231    fn read_pid_file_missing_returns_none() {
232        let dir = TempDir::new().unwrap();
233        let pid_path = dir.path().join("nonexistent.pid");
234        assert_eq!(read_pid_file(&pid_path).unwrap(), None);
235    }
236
237    #[test]
238    fn read_pid_file_invalid_content_errors() {
239        let dir = TempDir::new().unwrap();
240        let pid_path = dir.path().join("bad.pid");
241        fs::write(&pid_path, "not-a-number").unwrap();
242        assert!(read_pid_file(&pid_path).is_err());
243    }
244
245    #[test]
246    fn cleanup_stale_files_removes_existing() {
247        let dir = TempDir::new().unwrap();
248        let f1 = dir.path().join("a");
249        let f2 = dir.path().join("b");
250        fs::write(&f1, "x").unwrap();
251        fs::write(&f2, "y").unwrap();
252
253        cleanup_stale_files(&[&f1, &f2]);
254
255        assert!(!f1.exists());
256        assert!(!f2.exists());
257    }
258
259    #[test]
260    fn cleanup_stale_files_ignores_missing() {
261        let dir = TempDir::new().unwrap();
262        let missing = dir.path().join("gone");
263        cleanup_stale_files(&[&missing]);
264    }
265
266    #[cfg(unix)]
267    #[test]
268    fn is_process_running_detects_current_process() {
269        assert!(is_process_running(std::process::id()));
270    }
271
272    #[cfg(unix)]
273    #[test]
274    fn is_process_running_false_for_nonexistent() {
275        assert!(!is_process_running(999_999_999));
276    }
277}