atomic_file.rs 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. use std::fs::{self, OpenOptions};
  2. use std::io::Write;
  3. use std::path::Path;
  4. use std::time::{SystemTime, UNIX_EPOCH};
  5. /// Atomically replace `destination` with a sibling temporary file.
  6. #[cfg(windows)]
  7. pub fn replace_file_atomically(temp: &Path, destination: &Path) -> Result<(), String> {
  8. use std::os::windows::ffi::OsStrExt;
  9. #[link(name = "Kernel32")]
  10. extern "system" {
  11. fn MoveFileExW(existing: *const u16, new: *const u16, flags: u32) -> i32;
  12. }
  13. const MOVEFILE_REPLACE_EXISTING: u32 = 0x1;
  14. const MOVEFILE_WRITE_THROUGH: u32 = 0x8;
  15. let existing: Vec<u16> = temp.as_os_str().encode_wide().chain(Some(0)).collect();
  16. let new: Vec<u16> = destination.as_os_str().encode_wide().chain(Some(0)).collect();
  17. let result = unsafe {
  18. MoveFileExW(
  19. existing.as_ptr(),
  20. new.as_ptr(),
  21. MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
  22. )
  23. };
  24. if result == 0 {
  25. Err(format!(
  26. "原子替换文件失败: {}",
  27. std::io::Error::last_os_error()
  28. ))
  29. } else {
  30. Ok(())
  31. }
  32. }
  33. #[cfg(not(windows))]
  34. pub fn replace_file_atomically(temp: &Path, destination: &Path) -> Result<(), String> {
  35. fs::rename(temp, destination).map_err(|e| format!("原子替换文件失败: {e}"))
  36. }
  37. pub fn write_bytes_atomically(path: &Path, bytes: &[u8]) -> Result<(), String> {
  38. write_bytes_atomically_with_replace(path, bytes, replace_file_atomically)
  39. }
  40. pub fn write_bytes_atomically_with_replace<F>(
  41. path: &Path,
  42. bytes: &[u8],
  43. replace: F,
  44. ) -> Result<(), String>
  45. where
  46. F: FnOnce(&Path, &Path) -> Result<(), String>,
  47. {
  48. let parent = path
  49. .parent()
  50. .ok_or_else(|| "路径缺少父目录".to_string())?;
  51. fs::create_dir_all(parent).map_err(|e| format!("创建目录失败 '{}': {e}", parent.display()))?;
  52. let file_name = path
  53. .file_name()
  54. .map(|name| name.to_string_lossy().to_string())
  55. .unwrap_or_else(|| "app-state.json".to_string());
  56. let stamp = SystemTime::now()
  57. .duration_since(UNIX_EPOCH)
  58. .map(|duration| duration.as_nanos())
  59. .unwrap_or(0);
  60. let temp = parent.join(format!(".{file_name}.{stamp}.tmp"));
  61. let write_result = (|| {
  62. let mut file = OpenOptions::new()
  63. .write(true)
  64. .create_new(true)
  65. .open(&temp)
  66. .map_err(|e| format!("创建临时文件失败 '{}': {e}", temp.display()))?;
  67. file.write_all(bytes)
  68. .map_err(|e| format!("写入临时文件失败 '{}': {e}", temp.display()))?;
  69. file.sync_all()
  70. .map_err(|e| format!("同步临时文件失败 '{}': {e}", temp.display()))?;
  71. drop(file);
  72. replace(&temp, path)
  73. })();
  74. if write_result.is_err() {
  75. let _ = fs::remove_file(&temp);
  76. }
  77. write_result
  78. }
  79. #[cfg(test)]
  80. mod tests {
  81. use super::*;
  82. use std::path::PathBuf;
  83. fn unique_test_dir(name: &str) -> PathBuf {
  84. let dir = std::env::temp_dir().join(format!(
  85. "qmai_atomic_file_{name}_{}",
  86. uuid::Uuid::new_v4()
  87. ));
  88. fs::create_dir_all(&dir).unwrap();
  89. dir
  90. }
  91. #[test]
  92. fn failed_replace_leaves_the_original_file() {
  93. let dir = unique_test_dir("replace_fail");
  94. let path = dir.join("app-state.json");
  95. fs::write(&path, r#"{"llmConfig":{"model":"keep"}}"#).unwrap();
  96. let result = write_bytes_atomically_with_replace(
  97. &path,
  98. br#"{"llmConfig":{"model":"new"}}"#,
  99. |_temp, _dest| Err("injected replace failure".to_string()),
  100. );
  101. assert!(result.is_err());
  102. assert_eq!(
  103. fs::read_to_string(&path).unwrap(),
  104. r#"{"llmConfig":{"model":"keep"}}"#
  105. );
  106. let leftovers: Vec<_> = fs::read_dir(&dir)
  107. .unwrap()
  108. .filter_map(|entry| entry.ok())
  109. .map(|entry| entry.file_name().to_string_lossy().into_owned())
  110. .filter(|name| name.ends_with(".tmp"))
  111. .collect();
  112. assert!(leftovers.is_empty(), "temp files leftover: {leftovers:?}");
  113. let _ = fs::remove_dir_all(&dir);
  114. }
  115. #[test]
  116. fn atomic_write_replaces_existing_content() {
  117. let dir = unique_test_dir("replace_ok");
  118. let path = dir.join("app-state.json");
  119. fs::write(&path, "{}").unwrap();
  120. write_bytes_atomically(&path, br#"{"ok":true}"#).unwrap();
  121. assert_eq!(fs::read_to_string(&path).unwrap(), r#"{"ok":true}"#);
  122. let _ = fs::remove_dir_all(&dir);
  123. }
  124. }