Skip to main content

aperture_cli/
atomic.rs

1//! Atomic file I/O utilities for concurrency-safe cache operations.
2//!
3//! This module provides:
4//! - **Atomic writes** via temp-file + rename to prevent partial/corrupt files.
5//! - **Advisory file locking** for coordinating concurrent access to cache directories.
6//!
7//! # Concurrency Guarantees
8//!
9//! - A reader will never see a partially written file.
10//! - Concurrent writers to the same path will not interleave bytes; the last
11//!   rename wins, producing one complete file.
12//! - Advisory locks coordinate cache-directory operations across processes.
13//!
14//! # Cross-Platform Notes
15//!
16//! - On POSIX systems, `rename(2)` is atomic within the same filesystem.
17//! - On Windows, `std::fs::rename` uses `MoveFileEx` with `MOVEFILE_REPLACE_EXISTING`,
18//!   which is atomic for same-volume renames.
19
20use std::path::Path;
21
22/// Write `data` to `path` atomically by writing to a temporary sibling file
23/// and then renaming it into place.
24///
25/// The temp file is created in the same directory as `path` to guarantee
26/// same-filesystem rename semantics.
27///
28/// # Errors
29///
30/// Returns an error if:
31/// - The parent directory of `path` does not exist.
32/// - The temp file cannot be created or written.
33/// - The rename operation fails.
34pub async fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
35    let temp_path = temp_sibling(path);
36
37    // Write data to temp file
38    tokio::fs::write(&temp_path, data).await?;
39
40    // Atomically move temp file to target
41    if let Err(e) = tokio::fs::rename(&temp_path, path).await {
42        // Clean up the temp file on rename failure
43        let _ = tokio::fs::remove_file(&temp_path).await;
44        return Err(e);
45    }
46
47    Ok(())
48}
49
50/// Synchronous version of [`atomic_write`] for use in contexts that cannot
51/// use async (e.g., the [`FileSystem`](crate::fs::FileSystem) trait).
52///
53/// # Errors
54///
55/// Returns an error if any file operation fails.
56pub fn atomic_write_sync(path: &Path, data: &[u8]) -> std::io::Result<()> {
57    let temp_path = temp_sibling(path);
58
59    // Write data to temp file
60    std::fs::write(&temp_path, data)?;
61
62    // Atomically move temp file to target
63    if let Err(e) = std::fs::rename(&temp_path, path) {
64        // Clean up the temp file on rename failure
65        let _ = std::fs::remove_file(&temp_path);
66        return Err(e);
67    }
68
69    Ok(())
70}
71
72/// Generate a unique temporary file path as a sibling of `path`.
73///
74/// Uses `fastrand` for a random suffix to avoid collisions between
75/// concurrent writers targeting the same destination.
76fn 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
87/// Check whether an I/O error represents a lock-contention condition
88/// on the current platform.
89fn is_lock_contention_error(e: &std::io::Error) -> bool {
90    #[cfg(unix)]
91    {
92        // EAGAIN and EWOULDBLOCK are the same value on Linux but may
93        // differ on other POSIX systems, so we check both.
94        let code = e.raw_os_error();
95        code == Some(libc::EAGAIN) || code == Some(libc::EWOULDBLOCK)
96    }
97    #[cfg(windows)]
98    {
99        // ERROR_LOCK_VIOLATION = 33
100        e.raw_os_error() == Some(33)
101    }
102    #[cfg(not(any(unix, windows)))]
103    {
104        let _ = e;
105        false
106    }
107}
108
109/// Name of the advisory lock file placed in cache directories.
110const LOCK_FILE_NAME: &str = ".aperture.lock";
111
112/// An advisory file lock scoped to a directory.
113///
114/// The lock is acquired on creation and released when the guard is dropped.
115/// This uses `fs2` advisory locking which coordinates between cooperating
116/// processes — it does **not** prevent non-cooperating processes from
117/// accessing the directory.
118///
119/// # Example
120///
121/// ```no_run
122/// use std::path::Path;
123/// use aperture_cli::atomic::DirLock;
124///
125/// let lock = DirLock::acquire(Path::new("/tmp/cache")).unwrap();
126/// // … perform cache operations …
127/// drop(lock); // lock is released
128/// ```
129pub struct DirLock {
130    _file: std::fs::File,
131}
132
133impl DirLock {
134    /// Acquire an exclusive advisory lock on `dir`.
135    ///
136    /// Creates the lock file (`<dir>/.aperture.lock`) if it does not exist.
137    /// Blocks until the lock is available.
138    ///
139    /// # Errors
140    ///
141    /// Returns an error if the lock file cannot be created or locked.
142    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        // Ensure the directory exists
148        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    /// Try to acquire an exclusive advisory lock without blocking.
162    ///
163    /// Returns `Ok(None)` if the lock is held by another process.
164    ///
165    /// # Errors
166    ///
167    /// Returns an error if the lock file cannot be created.
168    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        // Ensure the directory exists
174        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                // On some platforms, `try_lock_exclusive` may return a
187                // platform-specific error code instead of `WouldBlock`.
188                // Only treat known lock-contention codes as "already held".
189                if is_lock_contention_error(&e) {
190                    return Ok(None);
191                }
192                Err(e)
193            }
194        }
195    }
196}
197
198// The lock is released when `_file` is dropped — `fs2` advisory locks
199// are automatically released when the file descriptor is closed.
200
201#[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        // Only the target file should exist
225        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        // Lock file should exist
279        assert!(dir.path().join(LOCK_FILE_NAME).exists());
280
281        drop(lock);
282        // Lock file still exists (we don't delete it) but lock is released
283        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        // Second try-acquire should fail while first lock is held
294        let lock2 = DirLock::try_acquire(dir.path()).unwrap();
295        assert!(lock2.is_none());
296
297        // After dropping first lock, try-acquire should succeed
298        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        // The file should contain one complete write — not a mixture
322        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        // Should be in the same directory
333        assert_eq!(t1.parent(), t2.parent());
334        assert_eq!(t1.parent().unwrap(), Path::new("/tmp/cache"));
335        // Should start with dot (hidden)
336        let name1 = t1.file_name().unwrap().to_string_lossy();
337        assert!(name1.starts_with('.'));
338        assert!(name1.ends_with(".tmp"));
339        // Names should (almost certainly) be different due to random suffix
340        assert_ne!(t1, t2);
341    }
342}