use anyhow::{Context, Result};
use std::path::Path;
use tokio::fs;
use tokio::io::AsyncWriteExt;
use tracing::debug;
pub async fn atomic_write(path: &Path, content: &[u8]) -> Result<()> {
let dir = path.parent().unwrap_or_else(|| Path::new("."));
let file_name = path.file_name().unwrap_or_default();
let tmp_name = format!(
".{}.tmp.{}",
file_name.to_string_lossy(),
uuid::Uuid::new_v4()
);
let tmp_path = dir.join(&tmp_name);
debug!(tmp = %tmp_path.display(), target = %path.display(), "Atomic write start");
let mut options = fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
set_private_create_mode(&mut options);
let mut file = options
.open(&tmp_path)
.await
.with_context(|| format!("Failed to create temp file: {}", tmp_path.display()))?;
set_private_file_permissions(&file, &tmp_path).await?;
file.write_all(content).await?;
file.flush().await?;
let std_file = file.into_std().await;
std_file.sync_data()?;
drop(std_file);
fs::rename(&tmp_path, path).await.with_context(|| {
format!(
"Failed to rename {} to {}",
tmp_path.display(),
path.display()
)
})?;
debug!(target = %path.display(), "Atomic write complete");
Ok(())
}
pub async fn atomic_append(path: &Path, content: &[u8]) -> Result<()> {
let mut options = fs::OpenOptions::new();
options.create(true).append(true);
set_private_create_mode(&mut options);
let mut file = options
.open(path)
.await
.with_context(|| format!("Failed to open file for append: {}", path.display()))?;
set_private_file_permissions(&file, path).await?;
file.write_all(content).await?;
file.flush().await?;
Ok(())
}
#[cfg(unix)]
fn set_private_create_mode(options: &mut fs::OpenOptions) {
options.mode(0o600);
}
#[cfg(not(unix))]
fn set_private_create_mode(_options: &mut fs::OpenOptions) {}
#[cfg(unix)]
async fn set_private_file_permissions(file: &fs::File, path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
file.set_permissions(std::fs::Permissions::from_mode(0o600))
.await
.with_context(|| format!("Failed to harden file permissions: {}", path.display()))?;
Ok(())
}
#[cfg(not(unix))]
async fn set_private_file_permissions(_file: &fs::File, _path: &Path) -> Result<()> {
Ok(())
}
#[deprecated(
since = "0.3.31",
note = "Race-prone read-modify-write; prefer `atomic_append` which uses O_APPEND."
)]
pub async fn atomic_append_jsonl(path: &Path, line: &str) -> Result<()> {
let mut content = Vec::new();
if path.exists() {
content = fs::read(path).await.unwrap_or_default();
}
if !content.is_empty() && !content.ends_with(b"\n") {
content.push(b'\n');
}
content.extend_from_slice(line.as_bytes());
content.push(b'\n');
atomic_write(path, &content).await
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_atomic_write_roundtrip() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.json");
atomic_write(&path, b"hello world").await.unwrap();
let content = fs::read_to_string(&path).await.unwrap();
assert_eq!(content, "hello world");
}
#[tokio::test]
#[allow(deprecated)]
async fn test_atomic_append_jsonl() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.jsonl");
atomic_append_jsonl(&path, r#"{"id":"1"}"#).await.unwrap();
atomic_append_jsonl(&path, r#"{"id":"2"}"#).await.unwrap();
let content = fs::read_to_string(&path).await.unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0], r#"{"id":"1"}"#);
assert_eq!(lines[1], r#"{"id":"2"}"#);
}
}