1use std::path::Path;
21
22pub async fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
35 let temp_path = temp_sibling(path);
36
37 tokio::fs::write(&temp_path, data).await?;
39
40 if let Err(e) = tokio::fs::rename(&temp_path, path).await {
42 let _ = tokio::fs::remove_file(&temp_path).await;
44 return Err(e);
45 }
46
47 Ok(())
48}
49
50pub fn atomic_write_sync(path: &Path, data: &[u8]) -> std::io::Result<()> {
57 let temp_path = temp_sibling(path);
58
59 std::fs::write(&temp_path, data)?;
61
62 if let Err(e) = std::fs::rename(&temp_path, path) {
64 let _ = std::fs::remove_file(&temp_path);
66 return Err(e);
67 }
68
69 Ok(())
70}
71
72fn temp_sibling(path: &Path) -> std::path::PathBuf {
77 let random_suffix = fastrand::u64(..);
78 let file_name = path
79 .file_name()
80 .map_or_else(|| "file".to_string(), |n| n.to_string_lossy().to_string());
81
82 let temp_name = format!(".{file_name}.{random_suffix:016x}.tmp");
83
84 path.with_file_name(temp_name)
85}
86
87fn is_lock_contention_error(e: &std::io::Error) -> bool {
90 #[cfg(unix)]
91 {
92 let code = e.raw_os_error();
95 code == Some(libc::EAGAIN) || code == Some(libc::EWOULDBLOCK)
96 }
97 #[cfg(windows)]
98 {
99 e.raw_os_error() == Some(33)
101 }
102 #[cfg(not(any(unix, windows)))]
103 {
104 let _ = e;
105 false
106 }
107}
108
109const LOCK_FILE_NAME: &str = ".aperture.lock";
111
112pub struct DirLock {
130 _file: std::fs::File,
131}
132
133impl DirLock {
134 pub fn acquire(dir: &Path) -> std::io::Result<Self> {
143 use fs2::FileExt;
144
145 let lock_path = dir.join(LOCK_FILE_NAME);
146
147 std::fs::create_dir_all(dir)?;
149
150 let file = std::fs::OpenOptions::new()
151 .create(true)
152 .truncate(false)
153 .write(true)
154 .open(&lock_path)?;
155
156 file.lock_exclusive()?;
157
158 Ok(Self { _file: file })
159 }
160
161 pub fn try_acquire(dir: &Path) -> std::io::Result<Option<Self>> {
169 use fs2::FileExt;
170
171 let lock_path = dir.join(LOCK_FILE_NAME);
172
173 std::fs::create_dir_all(dir)?;
175
176 let file = std::fs::OpenOptions::new()
177 .create(true)
178 .truncate(false)
179 .write(true)
180 .open(&lock_path)?;
181
182 match file.try_lock_exclusive() {
183 Ok(()) => Ok(Some(Self { _file: file })),
184 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => Ok(None),
185 Err(e) => {
186 if is_lock_contention_error(&e) {
190 return Ok(None);
191 }
192 Err(e)
193 }
194 }
195 }
196}
197
198#[cfg(test)]
202mod tests {
203 use super::*;
204 use tempfile::TempDir;
205
206 #[tokio::test]
207 async fn test_atomic_write_creates_file() {
208 let dir = TempDir::new().unwrap();
209 let path = dir.path().join("test.txt");
210
211 atomic_write(&path, b"hello world").await.unwrap();
212
213 let content = tokio::fs::read_to_string(&path).await.unwrap();
214 assert_eq!(content, "hello world");
215 }
216
217 #[tokio::test]
218 async fn test_atomic_write_no_temp_files_left() {
219 let dir = TempDir::new().unwrap();
220 let path = dir.path().join("test.txt");
221
222 atomic_write(&path, b"data").await.unwrap();
223
224 let entries: Vec<_> = std::fs::read_dir(dir.path())
226 .unwrap()
227 .filter_map(Result::ok)
228 .collect();
229 assert_eq!(entries.len(), 1);
230 assert_eq!(
231 entries[0].file_name().to_string_lossy().as_ref(),
232 "test.txt"
233 );
234 }
235
236 #[tokio::test]
237 async fn test_atomic_write_overwrites_existing() {
238 let dir = TempDir::new().unwrap();
239 let path = dir.path().join("test.txt");
240
241 atomic_write(&path, b"first").await.unwrap();
242 atomic_write(&path, b"second").await.unwrap();
243
244 let content = tokio::fs::read_to_string(&path).await.unwrap();
245 assert_eq!(content, "second");
246 }
247
248 #[test]
249 fn test_atomic_write_sync_creates_file() {
250 let dir = TempDir::new().unwrap();
251 let path = dir.path().join("test.txt");
252
253 atomic_write_sync(&path, b"hello sync").unwrap();
254
255 let content = std::fs::read_to_string(&path).unwrap();
256 assert_eq!(content, "hello sync");
257 }
258
259 #[test]
260 fn test_atomic_write_sync_no_temp_files_left() {
261 let dir = TempDir::new().unwrap();
262 let path = dir.path().join("test.txt");
263
264 atomic_write_sync(&path, b"data").unwrap();
265
266 let entries: Vec<_> = std::fs::read_dir(dir.path())
267 .unwrap()
268 .filter_map(Result::ok)
269 .collect();
270 assert_eq!(entries.len(), 1);
271 }
272
273 #[test]
274 fn test_dir_lock_acquire_and_release() {
275 let dir = TempDir::new().unwrap();
276
277 let lock = DirLock::acquire(dir.path()).unwrap();
278 assert!(dir.path().join(LOCK_FILE_NAME).exists());
280
281 drop(lock);
282 assert!(dir.path().join(LOCK_FILE_NAME).exists());
284 }
285
286 #[test]
287 fn test_dir_lock_try_acquire() {
288 let dir = TempDir::new().unwrap();
289
290 let lock1 = DirLock::try_acquire(dir.path()).unwrap();
291 assert!(lock1.is_some());
292
293 let lock2 = DirLock::try_acquire(dir.path()).unwrap();
295 assert!(lock2.is_none());
296
297 drop(lock1);
299 let lock3 = DirLock::try_acquire(dir.path()).unwrap();
300 assert!(lock3.is_some());
301 }
302
303 #[tokio::test]
304 async fn test_concurrent_atomic_writes_no_corruption() {
305 let dir = TempDir::new().unwrap();
306 let path = dir.path().join("concurrent.txt");
307
308 let mut handles = Vec::new();
309 for i in 0..20 {
310 let p = path.clone();
311 handles.push(tokio::spawn(async move {
312 let data = format!("writer-{i}-{}", "x".repeat(1000));
313 atomic_write(&p, data.as_bytes()).await.unwrap();
314 }));
315 }
316
317 for handle in handles {
318 handle.await.unwrap();
319 }
320
321 let content = tokio::fs::read_to_string(&path).await.unwrap();
323 assert!(content.starts_with("writer-"));
324 assert!(content.ends_with(&"x".repeat(1000)));
325 }
326
327 #[test]
328 fn test_temp_sibling_uniqueness() {
329 let path = Path::new("/tmp/cache/test.json");
330 let t1 = temp_sibling(path);
331 let t2 = temp_sibling(path);
332 assert_eq!(t1.parent(), t2.parent());
334 assert_eq!(t1.parent().unwrap(), Path::new("/tmp/cache"));
335 let name1 = t1.file_name().unwrap().to_string_lossy();
337 assert!(name1.starts_with('.'));
338 assert!(name1.ends_with(".tmp"));
339 assert_ne!(t1, t2);
341 }
342}