1use std::fs;
2use std::io;
3use std::path::{Path, PathBuf};
4
5use log::{debug, error};
6use serde::Serialize;
7use serde::de::DeserializeOwned;
8
9pub struct FileLock {
12 lock_path: PathBuf,
13 #[cfg(unix)]
14 _file: fs::File,
15}
16
17impl FileLock {
18 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 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 let file = fs::OpenOptions::new()
55 .write(true)
56 .create_new(true)
57 .open(&lock_path)
58 .or_else(|_| {
59 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 let _ = &self.lock_path;
85 }
86}
87
88pub fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
104 debug!("[purple] Atomic write: {}", path.display());
105 if let Some(parent) = path.parent() {
107 fs::create_dir_all(parent)?;
108 }
109
110 #[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 #[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 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 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 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 #[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
245pub 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
278pub 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 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}