1use std::fs;
2use std::io;
3use std::path::{Path, PathBuf};
4
5use log::{debug, error};
6
7pub struct FileLock {
10 lock_path: PathBuf,
11 #[cfg(unix)]
12 _file: fs::File,
13}
14
15impl FileLock {
16 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 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 let file = fs::OpenOptions::new()
53 .write(true)
54 .create_new(true)
55 .open(&lock_path)
56 .or_else(|_| {
57 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 let _ = &self.lock_path;
83 }
84}
85
86pub fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
102 debug!("Atomic write: {}", path.display());
103 if let Some(parent) = path.parent() {
105 fs::create_dir_all(parent)?;
106 }
107
108 #[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 #[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 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 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 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 #[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 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}