xchecker_utils/
atomic_write.rs1use 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#[derive(Debug, Clone, Default)]
23pub struct AtomicWriteResult {
24 pub rename_retry_count: u32,
26 pub used_cross_filesystem_fallback: bool,
28 pub warnings: Vec<String>,
30}
31
32pub fn write_file_atomic(path: &Utf8Path, content: &str) -> Result<AtomicWriteResult> {
41 let mut result = AtomicWriteResult::default();
42
43 let normalized_content = normalize_line_endings(content);
45
46 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 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 temp_file
59 .write_all(normalized_content.as_bytes())
60 .with_context(|| "Failed to write content to temporary file")?;
61
62 temp_file
64 .as_file()
65 .sync_all()
66 .with_context(|| "Failed to fsync temporary file")?;
67
68 let temp_path = temp_file.path().to_path_buf();
70
71 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 result.used_cross_filesystem_fallback = true;
86 result
87 .warnings
88 .push("Used cross-filesystem fallback (copy→fsync→replace)".to_string());
89
90 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
101fn normalize_line_endings(content: &str) -> String {
103 content.replace("\r\n", "\n").replace('\r', "\n")
104}
105
106#[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 match temp_file.persist(target) {
124 Ok(_) => return Ok(retry_count),
125 Err(persist_error) => {
126 if retry_count >= MAX_RETRIES {
128 return Err(anyhow::anyhow!(persist_error.error));
129 }
130
131 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 let delay_ms = INITIAL_DELAY_MS * 2_u64.pow(retry_count);
143
144 if total_delay_ms + delay_ms > MAX_TOTAL_DELAY_MS {
146 let remaining = MAX_TOTAL_DELAY_MS.saturating_sub(total_delay_ms);
148 if remaining > 0 {
149 thread::sleep(Duration::from_millis(remaining));
150 }
151 return persist_error
153 .file
154 .persist(target)
155 .map(|_| retry_count + 1)
156 .map_err(|e| anyhow::anyhow!(e.error));
157 }
158
159 thread::sleep(Duration::from_millis(delay_ms));
161 total_delay_ms += delay_ms;
162 retry_count += 1;
163
164 temp_file = persist_error.file;
166 }
167 }
168 }
169}
170
171#[cfg(not(target_os = "windows"))]
173fn atomic_rename(temp_file: NamedTempFile, target: &Path) -> Result<u32> {
174 temp_file
175 .persist(target)
176 .map(|_| 0) .map_err(|e| anyhow::anyhow!(e.error))
178}
179
180#[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, None => false,
192 }
193 } else {
194 false
195 }
196}
197
198#[cfg(windows)]
200fn is_cross_filesystem_error(_err: &anyhow::Error) -> bool {
201 false
202}
203
204fn cross_filesystem_copy_from_path(temp_path: &Path, target: &Utf8Path) -> Result<()> {
206 let content = fs::read(temp_path)
208 .with_context(|| "Failed to read temporary file for cross-filesystem copy")?;
209
210 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 target_temp
217 .write_all(&content)
218 .with_context(|| "Failed to write content during cross-filesystem copy")?;
219
220 target_temp
222 .as_file()
223 .sync_all()
224 .with_context(|| "Failed to fsync during cross-filesystem copy")?;
225
226 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 let _ = fs::remove_file(temp_path);
234
235 Ok(())
236}
237
238#[allow(dead_code)] pub 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 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 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 let initial_content = "initial content";
341 write_file_atomic(file_path, initial_content).unwrap();
342
343 let new_content = "new content";
345 let result = write_file_atomic(file_path, new_content);
346
347 assert!(result.is_ok());
348
349 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 let content_with_crlf = b"line1\r\nline2\r\nline3";
362 fs::write(file_path.as_std_path(), content_with_crlf).unwrap();
363
364 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 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 assert!(read_content.contains("Special chars:"));
434 }
435}