Skip to main content

uira_core/protocol/primitives/
atomic_write.rs

1//! Atomic file writing utilities
2//!
3//! Provides crash-safe file writes using the temp→fsync→rename pattern.
4//! Uses the `tempfile` crate for cross-platform atomic operations.
5
6use std::fs::{self, File};
7use std::io::{self, Write};
8use std::path::Path;
9
10use tempfile::NamedTempFile;
11
12#[cfg(unix)]
13use std::os::unix::fs::PermissionsExt;
14
15/// Write content atomically to a file.
16///
17/// Uses the pattern: create unique temp file → write → fsync → atomic rename
18/// This ensures the file is either fully written or unchanged on crash.
19///
20/// # Cross-platform behavior
21/// - On Unix: Uses rename(2) which atomically replaces the target
22/// - On Windows: Uses `tempfile::NamedTempFile::persist()` which handles
23///   the platform-specific workarounds for atomic replacement
24///
25/// # Cleanup guarantee
26/// If any step fails, the temp file is automatically cleaned up via RAII.
27/// No orphaned temp files will be left behind.
28///
29/// # Arguments
30/// * `path` - Target file path
31/// * `content` - Content to write
32/// * `mode` - Optional Unix file permissions (ignored on non-Unix, defaults to 0o644)
33pub fn atomic_write(path: &Path, content: &[u8], mode: Option<u32>) -> io::Result<()> {
34    let parent = path.parent().ok_or_else(|| {
35        io::Error::new(io::ErrorKind::InvalidInput, "Path has no parent directory")
36    })?;
37
38    // Ensure parent directory exists
39    fs::create_dir_all(parent)?;
40
41    // Create temp file in same directory (required for atomic rename on same filesystem)
42    // NamedTempFile generates a unique random name, avoiding collisions
43    let mut temp_file = NamedTempFile::new_in(parent)?;
44
45    // Write content
46    temp_file.write_all(content)?;
47
48    // Fsync to ensure data is on disk before rename
49    temp_file.as_file().sync_all()?;
50
51    // Set permissions before persist (Unix only)
52    #[cfg(unix)]
53    {
54        let m = mode.unwrap_or(0o644);
55        let perms = std::fs::Permissions::from_mode(m);
56        temp_file.as_file().set_permissions(perms)?;
57    }
58
59    // Suppress unused variable warning on non-Unix
60    #[cfg(not(unix))]
61    let _ = mode;
62
63    // Atomic rename - persist() handles cross-platform differences
64    // On Windows, this handles the case where target already exists
65    temp_file.persist(path).map_err(|e| e.error)?;
66
67    // Sync parent directory for durability (Unix only)
68    // This ensures the directory entry is persisted
69    #[cfg(unix)]
70    {
71        if let Ok(dir) = File::open(parent) {
72            let _ = dir.sync_all();
73        }
74    }
75
76    Ok(())
77}
78
79/// Write content atomically with secure permissions (0o600 on Unix).
80///
81/// Convenience wrapper for writing sensitive files like credentials.
82/// On non-Unix platforms, uses default permissions but still provides
83/// atomic write guarantees.
84pub fn atomic_write_secure(path: &Path, content: &[u8]) -> io::Result<()> {
85    atomic_write(path, content, Some(0o600))
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use std::fs;
92    use tempfile::tempdir;
93
94    #[test]
95    fn test_atomic_write_creates_file() {
96        let dir = tempdir().unwrap();
97        let path = dir.path().join("test.txt");
98
99        atomic_write(&path, b"hello world", None).unwrap();
100
101        assert_eq!(fs::read_to_string(&path).unwrap(), "hello world");
102    }
103
104    #[test]
105    fn test_atomic_write_overwrites_existing() {
106        let dir = tempdir().unwrap();
107        let path = dir.path().join("test.txt");
108
109        atomic_write(&path, b"first", None).unwrap();
110        atomic_write(&path, b"second", None).unwrap();
111
112        assert_eq!(fs::read_to_string(&path).unwrap(), "second");
113    }
114
115    #[test]
116    fn test_atomic_write_creates_parent_dirs() {
117        let dir = tempdir().unwrap();
118        let path = dir.path().join("nested/dir/test.txt");
119
120        atomic_write(&path, b"nested content", None).unwrap();
121
122        assert_eq!(fs::read_to_string(&path).unwrap(), "nested content");
123    }
124
125    #[cfg(unix)]
126    #[test]
127    fn test_atomic_write_secure_permissions() {
128        use std::os::unix::fs::PermissionsExt;
129
130        let dir = tempdir().unwrap();
131        let path = dir.path().join("secret.txt");
132
133        atomic_write_secure(&path, b"secret").unwrap();
134
135        let perms = fs::metadata(&path).unwrap().permissions();
136        assert_eq!(perms.mode() & 0o777, 0o600);
137    }
138
139    #[test]
140    fn test_no_temp_file_left_on_success() {
141        let dir = tempdir().unwrap();
142        let path = dir.path().join("test.txt");
143
144        atomic_write(&path, b"content", None).unwrap();
145
146        // Check no temp files remain
147        let entries: Vec<_> = fs::read_dir(dir.path()).unwrap().collect();
148        assert_eq!(entries.len(), 1);
149        assert_eq!(entries[0].as_ref().unwrap().file_name(), "test.txt");
150    }
151
152    #[test]
153    fn test_no_temp_file_left_on_write_error() {
154        let dir = tempdir().unwrap();
155        // Try to write to a path where parent doesn't exist and can't be created
156        // This is tricky to test reliably, so we'll just verify the RAII cleanup
157        // by checking that NamedTempFile cleans up on drop
158
159        let temp = NamedTempFile::new_in(dir.path()).unwrap();
160        let temp_path = temp.path().to_path_buf();
161        assert!(temp_path.exists());
162
163        // Drop without persist - should clean up
164        drop(temp);
165        assert!(!temp_path.exists());
166    }
167
168    #[test]
169    fn test_concurrent_writes_no_collision() {
170        use std::sync::Arc;
171        use std::thread;
172
173        let dir = tempdir().unwrap();
174        let dir_path = Arc::new(dir.path().to_path_buf());
175
176        // Spawn multiple threads writing to different files simultaneously
177        let handles: Vec<_> = (0..10)
178            .map(|i| {
179                let dir = Arc::clone(&dir_path);
180                thread::spawn(move || {
181                    let path = dir.join(format!("file_{}.txt", i));
182                    atomic_write(&path, format!("content_{}", i).as_bytes(), None).unwrap();
183                })
184            })
185            .collect();
186
187        for handle in handles {
188            handle.join().unwrap();
189        }
190
191        // Verify all files written correctly
192        for i in 0..10 {
193            let path = dir.path().join(format!("file_{}.txt", i));
194            assert_eq!(fs::read_to_string(&path).unwrap(), format!("content_{}", i));
195        }
196
197        // Verify no temp files left
198        let entries: Vec<_> = fs::read_dir(dir.path())
199            .unwrap()
200            .filter_map(|e| e.ok())
201            .filter(|e| e.file_name().to_string_lossy().starts_with('.'))
202            .collect();
203        assert!(entries.is_empty(), "Temp files left behind: {:?}", entries);
204    }
205}