Skip to main content

xchecker_utils/
atomic_write.rs

1//! Atomic file operations with cross-platform support (FR-FS)
2//!
3//! This module provides atomic file write operations with:
4//! - Temporary file creation with fsync
5//! - Atomic rename (same filesystem)
6//! - Windows rename retry with exponential backoff (≤ 250ms total)
7//! - Cross-filesystem fallback (copy→fsync→replace)
8//! - Warning tracking for retries and fallbacks
9
10use anyhow::{Context, Result};
11use camino::Utf8Path;
12use std::fs;
13use std::io::Write;
14use std::path::Path;
15
16use tempfile::NamedTempFile;
17
18#[cfg(target_os = "windows")]
19use std::{thread, time::Duration};
20
21/// Result of an atomic write operation
22#[derive(Debug, Clone, Default)]
23pub struct AtomicWriteResult {
24    /// Number of rename retries that occurred (Windows only)
25    pub rename_retry_count: u32,
26    /// Whether cross-filesystem fallback was used
27    pub used_cross_filesystem_fallback: bool,
28    /// Any warnings generated during the operation
29    pub warnings: Vec<String>,
30}
31
32/// Atomically write content to a file using temp file + fsync + rename
33///
34/// This implements FR-FS-001 through FR-FS-005:
35/// - FR-FS-001: Write to temporary file first, fsync, then atomically rename
36/// - FR-FS-002: Windows rename retry with bounded exponential backoff (≤ 250ms)
37/// - FR-FS-003: Track `rename_retry_count` in warnings
38/// - FR-FS-004: UTF-8 encoding with LF line endings
39/// - FR-FS-005: Cross-filesystem fallback (copy→fsync→replace)
40pub fn write_file_atomic(path: &Utf8Path, content: &str) -> Result<AtomicWriteResult> {
41    let mut result = AtomicWriteResult::default();
42
43    // Normalize line endings to LF (FR-FS-004)
44    let normalized_content = normalize_line_endings(content);
45
46    // Ensure parent directory exists
47    if let Some(parent) = path.parent() {
48        fs::create_dir_all(parent)
49            .with_context(|| format!("Failed to create parent directory: {parent}"))?;
50    }
51
52    // Create temporary file in the same directory as the target
53    let temp_dir = path.parent().unwrap_or_else(|| Utf8Path::new("."));
54    let mut temp_file = NamedTempFile::new_in(temp_dir)
55        .with_context(|| format!("Failed to create temporary file in: {temp_dir}"))?;
56
57    // Write content to temporary file
58    temp_file
59        .write_all(normalized_content.as_bytes())
60        .with_context(|| "Failed to write content to temporary file")?;
61
62    // Ensure data is written to disk (FR-FS-001)
63    temp_file
64        .as_file()
65        .sync_all()
66        .with_context(|| "Failed to fsync temporary file")?;
67
68    // Get the temp file path before attempting rename (for cross-filesystem fallback)
69    let temp_path = temp_file.path().to_path_buf();
70
71    // Attempt atomic rename with platform-specific retry logic
72    let rename_result = atomic_rename(temp_file, path.as_std_path());
73
74    match rename_result {
75        Ok(retry_count) => {
76            result.rename_retry_count = retry_count;
77            if retry_count > 0 {
78                result.warnings.push(format!(
79                    "Rename required {retry_count} retries due to transient filesystem locks"
80                ));
81            }
82        }
83        Err(e) if is_cross_filesystem_error(&e) => {
84            // FR-FS-005: Cross-filesystem fallback
85            result.used_cross_filesystem_fallback = true;
86            result
87                .warnings
88                .push("Used cross-filesystem fallback (copy→fsync→replace)".to_string());
89
90            // Fallback: copy→fsync→replace
91            cross_filesystem_copy_from_path(&temp_path, path)?;
92        }
93        Err(e) => {
94            return Err(e).with_context(|| format!("Failed to atomically write file: {path}"));
95        }
96    }
97
98    Ok(result)
99}
100
101/// Normalize line endings to LF (FR-FS-004)
102fn normalize_line_endings(content: &str) -> String {
103    content.replace("\r\n", "\n").replace('\r', "\n")
104}
105
106/// Attempt atomic rename with platform-specific retry logic
107///
108/// Returns the number of retries that were needed.
109/// On Windows, implements exponential backoff with ≤ 250ms total (FR-FS-002)
110#[cfg(target_os = "windows")]
111fn atomic_rename(mut temp_file: NamedTempFile, target: &Path) -> Result<u32> {
112    use std::io::ErrorKind;
113
114    const MAX_RETRIES: u32 = 5;
115    const INITIAL_DELAY_MS: u64 = 10;
116    const MAX_TOTAL_DELAY_MS: u64 = 250;
117
118    let mut retry_count = 0;
119    let mut total_delay_ms = 0;
120
121    loop {
122        // Try to persist
123        match temp_file.persist(target) {
124            Ok(_) => return Ok(retry_count),
125            Err(persist_error) => {
126                // Check if we should retry
127                if retry_count >= MAX_RETRIES {
128                    return Err(anyhow::anyhow!(persist_error.error));
129                }
130
131                // Check if this is a retryable error (permission denied, access denied)
132                let is_retryable = matches!(
133                    persist_error.error.kind(),
134                    ErrorKind::PermissionDenied | ErrorKind::Other
135                );
136
137                if !is_retryable {
138                    return Err(anyhow::anyhow!(persist_error.error));
139                }
140
141                // Calculate delay with exponential backoff
142                let delay_ms = INITIAL_DELAY_MS * 2_u64.pow(retry_count);
143
144                // Check if we would exceed total delay budget
145                if total_delay_ms + delay_ms > MAX_TOTAL_DELAY_MS {
146                    // Use remaining budget
147                    let remaining = MAX_TOTAL_DELAY_MS.saturating_sub(total_delay_ms);
148                    if remaining > 0 {
149                        thread::sleep(Duration::from_millis(remaining));
150                    }
151                    // One final attempt
152                    return persist_error
153                        .file
154                        .persist(target)
155                        .map(|_| retry_count + 1)
156                        .map_err(|e| anyhow::anyhow!(e.error));
157                }
158
159                // Sleep and retry
160                thread::sleep(Duration::from_millis(delay_ms));
161                total_delay_ms += delay_ms;
162                retry_count += 1;
163
164                // Get the temp file back for next iteration
165                temp_file = persist_error.file;
166            }
167        }
168    }
169}
170
171/// Attempt atomic rename (Unix: no retry needed)
172#[cfg(not(target_os = "windows"))]
173fn atomic_rename(temp_file: NamedTempFile, target: &Path) -> Result<u32> {
174    temp_file
175        .persist(target)
176        .map(|_| 0) // No retries on Unix
177        .map_err(|e| anyhow::anyhow!(e.error))
178}
179
180/// Check if an error indicates a cross-filesystem operation
181#[cfg(unix)]
182fn is_cross_filesystem_error(err: &anyhow::Error) -> bool {
183    use std::io::ErrorKind;
184
185    if let Some(io_error) = err.downcast_ref::<std::io::Error>() {
186        if io_error.kind() != ErrorKind::Other {
187            return false;
188        }
189        match io_error.raw_os_error() {
190            Some(code) => code == 18, // EXDEV on Linux/macOS
191            None => false,
192        }
193    } else {
194        false
195    }
196}
197
198/// Check if an error indicates a cross-filesystem operation
199#[cfg(windows)]
200fn is_cross_filesystem_error(_err: &anyhow::Error) -> bool {
201    false
202}
203
204/// Perform cross-filesystem copy: copy→fsync→replace (FR-FS-005)
205fn cross_filesystem_copy_from_path(temp_path: &Path, target: &Utf8Path) -> Result<()> {
206    // Read content from temp file
207    let content = fs::read(temp_path)
208        .with_context(|| "Failed to read temporary file for cross-filesystem copy")?;
209
210    // Create a new temp file in the target directory
211    let target_dir = target.parent().unwrap_or_else(|| Utf8Path::new("."));
212    let mut target_temp = NamedTempFile::new_in(target_dir)
213        .with_context(|| format!("Failed to create temp file in target directory: {target_dir}"))?;
214
215    // Write content
216    target_temp
217        .write_all(&content)
218        .with_context(|| "Failed to write content during cross-filesystem copy")?;
219
220    // Fsync
221    target_temp
222        .as_file()
223        .sync_all()
224        .with_context(|| "Failed to fsync during cross-filesystem copy")?;
225
226    // Atomic rename (should succeed since we're on the same filesystem now)
227    target_temp
228        .persist(target.as_std_path())
229        .map_err(|e| anyhow::anyhow!(e.error))
230        .with_context(|| "Failed to persist during cross-filesystem copy")?;
231
232    // Clean up original temp file
233    let _ = fs::remove_file(temp_path);
234
235    Ok(())
236}
237
238/// Read a file with CRLF tolerance (FR-FS-005)
239///
240/// This reads a file and normalizes line endings to LF, making it tolerant
241/// of CRLF line endings on Windows.
242#[allow(dead_code)] // Test utility for cross-platform testing
243pub fn read_file_with_crlf_tolerance(path: &Utf8Path) -> Result<String> {
244    let content = fs::read_to_string(path.as_std_path())
245        .with_context(|| format!("Failed to read file: {path}"))?;
246
247    Ok(normalize_line_endings(&content))
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use tempfile::TempDir;
254
255    fn create_temp_dir() -> TempDir {
256        TempDir::new().unwrap()
257    }
258
259    #[test]
260    fn test_normalize_line_endings() {
261        assert_eq!(
262            normalize_line_endings("line1\r\nline2\r\nline3"),
263            "line1\nline2\nline3"
264        );
265        assert_eq!(
266            normalize_line_endings("line1\rline2\rline3"),
267            "line1\nline2\nline3"
268        );
269        assert_eq!(
270            normalize_line_endings("line1\nline2\nline3"),
271            "line1\nline2\nline3"
272        );
273        assert_eq!(
274            normalize_line_endings("mixed\r\nline\nending\r"),
275            "mixed\nline\nending\n"
276        );
277    }
278
279    #[test]
280    fn test_atomic_write_basic() {
281        let temp_dir = create_temp_dir();
282        let path_buf = temp_dir.path().join("test.txt");
283        let file_path = Utf8Path::from_path(path_buf.as_path()).unwrap();
284
285        let content = "test content\nwith multiple lines";
286        let result = write_file_atomic(file_path, content);
287
288        assert!(result.is_ok());
289        let write_result = result.unwrap();
290        assert_eq!(write_result.rename_retry_count, 0);
291        assert!(!write_result.used_cross_filesystem_fallback);
292        assert!(write_result.warnings.is_empty());
293
294        // Verify file exists and has correct content
295        assert!(file_path.exists());
296        let read_content = fs::read_to_string(file_path.as_std_path()).unwrap();
297        assert_eq!(read_content, content);
298    }
299
300    #[test]
301    fn test_atomic_write_normalizes_line_endings() {
302        let temp_dir = create_temp_dir();
303        let path_buf = temp_dir.path().join("test_crlf.txt");
304        let file_path = Utf8Path::from_path(path_buf.as_path()).unwrap();
305
306        let content_with_crlf = "line1\r\nline2\r\nline3";
307        let result = write_file_atomic(file_path, content_with_crlf);
308
309        assert!(result.is_ok());
310
311        // Verify content has LF line endings
312        let read_content = fs::read_to_string(file_path.as_std_path()).unwrap();
313        assert_eq!(read_content, "line1\nline2\nline3");
314        assert!(!read_content.contains("\r\n"));
315    }
316
317    #[test]
318    fn test_atomic_write_creates_parent_directory() {
319        let temp_dir = create_temp_dir();
320        let path_buf = temp_dir.path().join("nested").join("dir").join("test.txt");
321        let nested_path = Utf8Path::from_path(path_buf.as_path()).unwrap();
322
323        let content = "test content";
324        let result = write_file_atomic(nested_path, content);
325
326        assert!(result.is_ok());
327        assert!(nested_path.exists());
328
329        let read_content = fs::read_to_string(nested_path.as_std_path()).unwrap();
330        assert_eq!(read_content, content);
331    }
332
333    #[test]
334    fn test_atomic_write_overwrites_existing() {
335        let temp_dir = create_temp_dir();
336        let path_buf = temp_dir.path().join("overwrite.txt");
337        let file_path = Utf8Path::from_path(path_buf.as_path()).unwrap();
338
339        // Write initial content
340        let initial_content = "initial content";
341        write_file_atomic(file_path, initial_content).unwrap();
342
343        // Overwrite with new content
344        let new_content = "new content";
345        let result = write_file_atomic(file_path, new_content);
346
347        assert!(result.is_ok());
348
349        // Verify new content
350        let read_content = fs::read_to_string(file_path.as_std_path()).unwrap();
351        assert_eq!(read_content, new_content);
352    }
353
354    #[test]
355    fn test_read_file_with_crlf_tolerance() {
356        let temp_dir = create_temp_dir();
357        let path_buf = temp_dir.path().join("crlf_test.txt");
358        let file_path = Utf8Path::from_path(path_buf.as_path()).unwrap();
359
360        // Write file with CRLF line endings directly (bypassing our atomic write)
361        let content_with_crlf = b"line1\r\nline2\r\nline3";
362        fs::write(file_path.as_std_path(), content_with_crlf).unwrap();
363
364        // Read with CRLF tolerance
365        let result = read_file_with_crlf_tolerance(file_path);
366
367        assert!(result.is_ok());
368        let content = result.unwrap();
369        assert_eq!(content, "line1\nline2\nline3");
370        assert!(!content.contains('\r'));
371    }
372
373    #[test]
374    fn test_atomic_write_empty_content() {
375        let temp_dir = create_temp_dir();
376        let path_buf = temp_dir.path().join("empty.txt");
377        let file_path = Utf8Path::from_path(path_buf.as_path()).unwrap();
378
379        let result = write_file_atomic(file_path, "");
380
381        assert!(result.is_ok());
382        assert!(file_path.exists());
383
384        let read_content = fs::read_to_string(file_path.as_std_path()).unwrap();
385        assert_eq!(read_content, "");
386    }
387
388    #[test]
389    fn test_atomic_write_large_content() {
390        let temp_dir = create_temp_dir();
391        let path_buf = temp_dir.path().join("large.txt");
392        let file_path = Utf8Path::from_path(path_buf.as_path()).unwrap();
393
394        // Create large content (1 MB)
395        let large_content = "x".repeat(1024 * 1024);
396        let result = write_file_atomic(file_path, &large_content);
397
398        assert!(result.is_ok());
399        assert!(file_path.exists());
400
401        let read_content = fs::read_to_string(file_path.as_std_path()).unwrap();
402        assert_eq!(read_content.len(), large_content.len());
403    }
404
405    #[test]
406    fn test_atomic_write_unicode_content() {
407        let temp_dir = create_temp_dir();
408        let path_buf = temp_dir.path().join("unicode.txt");
409        let file_path = Utf8Path::from_path(path_buf.as_path()).unwrap();
410
411        let unicode_content = "Hello 世界 🌍 Привет مرحبا";
412        let result = write_file_atomic(file_path, unicode_content);
413
414        assert!(result.is_ok());
415
416        let read_content = fs::read_to_string(file_path.as_std_path()).unwrap();
417        assert_eq!(read_content, unicode_content);
418    }
419
420    #[test]
421    fn test_atomic_write_special_characters() {
422        let temp_dir = create_temp_dir();
423        let path_buf = temp_dir.path().join("special.txt");
424        let file_path = Utf8Path::from_path(path_buf.as_path()).unwrap();
425
426        let special_content = "Special chars: \t\n\"'`$\\{}[]()";
427        let result = write_file_atomic(file_path, special_content);
428
429        assert!(result.is_ok());
430
431        let read_content = fs::read_to_string(file_path.as_std_path()).unwrap();
432        // Note: \n will be preserved, but \r\n would be normalized
433        assert!(read_content.contains("Special chars:"));
434    }
435}