Skip to main content

purple_ssh/
fs_util.rs

1use std::fs;
2use std::io;
3use std::path::{Path, PathBuf};
4
5/// Advisory file lock using a `.lock` file.
6/// The lock is released when the `FileLock` is dropped.
7pub struct FileLock {
8    lock_path: PathBuf,
9    #[cfg(unix)]
10    _file: fs::File,
11}
12
13impl FileLock {
14    /// Acquire an advisory lock for the given path.
15    /// Creates a `.purple_lock` file alongside the target and holds an `flock` on it.
16    /// Blocks until the lock is acquired (or returns an error on failure).
17    pub fn acquire(path: &Path) -> io::Result<Self> {
18        let mut lock_name = path.file_name().unwrap_or_default().to_os_string();
19        lock_name.push(".purple_lock");
20        let lock_path = path.with_file_name(lock_name);
21
22        #[cfg(unix)]
23        {
24            use std::os::unix::fs::OpenOptionsExt;
25            let file = fs::OpenOptions::new()
26                .write(true)
27                .create(true)
28                .truncate(false)
29                .mode(0o600)
30                .open(&lock_path)?;
31
32            // LOCK_EX = exclusive, blocks until acquired
33            let ret =
34                unsafe { libc::flock(std::os::unix::io::AsRawFd::as_raw_fd(&file), libc::LOCK_EX) };
35            if ret != 0 {
36                return Err(io::Error::last_os_error());
37            }
38
39            Ok(FileLock {
40                lock_path,
41                _file: file,
42            })
43        }
44
45        #[cfg(not(unix))]
46        {
47            // On non-Unix, use a simple lock file (best-effort)
48            let file = fs::OpenOptions::new()
49                .write(true)
50                .create_new(true)
51                .open(&lock_path)
52                .or_else(|_| {
53                    // If it already exists, wait briefly and retry
54                    std::thread::sleep(std::time::Duration::from_millis(100));
55                    fs::remove_file(&lock_path).ok();
56                    fs::OpenOptions::new()
57                        .write(true)
58                        .create_new(true)
59                        .open(&lock_path)
60                })?;
61            Ok(FileLock {
62                lock_path,
63                _file: file,
64            })
65        }
66    }
67}
68
69impl Drop for FileLock {
70    fn drop(&mut self) {
71        // On Unix, flock is released when the file descriptor is closed (automatic).
72        // Clean up the lock file.
73        let _ = fs::remove_file(&self.lock_path);
74    }
75}
76
77/// Atomic write: write content to a PID-suffixed temp file with chmod 600, then rename.
78/// Uses O_EXCL (create_new) to prevent symlink attacks on the temp file path.
79/// Cleans up the temp file on failure.
80pub fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
81    // Ensure parent directory exists
82    if let Some(parent) = path.parent() {
83        fs::create_dir_all(parent)?;
84    }
85
86    let mut tmp_name = path.file_name().unwrap_or_default().to_os_string();
87    tmp_name.push(format!(".purple_tmp.{}", std::process::id()));
88    let tmp_path = path.with_file_name(tmp_name);
89
90    #[cfg(unix)]
91    {
92        use std::io::Write;
93        use std::os::unix::fs::OpenOptionsExt;
94        // Try O_EXCL first. If a stale tmp file exists from a crashed run, remove
95        // it and retry once. This avoids a TOCTOU gap from removing before creating.
96        let open = || {
97            fs::OpenOptions::new()
98                .write(true)
99                .create_new(true)
100                .mode(0o600)
101                .open(&tmp_path)
102        };
103        let mut file = match open() {
104            Ok(f) => f,
105            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
106                let _ = fs::remove_file(&tmp_path);
107                open().map_err(|e| {
108                    io::Error::new(
109                        e.kind(),
110                        format!("Failed to create temp file {}: {}", tmp_path.display(), e),
111                    )
112                })?
113            }
114            Err(e) => {
115                return Err(io::Error::new(
116                    e.kind(),
117                    format!("Failed to create temp file {}: {}", tmp_path.display(), e),
118                ));
119            }
120        };
121        if let Err(e) = file.write_all(content) {
122            drop(file);
123            let _ = fs::remove_file(&tmp_path);
124            return Err(e);
125        }
126        if let Err(e) = file.sync_all() {
127            drop(file);
128            let _ = fs::remove_file(&tmp_path);
129            return Err(e);
130        }
131    }
132
133    #[cfg(not(unix))]
134    {
135        if let Err(e) = fs::write(&tmp_path, content) {
136            let _ = fs::remove_file(&tmp_path);
137            return Err(e);
138        }
139        // sync_all via reopen since fs::write doesn't return a File handle
140        match fs::File::open(&tmp_path) {
141            Ok(f) => {
142                if let Err(e) = f.sync_all() {
143                    let _ = fs::remove_file(&tmp_path);
144                    return Err(e);
145                }
146            }
147            Err(e) => {
148                let _ = fs::remove_file(&tmp_path);
149                return Err(e);
150            }
151        }
152    }
153
154    let result = fs::rename(&tmp_path, path);
155    if result.is_err() {
156        let _ = fs::remove_file(&tmp_path);
157    }
158    result
159}