diff --git a/Cargo.lock b/Cargo.lock index 83cb45c..fd504b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4752,6 +4752,7 @@ dependencies = [ "tracing", "url", "uuid", + "zesdex-utils", ] [[package]] diff --git a/crates/zesdex-cms/src/infrastructure/persistence/app_config_repo.rs b/crates/zesdex-cms/src/infrastructure/persistence/app_config_repo.rs index c92f31f..1be667e 100644 --- a/crates/zesdex-cms/src/infrastructure/persistence/app_config_repo.rs +++ b/crates/zesdex-cms/src/infrastructure/persistence/app_config_repo.rs @@ -12,11 +12,11 @@ clippy::cast_possible_wrap )] -use std::io::Write; use std::path::Path; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; +use zesdex_utils::write_json_atomic; use crate::domain::app_config::{AppConfig, ModelRole, ProviderConfig}; use crate::domain::repository::AppConfigRepository; @@ -134,31 +134,8 @@ impl AppConfigRepository for JsonAppConfigRepository { std::fs::create_dir_all(base_dir) .with_context(|| format!("failed to create base dir '{}'", base_dir.display()))?; let path = base_dir.join("app_config.json"); - let tmp = base_dir.join("app_config.json.tmp"); - let json = - serde_json::to_string_pretty(config).context("failed to serialize app config")?; - { - let mut f = std::fs::OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .open(&tmp) - .with_context(|| format!("failed to write temp file '{}'", tmp.display()))?; - f.write_all(json.as_bytes())?; - f.sync_all()?; - } - std::fs::rename(&tmp, &path).with_context(|| { - format!( - "failed to rename '{}' -> '{}'", - tmp.display(), - path.display() - ) - })?; - if let Some(parent) = path.parent() { - if let Ok(d) = std::fs::File::open(parent) { - let _ = d.sync_all(); - } - } + write_json_atomic(&path, config, None) + .with_context(|| "failed to save app_config")?; tracing::debug!("app_config saved to '{}'", path.display()); Ok(()) } diff --git a/crates/zesdex-cms/src/infrastructure/persistence/conversation_repo.rs b/crates/zesdex-cms/src/infrastructure/persistence/conversation_repo.rs index 82f5d7b..efec85e 100644 --- a/crates/zesdex-cms/src/infrastructure/persistence/conversation_repo.rs +++ b/crates/zesdex-cms/src/infrastructure/persistence/conversation_repo.rs @@ -11,10 +11,10 @@ clippy::cast_possible_wrap )] -use std::io::Write; use std::path::Path; use anyhow::{Context, Result}; +use zesdex_utils::write_json_atomic; use crate::domain::conversation::Conversation; use crate::domain::repository::ConversationRepository; @@ -44,31 +44,8 @@ impl ConversationRepository for JsonConversationRepository { std::fs::create_dir_all(session_dir) .with_context(|| format!("failed to create session dir '{}'", session_dir.display()))?; let path = session_dir.join("conversation.json"); - let tmp = session_dir.join("conversation.json.tmp"); - let json = serde_json::to_string_pretty(conversation) - .context("failed to serialize conversation")?; - { - let mut f = std::fs::OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .open(&tmp) - .with_context(|| format!("failed to write temp file '{}'", tmp.display()))?; - f.write_all(json.as_bytes())?; - f.sync_all()?; - } - std::fs::rename(&tmp, &path).with_context(|| { - format!( - "failed to rename '{}' -> '{}'", - tmp.display(), - path.display() - ) - })?; - if let Some(parent) = path.parent() { - if let Ok(d) = std::fs::File::open(parent) { - let _ = d.sync_all(); - } - } + write_json_atomic(&path, conversation, None) + .with_context(|| "failed to save conversation")?; tracing::debug!("conversation saved to '{}'", path.display()); Ok(()) } diff --git a/crates/zesdex-cms/src/infrastructure/persistence/settings_repo.rs b/crates/zesdex-cms/src/infrastructure/persistence/settings_repo.rs index c6ed19f..40d8477 100644 --- a/crates/zesdex-cms/src/infrastructure/persistence/settings_repo.rs +++ b/crates/zesdex-cms/src/infrastructure/persistence/settings_repo.rs @@ -11,10 +11,10 @@ clippy::cast_possible_wrap )] -use std::io::Write; use std::path::Path; use anyhow::{Context, Result}; +use zesdex_utils::write_json_atomic; use crate::domain::repository::SettingsRepository; use crate::domain::settings::Settings; @@ -56,31 +56,8 @@ impl SettingsRepository for JsonSettingsRepository { std::fs::create_dir_all(base_dir) .with_context(|| format!("failed to create base dir '{}'", base_dir.display()))?; let path = base_dir.join("settings.json"); - let tmp = base_dir.join("settings.json.tmp"); - let json = - serde_json::to_string_pretty(settings).context("failed to serialize settings")?; - { - let mut f = std::fs::OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .open(&tmp) - .with_context(|| format!("failed to write temp file '{}'", tmp.display()))?; - f.write_all(json.as_bytes())?; - f.sync_all()?; - } - std::fs::rename(&tmp, &path).with_context(|| { - format!( - "failed to rename '{}' -> '{}'", - tmp.display(), - path.display() - ) - })?; - if let Some(parent) = path.parent() { - if let Ok(d) = std::fs::File::open(parent) { - let _ = d.sync_all(); - } - } + write_json_atomic(&path, settings, None) + .with_context(|| "failed to save settings")?; tracing::debug!("settings saved to '{}'", path.display()); Ok(()) } diff --git a/crates/zesdex-entities/Cargo.toml b/crates/zesdex-entities/Cargo.toml index 044959a..58773f0 100644 --- a/crates/zesdex-entities/Cargo.toml +++ b/crates/zesdex-entities/Cargo.toml @@ -18,3 +18,4 @@ url.workspace = true reqwest.workspace = true tokio.workspace = true tracing.workspace = true +zesdex-utils.workspace = true diff --git a/crates/zesdex-entities/src/domain/auth/session.rs b/crates/zesdex-entities/src/domain/auth/session.rs index 904d7d5..f9d54cd 100644 --- a/crates/zesdex-entities/src/domain/auth/session.rs +++ b/crates/zesdex-entities/src/domain/auth/session.rs @@ -58,26 +58,19 @@ impl Session { /// Persist this session's metadata to `session.json`, atomically /// with fsync for crash safety. /// - /// Flow: ensure the session directory exists → serialize to pretty - /// JSON → write to `session.json.tmp` → fsync → rename over - /// `session.json` → fsync parent directory. + /// Flow: ensure the session directory exists → atomically write + /// pretty-printed JSON via `write_json_atomic`. /// /// Why: write-then-rename avoids a torn/partial `session.json` if /// interrupted mid-write; fsync before rename ensures the data is /// on disk before the rename makes it visible. /// - /// Return: `Ok(())` on success, or an `io::Error` from any step. - pub fn save(&self, base_dir: &Path) -> std::io::Result<()> { + /// Return: `Ok(())` on success, or an `anyhow::Error` from any step. + pub fn save(&self, base_dir: &Path) -> anyhow::Result<()> { let dir = self.session_dir(base_dir); std::fs::create_dir_all(&dir)?; let path = dir.join("session.json"); - let data = serde_json::to_string_pretty(self)?; - let tmp = dir.join("session.json.tmp"); - std::fs::write(&tmp, data)?; - let f = std::fs::File::open(&tmp)?; - f.sync_all()?; - std::fs::rename(&tmp, path)?; - let _ = std::fs::File::open(&dir).and_then(|d| d.sync_all()); + zesdex_utils::write_json_atomic(&path, self, None)?; Ok(()) } diff --git a/crates/zesdex-entities/src/domain/common/conversation.rs b/crates/zesdex-entities/src/domain/common/conversation.rs index 5733dc9..fbdb44d 100644 --- a/crates/zesdex-entities/src/domain/common/conversation.rs +++ b/crates/zesdex-entities/src/domain/common/conversation.rs @@ -77,20 +77,14 @@ impl Conversation { /// Persist the conversation to a JSON file at the given base directory. /// /// Flow: compute path from `session_id` → ensure directory exists → - /// serialize to pretty JSON → write-then-rename with fsync. + /// atomically write pretty-printed JSON via `write_json_atomic`. /// - /// Return: `Ok(())` on success, or an `io::Error` from any step. - pub fn save_conversation(&self, base_dir: &std::path::Path) -> std::io::Result<()> { + /// Return: `Ok(())` on success, or an `anyhow::Error` from any step. + pub fn save_conversation(&self, base_dir: &std::path::Path) -> anyhow::Result<()> { let dir = base_dir.join("sessions").join(&self.session_id); std::fs::create_dir_all(&dir)?; let path = dir.join("conversation.json"); - let data = serde_json::to_string_pretty(self)?; - let tmp = dir.join("conversation.json.tmp"); - std::fs::write(&tmp, data)?; - let f = std::fs::File::open(&tmp)?; - f.sync_all()?; - std::fs::rename(&tmp, path)?; - let _ = std::fs::File::open(&dir).and_then(|d| d.sync_all()); + zesdex_utils::write_json_atomic(&path, self, None)?; Ok(()) } diff --git a/crates/zesdex-iam/src/infrastructure/persistence/oauth_repo.rs b/crates/zesdex-iam/src/infrastructure/persistence/oauth_repo.rs index bf09dcf..565417f 100644 --- a/crates/zesdex-iam/src/infrastructure/persistence/oauth_repo.rs +++ b/crates/zesdex-iam/src/infrastructure/persistence/oauth_repo.rs @@ -11,6 +11,8 @@ //! 0o600 on Unix. use std::path::Path; +use zesdex_utils::write_json_atomic; + use crate::domain::oauth::OAuthToken; use crate::domain::repository::OAuthRepository; @@ -30,20 +32,7 @@ impl OAuthRepository for FileSystemOAuthRepository { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - let data = serde_json::to_string_pretty(token)?; - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, data)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?; - } - let f = std::fs::File::open(&tmp)?; - f.sync_all()?; - std::fs::rename(&tmp, path)?; - if let Some(parent) = path.parent() { - let _ = std::fs::File::open(parent).and_then(|d| d.sync_all()); - } + write_json_atomic(path, token, Some(0o600))?; Ok(()) } diff --git a/crates/zesdex-iam/src/infrastructure/persistence/session_repo.rs b/crates/zesdex-iam/src/infrastructure/persistence/session_repo.rs index 3352ed7..f1e98bf 100644 --- a/crates/zesdex-iam/src/infrastructure/persistence/session_repo.rs +++ b/crates/zesdex-iam/src/infrastructure/persistence/session_repo.rs @@ -10,6 +10,8 @@ //! Writes use a write-then-rename + fsync pattern for crash safety. use std::path::Path; +use zesdex_utils::write_json_atomic; + use crate::domain::repository::SessionRepository; use crate::domain::session::Session; @@ -61,17 +63,7 @@ impl SessionRepository for FileSystemSessionRepository { let dir = session.session_dir(base_dir); std::fs::create_dir_all(&dir)?; let path = dir.join("session.json"); - let data = serde_json::to_string_pretty(session)?; - let tmp = dir.join("session.json.tmp"); - std::fs::write(&tmp, data)?; - // fsync before rename ensures the data is on disk. - let f = std::fs::File::open(&tmp)?; - f.sync_all()?; - std::fs::rename(&tmp, path)?; - // fsync the parent directory so the rename survives a crash. - if let Some(parent) = dir.parent() { - let _ = std::fs::File::open(parent).and_then(|d| d.sync_all()); - } + write_json_atomic(&path, session, None)?; Ok(()) } diff --git a/crates/zesdex-utils/src/atomic_write.rs b/crates/zesdex-utils/src/atomic_write.rs new file mode 100644 index 0000000..4b0f582 --- /dev/null +++ b/crates/zesdex-utils/src/atomic_write.rs @@ -0,0 +1,46 @@ +//! Crash-safe atomic file write helper. +//! +//! Writes serializable data to a temp file, fsyncs, then renames into +//! place to guarantee atomicity. On Unix, an optional `mode` sets the +//! permissions of the final file (e.g. `0o600` for OAuth tokens). + +use std::io::Write; +use std::path::Path; + +use serde::Serialize; + +/// Atomically write serializable `data` to `path`. +/// +/// Flow: serialize to pretty JSON -> write to `path.tmp` -> fsync -> rename -> fsync parent. +/// If `mode` is `Some`, set permissions before rename (Unix only). +/// +/// Edge case: tmp file name uses `with_extension("tmp")` which replaces +/// the existing extension -- correct for `foo.json` -> `foo.tmp`. For paths +/// without an extension (unlikely in this codebase), appends `.tmp`. +pub fn write_json_atomic(path: &Path, data: &T, mode: Option) -> anyhow::Result<()> { + let tmp = path.with_extension("tmp"); + let bytes = serde_json::to_vec_pretty(data)?; + { + let mut f = std::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&tmp)?; + f.write_all(&bytes)?; + f.sync_all()?; + } + if let Some(m) = mode { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(m))?; + } + #[cfg(not(unix))] + { let _ = m; } + } + std::fs::rename(&tmp, path)?; + if let Some(parent) = path.parent() { + let _ = std::fs::File::open(parent).and_then(|d| d.sync_all()); + } + Ok(()) +} diff --git a/crates/zesdex-utils/src/lib.rs b/crates/zesdex-utils/src/lib.rs index 7fd7c33..a4dcdbf 100644 --- a/crates/zesdex-utils/src/lib.rs +++ b/crates/zesdex-utils/src/lib.rs @@ -1,3 +1,5 @@ +pub mod atomic_write; +pub use atomic_write::write_json_atomic; pub mod clipboard; pub mod error; pub mod logger;