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        // Clean up the lock file.
77        let _ = fs::remove_file(&self.lock_path);
78    }
79}
80
81/// Atomic write: write content to a PID-suffixed temp file with chmod 600, then rename.
82/// Uses O_EXCL (create_new) to prevent symlink attacks on the temp file path.
83/// Cleans up the temp file on failure.
84pub fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
85    debug!("Atomic write: {}", path.display());
86    // Ensure parent directory exists
87    if let Some(parent) = path.parent() {
88        fs::create_dir_all(parent)?;
89    }
90
91    let mut tmp_name = path.file_name().unwrap_or_default().to_os_string();
92    tmp_name.push(format!(".purple_tmp.{}", std::process::id()));
93    let tmp_path = path.with_file_name(tmp_name);
94
95    #[cfg(unix)]
96    {
97        use std::io::Write;
98        use std::os::unix::fs::OpenOptionsExt;
99        // Try O_EXCL first. If a stale tmp file exists from a crashed run, remove
100        // it and retry once. This avoids a TOCTOU gap from removing before creating.
101        let open = || {
102            fs::OpenOptions::new()
103                .write(true)
104                .create_new(true)
105                .mode(0o600)
106                .open(&tmp_path)
107        };
108        let mut file = match open() {
109            Ok(f) => f,
110            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
111                let _ = fs::remove_file(&tmp_path);
112                open().map_err(|e| {
113                    io::Error::new(
114                        e.kind(),
115                        format!("Failed to create temp file {}: {}", tmp_path.display(), e),
116                    )
117                })?
118            }
119            Err(e) => {
120                return Err(io::Error::new(
121                    e.kind(),
122                    format!("Failed to create temp file {}: {}", tmp_path.display(), e),
123                ));
124            }
125        };
126        if let Err(e) = file.write_all(content) {
127            drop(file);
128            let _ = fs::remove_file(&tmp_path);
129            return Err(e);
130        }
131        if let Err(e) = file.sync_all() {
132            drop(file);
133            let _ = fs::remove_file(&tmp_path);
134            return Err(e);
135        }
136    }
137
138    #[cfg(not(unix))]
139    {
140        if let Err(e) = fs::write(&tmp_path, content) {
141            let _ = fs::remove_file(&tmp_path);
142            return Err(e);
143        }
144        // sync_all via reopen since fs::write doesn't return a File handle
145        match fs::File::open(&tmp_path) {
146            Ok(f) => {
147                if let Err(e) = f.sync_all() {
148                    let _ = fs::remove_file(&tmp_path);
149                    return Err(e);
150                }
151            }
152            Err(e) => {
153                let _ = fs::remove_file(&tmp_path);
154                return Err(e);
155            }
156        }
157    }
158
159    let result = fs::rename(&tmp_path, path);
160    if let Err(ref err) = result {
161        let _ = fs::remove_file(&tmp_path);
162        error!("[purple] Atomic write failed: {}: {err}", path.display());
163    }
164    result
165}