Skip to main content

semantic_diff/
signal.rs

1use std::fs;
2use std::io;
3use std::path::PathBuf;
4use std::process;
5
6#[cfg(unix)]
7use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
8
9/// Return the secure directory for PID and log files.
10///
11/// Uses `$XDG_RUNTIME_DIR/semantic-diff/` if set (typically `/run/user/<uid>/`),
12/// otherwise falls back to `$HOME/.local/state/semantic-diff/`.
13fn pid_dir() -> PathBuf {
14    let base = if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
15        PathBuf::from(xdg)
16    } else {
17        let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
18        PathBuf::from(home).join(".local").join("state")
19    };
20    base.join("semantic-diff")
21}
22
23/// Ensure the PID directory exists with restricted permissions (0o700).
24fn ensure_pid_dir() -> io::Result<PathBuf> {
25    let dir = pid_dir();
26    if !dir.exists() {
27        #[cfg(unix)]
28        {
29            fs::DirBuilder::new()
30                .recursive(true)
31                .mode(0o700)
32                .create(&dir)?;
33        }
34        #[cfg(not(unix))]
35        {
36            fs::create_dir_all(&dir)?;
37        }
38    }
39    Ok(dir)
40}
41
42/// Return the path to the PID file in the secure directory.
43pub fn pid_file_path() -> PathBuf {
44    pid_dir().join("semantic-diff.pid")
45}
46
47/// Return the path to the log file in the secure directory.
48pub fn log_file_path() -> PathBuf {
49    pid_dir().join("semantic-diff.log")
50}
51
52/// Write the current process ID to the PID file atomically.
53///
54/// Uses a temp file + rename pattern to prevent partial writes.
55/// The temp file is created with `create_new(true)` to avoid following symlinks.
56pub fn write_pid_file() -> io::Result<()> {
57    let dir = ensure_pid_dir()?;
58    let pid_path = dir.join("semantic-diff.pid");
59    let tmp_path = dir.join(".semantic-diff.pid.tmp");
60
61    // Remove stale temp file if it exists
62    let _ = fs::remove_file(&tmp_path);
63
64    // Write PID to temp file with restricted permissions
65    {
66        let mut opts = fs::OpenOptions::new();
67        opts.write(true).create_new(true);
68        #[cfg(unix)]
69        opts.mode(0o600);
70        let mut file = opts.open(&tmp_path)?;
71        io::Write::write_all(&mut file, process::id().to_string().as_bytes())?;
72    }
73
74    // Atomic rename
75    fs::rename(&tmp_path, &pid_path)?;
76
77    Ok(())
78}
79
80/// Remove the PID file (best-effort, ignores errors).
81pub fn remove_pid_file() {
82    let _ = fs::remove_file(pid_file_path());
83}
84
85/// Validate that a PID belongs to a semantic-diff process.
86///
87/// On macOS: uses `ps` to check the process command name.
88/// On Linux: reads `/proc/{pid}/comm` to check the process name.
89/// Returns false for PID 0 or if the process doesn't exist or doesn't match.
90fn validate_pid_ownership(pid: u32) -> bool {
91    if pid == 0 {
92        return false;
93    }
94
95    #[cfg(target_os = "macos")]
96    {
97        let output = std::process::Command::new("ps")
98            .args(["-p", &pid.to_string(), "-o", "comm="])
99            .output();
100        match output {
101            Ok(out) => {
102                let comm = String::from_utf8_lossy(&out.stdout);
103                comm.contains("semantic-diff")
104            }
105            Err(_) => false,
106        }
107    }
108
109    #[cfg(target_os = "linux")]
110    {
111        let comm_path = format!("/proc/{}/comm", pid);
112        match fs::read_to_string(&comm_path) {
113            Ok(comm) => comm.trim().contains("semantic-diff"),
114            Err(_) => false,
115        }
116    }
117
118    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
119    {
120        true
121    }
122}
123
124/// Read and parse the PID from the PID file.
125/// Returns None if the file is missing, contains invalid data,
126/// or the PID doesn't belong to a semantic-diff process.
127#[allow(dead_code)]
128pub fn read_pid() -> Option<u32> {
129    let pid_path = pid_file_path();
130    let pid: u32 = fs::read_to_string(pid_path).ok()?.trim().parse().ok()?;
131
132    if validate_pid_ownership(pid) {
133        Some(pid)
134    } else {
135        None
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use std::env;
143
144    #[test]
145    fn pid_dir_uses_xdg_runtime_dir_when_set() {
146        let test_dir = "/tmp/test-xdg-signal";
147        env::set_var("XDG_RUNTIME_DIR", test_dir);
148        let dir = pid_dir();
149        env::remove_var("XDG_RUNTIME_DIR");
150        assert!(
151            dir.starts_with(test_dir),
152            "pid_dir() should start with XDG_RUNTIME_DIR, got {:?}",
153            dir
154        );
155        assert!(
156            dir.ends_with("semantic-diff"),
157            "pid_dir() should end with 'semantic-diff', got {:?}",
158            dir
159        );
160    }
161
162    #[test]
163    fn pid_dir_falls_back_to_home_local_state() {
164        // Note: env vars are process-global so this test is inherently racy
165        // with parallel tests. We just verify the fallback path structure.
166        let saved = env::var("XDG_RUNTIME_DIR").ok();
167        env::remove_var("XDG_RUNTIME_DIR");
168        let dir = pid_dir();
169        if let Some(v) = saved {
170            env::set_var("XDG_RUNTIME_DIR", v);
171        }
172        // When XDG_RUNTIME_DIR is unset, should use $HOME/.local/state/semantic-diff
173        // But due to test parallelism, just verify it ends with semantic-diff
174        assert!(
175            dir.ends_with("semantic-diff"),
176            "pid_dir() fallback should end with 'semantic-diff', got {:?}",
177            dir
178        );
179    }
180
181    #[test]
182    fn write_pid_file_creates_file_with_correct_pid() {
183        // Test the atomic write logic directly to avoid env var races
184        let test_dir = tempfile::tempdir().unwrap();
185        let dir = test_dir.path().join("semantic-diff");
186        fs::create_dir_all(&dir).unwrap();
187        let pid_path = dir.join("semantic-diff.pid");
188        let tmp_path = dir.join(".semantic-diff.pid.tmp");
189        let _ = fs::remove_file(&tmp_path);
190        {
191            let mut file = fs::OpenOptions::new()
192                .write(true)
193                .create_new(true)
194                .open(&tmp_path)
195                .unwrap();
196            io::Write::write_all(&mut file, process::id().to_string().as_bytes()).unwrap();
197        }
198        fs::rename(&tmp_path, &pid_path).unwrap();
199        let content = fs::read_to_string(&pid_path).unwrap();
200        assert_eq!(
201            content.trim(),
202            process::id().to_string(),
203            "PID file should contain current PID"
204        );
205    }
206
207    #[test]
208    fn read_pid_returns_none_for_nonexistent_file() {
209        let test_dir = tempfile::tempdir().unwrap();
210        env::set_var("XDG_RUNTIME_DIR", test_dir.path());
211        let result = read_pid();
212        env::remove_var("XDG_RUNTIME_DIR");
213        assert_eq!(result, None, "read_pid should return None when file doesn't exist");
214    }
215
216    #[test]
217    fn validate_pid_ownership_returns_false_for_invalid_pids() {
218        assert!(!validate_pid_ownership(0), "PID 0 should be invalid");
219        assert!(
220            !validate_pid_ownership(999_999_999),
221            "Very large PID should be invalid (process unlikely to exist)"
222        );
223    }
224
225    #[test]
226    fn atomic_write_creates_file_after_write() {
227        // Test atomic rename directly to avoid env var races
228        let test_dir = tempfile::tempdir().unwrap();
229        let dir = test_dir.path().join("semantic-diff");
230        fs::create_dir_all(&dir).unwrap();
231        let pid_path = dir.join("semantic-diff.pid");
232        let tmp_path = dir.join(".semantic-diff.pid.tmp");
233        let _ = fs::remove_file(&tmp_path);
234        fs::write(&tmp_path, "12345").unwrap();
235        fs::rename(&tmp_path, &pid_path).unwrap();
236        assert!(pid_path.exists(), "PID file should exist after atomic write");
237        assert!(!tmp_path.exists(), "Temp file should not exist after atomic write");
238    }
239}