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