Skip to main content

nap_core/merge/
atomic_write.rs

1//! Atomic file write utility.
2//!
3//! Protocol invariant: repository writes MUST be atomic.
4//!
5//! Implementation: write temp file → flush → fsync → rename.
6//! This prevents partial writes from corrupting the repository.
7
8use std::io::Write;
9use std::path::{Path, PathBuf};
10
11/// Error type for atomic write operations.
12#[derive(Debug, thiserror::Error)]
13pub enum AtomicWriteError {
14    #[error("failed to create temp file: {0}")]
15    CreateTemp(std::io::Error),
16
17    #[error("failed to write content: {0}")]
18    Write(std::io::Error),
19
20    #[error("failed to flush: {0}")]
21    Flush(std::io::Error),
22
23    #[error("failed to fsync: {0}")]
24    Fsync(std::io::Error),
25
26    #[error("failed to rename: {0}")]
27    Rename(std::io::Error),
28}
29
30/// Atomically write content to a file.
31///
32/// 1. Create a temporary file alongside the target path.
33/// 2. Write all content.
34/// 3. Flush the stream.
35/// 4. fsync the file (ensures data hits the disk).
36/// 5. Rename the temp file to the target path (atomic on Unix).
37///
38/// # Arguments
39///
40/// * `path` - The final file path to write.
41/// * `content` - The bytes to write.
42pub fn atomic_write(path: &Path, content: &[u8]) -> Result<(), AtomicWriteError> {
43    // Create temp file in the same directory (same filesystem = atomic rename)
44    let parent = path.parent().unwrap_or_else(|| Path::new("."));
45    let file_stem = path
46        .file_stem()
47        .and_then(|s| s.to_str())
48        .unwrap_or("nap_tmp");
49    let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("tmp");
50
51    let temp_path = loop {
52        let rand_suffix: u64 = rand::random();
53        let candidate = parent.join(format!(".{file_stem}_{rand_suffix:016x}.{extension}"));
54        if !candidate.exists() {
55            break candidate;
56        }
57    };
58
59    let result = try_atomic_write(path, &temp_path, content);
60
61    // Clean up temp file on error
62    if result.is_err() {
63        let _ = std::fs::remove_file(&temp_path);
64    }
65
66    result
67}
68
69fn try_atomic_write(
70    target: &Path,
71    temp_path: &PathBuf,
72    content: &[u8],
73) -> Result<(), AtomicWriteError> {
74    // Ensure parent directory exists
75    if let Some(parent) = target.parent() {
76        std::fs::create_dir_all(parent).map_err(AtomicWriteError::CreateTemp)?;
77    }
78
79    // Create and write to temp file
80    let mut file = std::fs::File::create(temp_path).map_err(AtomicWriteError::CreateTemp)?;
81    file.write_all(content).map_err(AtomicWriteError::Write)?;
82    file.flush().map_err(AtomicWriteError::Flush)?;
83    file.sync_all().map_err(AtomicWriteError::Fsync)?;
84    drop(file); // Close before rename
85
86    // Atomic rename (atomic on Unix, best-effort on Windows)
87    std::fs::rename(temp_path, target).map_err(AtomicWriteError::Rename)?;
88
89    // Sync the directory to ensure the rename is durable
90    if let Some(parent) = target.parent()
91        && let Ok(dir) = std::fs::File::open(parent)
92    {
93        let _ = dir.sync_all();
94    }
95
96    Ok(())
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use std::io::Read;
103
104    #[test]
105    fn test_atomic_write_and_read() {
106        let dir = tempfile::tempdir().unwrap();
107        let path = dir.path().join("test.yaml");
108
109        let content = b"key: value\nname: Luke\n";
110        atomic_write(&path, content).unwrap();
111
112        let mut file = std::fs::File::open(&path).unwrap();
113        let mut buf = Vec::new();
114        file.read_to_end(&mut buf).unwrap();
115        assert_eq!(buf, content);
116    }
117
118    #[test]
119    fn test_atomic_write_no_temp_file_left() {
120        let dir = tempfile::tempdir().unwrap();
121        let path = dir.path().join("output.yaml");
122
123        atomic_write(&path, b"hello").unwrap();
124
125        // Verify no temp files remain
126        let entries: Vec<_> = std::fs::read_dir(dir.path())
127            .unwrap()
128            .filter_map(|e| e.ok())
129            .map(|e| e.file_name())
130            .collect();
131        assert_eq!(entries.len(), 1);
132        assert_eq!(entries[0], "output.yaml");
133    }
134
135    #[test]
136    fn test_atomic_write_content_is_identical() {
137        let dir = tempfile::tempdir().unwrap();
138        let path = dir.path().join("manifest.yaml");
139
140        let content = b"id: nap://test/char/luke\nname: Luke Skywalker\nversion: 1\n";
141        atomic_write(&path, content).unwrap();
142
143        let read_back = std::fs::read(&path).unwrap();
144        assert_eq!(read_back, content);
145    }
146
147    #[test]
148    fn test_atomic_write_creates_parent_dirs() {
149        let dir = tempfile::tempdir().unwrap();
150        let path = dir.path().join("nested/sub/dir/manifest.yaml");
151
152        atomic_write(&path, b"hello").unwrap();
153        assert!(path.exists());
154    }
155}