Skip to main content

purple_ssh/
fs_util.rs

1use std::fs;
2use std::io;
3use std::path::{Path, PathBuf};
4
5use log::{debug, error};
6use serde::Serialize;
7use serde::de::DeserializeOwned;
8
9/// Advisory file lock using a `.lock` file.
10/// The lock is released when the `FileLock` is dropped.
11pub struct FileLock {
12    lock_path: PathBuf,
13    #[cfg(unix)]
14    _file: fs::File,
15}
16
17impl FileLock {
18    /// Acquire an advisory lock for the given path.
19    /// Creates a `.purple_lock` file alongside the target and holds an `flock` on it.
20    /// Blocks until the lock is acquired (or returns an error on failure).
21    pub fn acquire(path: &Path) -> io::Result<Self> {
22        let mut lock_name = path.file_name().unwrap_or_default().to_os_string();
23        lock_name.push(".purple_lock");
24        let lock_path = path.with_file_name(lock_name);
25
26        #[cfg(unix)]
27        {
28            use std::os::unix::fs::OpenOptionsExt;
29            let file = fs::OpenOptions::new()
30                .write(true)
31                .create(true)
32                .truncate(false)
33                .mode(0o600)
34                .open(&lock_path)?;
35
36            // SAFETY: flock() is safe to call on any valid file descriptor.
37            // The fd comes from a File we just opened and own. LOCK_EX
38            // requests an exclusive advisory lock, blocking until acquired.
39            let ret =
40                unsafe { libc::flock(std::os::unix::io::AsRawFd::as_raw_fd(&file), libc::LOCK_EX) };
41            if ret != 0 {
42                return Err(io::Error::last_os_error());
43            }
44
45            Ok(FileLock {
46                lock_path,
47                _file: file,
48            })
49        }
50
51        #[cfg(not(unix))]
52        {
53            // On non-Unix, use a simple lock file (best-effort)
54            let file = fs::OpenOptions::new()
55                .write(true)
56                .create_new(true)
57                .open(&lock_path)
58                .or_else(|_| {
59                    // If it already exists, wait briefly and retry
60                    std::thread::sleep(std::time::Duration::from_millis(100));
61                    fs::remove_file(&lock_path).ok();
62                    fs::OpenOptions::new()
63                        .write(true)
64                        .create_new(true)
65                        .open(&lock_path)
66                })?;
67            Ok(FileLock {
68                lock_path,
69                _file: file,
70            })
71        }
72    }
73}
74
75impl Drop for FileLock {
76    fn drop(&mut self) {
77        // On Unix, flock is released when the file descriptor is closed (automatic).
78        // The lockfile itself is intentionally left on disk: unlinking it here
79        // creates a race where a second process opens a fresh inode at the
80        // same path and obtains an independent flock, so both processes
81        // think they hold the lock. Leaving the file (1 byte at chmod 600)
82        // matches the standard advisory-lock pattern.
83        // The `lock_path` field is kept for diagnostics (Debug, logging).
84        let _ = &self.lock_path;
85    }
86}
87
88/// Atomic write: write content to a PID-suffixed temp file with chmod 600, then rename.
89/// Uses O_EXCL (create_new) to prevent symlink attacks on the temp file path.
90/// Cleans up the temp file on failure.
91///
92/// When the target file already exists, its mode is preserved across the
93/// rename — clamped to a minimum of 0o600 so a write never widens the
94/// permission set of an SSH config file. A target with mode 0o644 stays
95/// 0o644; a target with mode 0o400 is tightened from 0o600 (the temp file's
96/// initial mode) up to 0o600 — i.e. the more restrictive of the two wins
97/// only when it's still at least 0o600.
98///
99/// Logs a warning when the target is a hard link with more than one name:
100/// `rename(2)` substitutes the inode atomically, so any sibling hard link
101/// silently keeps the OLD content. Common dotfiles managers (chezmoi, stow)
102/// use symlinks rather than hard links so this is rare, but worth surfacing.
103pub fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
104    debug!("[purple] Atomic write: {}", path.display());
105    // Ensure parent directory exists
106    if let Some(parent) = path.parent() {
107        fs::create_dir_all(parent)?;
108    }
109
110    // Capture the target's existing mode (if it exists) so the rename does
111    // not silently flip e.g. 0o644 to 0o600. `symlink_metadata` here would
112    // miss the case where the target is a symlink we just resolved; use
113    // `metadata` which follows.
114    #[cfg(unix)]
115    let target_mode: Option<u32> = {
116        use std::os::unix::fs::MetadataExt;
117        fs::metadata(path).ok().map(|m| m.mode() & 0o777)
118    };
119
120    // Detect hard-linked targets and emit a one-time warning so a user with
121    // a dotfiles repo hard-linked into ~/.ssh sees why their other name
122    // diverges after a save.
123    #[cfg(unix)]
124    if let Ok(meta) = fs::symlink_metadata(path) {
125        use std::os::unix::fs::MetadataExt;
126        if meta.nlink() > 1 {
127            log::warn!(
128                "[purple] {} has {} hard links; atomic write will keep this name's content but leave siblings pointing at the previous inode",
129                path.display(),
130                meta.nlink()
131            );
132        }
133    }
134
135    let mut tmp_name = path.file_name().unwrap_or_default().to_os_string();
136    tmp_name.push(format!(".purple_tmp.{}", std::process::id()));
137    let tmp_path = path.with_file_name(tmp_name);
138
139    #[cfg(unix)]
140    {
141        use std::io::Write;
142        use std::os::unix::fs::OpenOptionsExt;
143        // Try O_EXCL first. If a stale tmp file exists from a crashed run, remove
144        // it and retry once. This avoids a TOCTOU gap from removing before creating.
145        let open = || {
146            fs::OpenOptions::new()
147                .write(true)
148                .create_new(true)
149                .mode(0o600)
150                .open(&tmp_path)
151        };
152        let mut file = match open() {
153            Ok(f) => f,
154            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
155                let _ = fs::remove_file(&tmp_path);
156                open().map_err(|e| {
157                    io::Error::new(
158                        e.kind(),
159                        format!("Failed to create temp file {}: {}", tmp_path.display(), e),
160                    )
161                })?
162            }
163            Err(e) => {
164                return Err(io::Error::new(
165                    e.kind(),
166                    format!("Failed to create temp file {}: {}", tmp_path.display(), e),
167                ));
168            }
169        };
170        if let Err(e) = file.write_all(content) {
171            drop(file);
172            let _ = fs::remove_file(&tmp_path);
173            return Err(e);
174        }
175        if let Err(e) = file.sync_all() {
176            drop(file);
177            let _ = fs::remove_file(&tmp_path);
178            return Err(e);
179        }
180        // Preserve the target's mode if it existed. Clamp to a minimum of
181        // 0o600 so an SSH config file is never silently widened by this
182        // write; 0o400 or 0o600 stays as-is, 0o644 stays 0o644, anything
183        // wider keeps its original perms. Best-effort: a chmod failure
184        // shouldn't abort the write (the temp file is already at 0o600).
185        if let Some(mode) = target_mode {
186            use std::os::unix::fs::PermissionsExt;
187            let preserved = if mode < 0o600 { 0o600 } else { mode };
188            if let Err(e) = fs::set_permissions(&tmp_path, fs::Permissions::from_mode(preserved)) {
189                debug!(
190                    "[purple] could not preserve target mode {:o} on {}: {e}",
191                    preserved,
192                    tmp_path.display()
193                );
194            }
195        }
196    }
197
198    #[cfg(not(unix))]
199    {
200        if let Err(e) = fs::write(&tmp_path, content) {
201            let _ = fs::remove_file(&tmp_path);
202            return Err(e);
203        }
204        // sync_all via reopen since fs::write doesn't return a File handle
205        match fs::File::open(&tmp_path) {
206            Ok(f) => {
207                if let Err(e) = f.sync_all() {
208                    let _ = fs::remove_file(&tmp_path);
209                    return Err(e);
210                }
211            }
212            Err(e) => {
213                let _ = fs::remove_file(&tmp_path);
214                return Err(e);
215            }
216        }
217    }
218
219    let result = fs::rename(&tmp_path, path);
220    if let Err(ref err) = result {
221        let _ = fs::remove_file(&tmp_path);
222        error!("[purple] Atomic write failed: {}: {err}", path.display());
223        return result;
224    }
225
226    // Durable rename: the temp file's data was synced before rename, but the
227    // directory entry change produced by `rename` lives in the page cache
228    // until the parent directory itself is synced. Without this, a crash
229    // within seconds of save can leave the directory pointing at the old
230    // inode (= silently dropped edit). Best-effort: log but don't fail the
231    // write if the parent sync itself fails — the rename already succeeded.
232    #[cfg(unix)]
233    if let Some(parent) = path.parent() {
234        if let Err(err) = fs::File::open(parent).and_then(|d| d.sync_all()) {
235            debug!(
236                "[purple] parent dir sync after rename failed (rename succeeded): {}: {err}",
237                parent.display()
238            );
239        }
240    }
241
242    result
243}
244
245/// Read and parse a JSON file. On a parse error the corrupt file is preserved
246/// aside as `<path>.corrupt-<now>` (best-effort) before returning `None`, so a
247/// future debugger can recover it. Missing files and other read errors also
248/// return `None`. `now` (epoch seconds) stamps the corrupt-backup name. Shared
249/// by the on-disk JSON ledgers (key activity, snippet runs).
250pub fn read_json_recovering<T: DeserializeOwned>(path: &Path, now: u64) -> Option<T> {
251    match fs::read_to_string(path) {
252        Ok(s) => match serde_json::from_str::<T>(&s) {
253            Ok(value) => Some(value),
254            Err(e) => {
255                let backup = path.with_extension(format!("json.corrupt-{now}"));
256                if let Err(rename_err) = fs::rename(path, &backup) {
257                    debug!(
258                        "[purple] json read: parse failed and could not preserve corrupt file: parse={e} rename={rename_err}",
259                    );
260                } else {
261                    debug!(
262                        "[purple] json read: parse failed, preserved corrupt file at {}: {e}",
263                        backup.display(),
264                    );
265                }
266                None
267            }
268        },
269        Err(e) => {
270            if e.kind() != io::ErrorKind::NotFound {
271                debug!("[purple] json read failed for {}: {e}", path.display());
272            }
273            None
274        }
275    }
276}
277
278/// Serialize `value` as pretty JSON and write it atomically via [`atomic_write`].
279pub fn write_json_pretty<T: Serialize>(path: &Path, value: &T) -> io::Result<()> {
280    let body = serde_json::to_vec_pretty(value)
281        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
282    atomic_write(path, &body)
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn file_lock_does_not_remove_lockfile_on_drop() {
291        // Regression for the lockfile-unlink race: a second process can open
292        // a new inode at the same path between fd-close and remove_file,
293        // and then both processes hold flock on independent inodes.
294        // Leaving the lockfile in place after drop prevents this entirely.
295        let dir = tempfile::tempdir().expect("tempdir");
296        let target = dir.path().join("config");
297        let lockfile = dir.path().join("config.purple_lock");
298
299        {
300            let _lock = FileLock::acquire(&target).expect("acquire");
301            assert!(lockfile.exists(), "lockfile must be created on acquire");
302        }
303        assert!(
304            lockfile.exists(),
305            "lockfile must remain after drop (not unlinked)"
306        );
307    }
308
309    #[test]
310    fn atomic_write_creates_file_with_content() {
311        let dir = tempfile::tempdir().expect("tempdir");
312        let target = dir.path().join("file");
313        atomic_write(&target, b"hello\n").expect("write");
314        let content = std::fs::read_to_string(&target).expect("read");
315        assert_eq!(content, "hello\n");
316    }
317
318    #[test]
319    fn atomic_write_replaces_existing_file() {
320        let dir = tempfile::tempdir().expect("tempdir");
321        let target = dir.path().join("file");
322        std::fs::write(&target, b"old").expect("write old");
323        atomic_write(&target, b"new").expect("write new");
324        let content = std::fs::read_to_string(&target).expect("read");
325        assert_eq!(content, "new");
326    }
327
328    #[test]
329    fn atomic_write_leaves_no_temp_file() {
330        let dir = tempfile::tempdir().expect("tempdir");
331        let target = dir.path().join("file");
332        atomic_write(&target, b"content").expect("write");
333        let stem = target.file_name().unwrap().to_string_lossy().to_string();
334        let leftovers: Vec<_> = std::fs::read_dir(dir.path())
335            .unwrap()
336            .filter_map(|e| e.ok())
337            .filter(|e| {
338                let n = e.file_name().to_string_lossy().to_string();
339                n.starts_with(&format!("{}.purple_tmp.", stem))
340            })
341            .collect();
342        assert!(
343            leftovers.is_empty(),
344            "temp file leaked after successful write: {:?}",
345            leftovers.iter().map(|e| e.path()).collect::<Vec<_>>()
346        );
347    }
348}