use std::path::{Path, PathBuf};
use async_tempfile::TempFile;
use tokio::io::AsyncWriteExt as _;
use super::error::{IoErrorWithPath, IoOperation, WriteError};
pub async fn atomic_write(
path: impl AsRef<Path> + Send + Sync,
bytes: &[u8],
) -> Result<(), WriteError> {
let dest = canonical_destination(path.as_ref()).await?;
let dest_dir = dest.parent().unwrap_or_else(|| Path::new("."));
let mut temp_file = TempFile::new_in(dest_dir).await?;
#[cfg(feature = "tracing")]
tracing::debug!("staging temporary file at {:?}", temp_file.file_path());
if let Some(permissions) = permissions_for(&dest).await? {
temp_file
.set_permissions(permissions)
.await
.map_err(|error| IoErrorWithPath {
error,
path: temp_file.file_path().clone(),
operation: IoOperation::SetPermissions,
})?;
}
temp_file
.write_all(bytes)
.await
.map_err(|error| IoErrorWithPath {
error,
path: temp_file.file_path().clone(),
operation: IoOperation::Write,
})?;
temp_file.flush().await.map_err(|error| IoErrorWithPath {
error,
path: temp_file.file_path().clone(),
operation: IoOperation::Flush,
})?;
let temp_file_path = temp_file.file_path();
#[cfg(feature = "tracing")]
tracing::debug!("atomically replacing {dest:?} with {temp_file_path:?}");
tokio::fs::rename(temp_file_path, &dest)
.await
.map_err(|error| IoErrorWithPath {
error,
path: temp_file_path.clone(),
operation: IoOperation::Rename { dest: dest.clone() },
})?;
Ok(())
}
async fn permissions_for(dest: &Path) -> Result<Option<std::fs::Permissions>, WriteError> {
match tokio::fs::metadata(dest).await {
Ok(metadata) => Ok(Some(metadata.permissions())),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
Ok(Some(std::fs::Permissions::from_mode(0o600)))
}
#[cfg(not(unix))]
{
Ok(None)
}
}
Err(error) => Err(IoErrorWithPath {
error,
path: dest.to_path_buf(),
operation: IoOperation::GetMetadata,
}
.into()),
}
}
async fn canonical_destination(path: &Path) -> Result<PathBuf, WriteError> {
if let Ok(canonical) = tokio::fs::canonicalize(path).await {
return Ok(canonical);
}
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
tokio::fs::create_dir_all(&parent)
.await
.map_err(|error| IoErrorWithPath {
error,
path: parent.clone(),
operation: IoOperation::Write,
})?;
let canonical_parent =
tokio::fs::canonicalize(&parent)
.await
.map_err(|error| IoErrorWithPath {
error,
path: parent.clone(),
operation: IoOperation::GetMetadata,
})?;
let file_name = path.file_name().ok_or_else(|| IoErrorWithPath {
error: std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"destination path has no file name",
),
path: path.to_path_buf(),
operation: IoOperation::Write,
})?;
Ok(canonical_parent.join(file_name))
}
#[cfg(test)]
mod tests {
use std::time::{SystemTime, UNIX_EPOCH};
use super::{atomic_write, canonical_destination};
fn unique_test_root(label: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"qcs-common-atomic-write-test-{label}-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should be after unix epoch")
.as_nanos()
))
}
#[tokio::test]
async fn destination_resolves_next_to_the_target_creating_missing_dirs() {
let root = unique_test_root("missing-dirs");
let target_dir = root.join("nested").join("config");
let target_file = target_dir.join("secrets.toml");
let dest = canonical_destination(&target_file)
.await
.expect("should resolve a destination next to the target file");
let canonical_target_dir = tokio::fs::canonicalize(&target_dir)
.await
.expect("parent directory should have been created");
assert_eq!(dest.parent(), Some(canonical_target_dir.as_path()));
assert_eq!(dest.file_name(), target_file.file_name());
std::fs::remove_dir_all(root).expect("should remove the test directory");
}
#[cfg(unix)]
#[tokio::test]
async fn destination_follows_a_symlinked_file_to_its_real_location() {
let root = unique_test_root("symlinked-file");
let real_dir = root.join("real");
let link_dir = root.join("links");
tokio::fs::create_dir_all(&real_dir)
.await
.expect("should create the real directory");
tokio::fs::create_dir_all(&link_dir)
.await
.expect("should create the links directory");
let real_file = real_dir.join("secrets.toml");
tokio::fs::write(&real_file, b"contents")
.await
.expect("should create the real file");
let symlink = link_dir.join("secrets.toml");
std::os::unix::fs::symlink(&real_file, &symlink).expect("should create the symlink");
let dest = canonical_destination(&symlink)
.await
.expect("should resolve the symlink to its target");
let canonical_real_dir = tokio::fs::canonicalize(&real_dir)
.await
.expect("real directory should exist");
assert_eq!(dest.parent(), Some(canonical_real_dir.as_path()));
std::fs::remove_dir_all(root).expect("should remove the test directory");
}
#[tokio::test]
async fn atomic_write_creates_then_overwrites_the_file() {
let root = unique_test_root("write");
let target = root.join("nested").join("secrets.toml");
atomic_write(&target, b"first")
.await
.expect("should create the file");
assert_eq!(
tokio::fs::read(&target).await.expect("file should exist"),
b"first"
);
atomic_write(&target, b"second")
.await
.expect("should overwrite the file");
assert_eq!(
tokio::fs::read(&target).await.expect("file should exist"),
b"second"
);
std::fs::remove_dir_all(root).expect("should remove the test directory");
}
#[cfg(unix)]
#[tokio::test]
async fn atomic_write_creates_new_files_with_owner_only_permissions() {
use std::os::unix::fs::PermissionsExt as _;
let root = unique_test_root("perms");
let target = root.join("secrets.toml");
atomic_write(&target, b"secret")
.await
.expect("should create the file");
let mode = tokio::fs::metadata(&target)
.await
.expect("file should exist")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
std::fs::remove_dir_all(root).expect("should remove the test directory");
}
}