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