//! Durable atomic write — power-safe #5 (a-base §3): //! `write-temp → fsync(file) → rename → fsync(dir)`. //! //! Гарантия: после успеха `path` содержит новые данные целиком; при сбое до `rename` //! сохраняется прежняя версия (или отсутствие файла) — никогда не частично записанный файл. //! Торн-райт-безопасность опирается на атомарность POSIX `rename` в пределах одной ФС + //! упорядочивание `fsync`; полная проверка при резком обесточивании — E2E v0.3 (spec §3/§9.1). //! Расчёт на единственного писателя поддерева (Settings/State, architecture §3) — фикс. имя `.tmp`. use std::fs::{self, File}; use std::io::{self, Write}; use std::path::{Path, PathBuf}; /// Путь временного файла рядом с целевым (`settings.json` → `settings.json.tmp`). pub fn tmp_path(path: &Path) -> PathBuf { let name = path .file_name() .map(|n| format!("{}.tmp", n.to_string_lossy())) .unwrap_or_else(|| "shturman.tmp".to_string()); path.with_file_name(name) } /// Атомарно и durable записать `contents` в `path`. pub fn write_atomic(path: &Path, contents: &[u8]) -> io::Result<()> { let dir = path.parent().ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidInput, "путь без родительского каталога", ) })?; let tmp = tmp_path(path); { let mut f = File::create(&tmp)?; f.write_all(contents)?; f.sync_all()?; // fsync(file) } fs::rename(&tmp, path)?; // atomic в пределах одной ФС // fsync(dir) делает сам rename durable let dir_f = File::open(dir)?; dir_f.sync_all()?; Ok(()) } #[cfg(test)] mod tests { use super::*; use std::fs; #[test] fn persists_full_contents() { let d = tempfile::tempdir().unwrap(); let p = d.path().join("settings.json"); write_atomic(&p, b"{\"v\":1}").unwrap(); assert_eq!(fs::read(&p).unwrap(), b"{\"v\":1}"); } #[test] fn overwrites_and_leaves_no_tmp() { let d = tempfile::tempdir().unwrap(); let p = d.path().join("settings.json"); write_atomic(&p, b"old").unwrap(); write_atomic(&p, b"new").unwrap(); assert_eq!(fs::read(&p).unwrap(), b"new"); assert!( !tmp_path(&p).exists(), "осиротевший .tmp не должен оставаться" ); } // Целостность основного файла не зависит от постороннего .tmp (rename атомарен на одной ФС). // Полная гарантия при power-cut в момент rename — POSIX-свойство rename + E2E v0.3 (spec §3/§9.1). #[test] fn orphan_tmp_does_not_corrupt_main() { let d = tempfile::tempdir().unwrap(); let p = d.path().join("settings.json"); write_atomic(&p, b"v1").unwrap(); fs::write(tmp_path(&p), b"v2-incomplete").unwrap(); // как будто краш до rename assert_eq!(fs::read(&p).unwrap(), b"v1"); // следующая корректная запись перетирает и подчищает tmp write_atomic(&p, b"v2").unwrap(); assert_eq!(fs::read(&p).unwrap(), b"v2"); assert!(!tmp_path(&p).exists()); } }