use std::path::Path;
use std::time::Duration;
use camel_component_api::{Body, CamelError};
use tokio::io::AsyncWriteExt;
use crate::{TempFileGuard, write_body_with_charset};
pub(crate) const DEFAULT_TEMP_PREFIX: &str = ".tmp.";
pub(crate) async fn atomic_write(
target_path: &Path,
body: Body,
temp_prefix: Option<&str>,
durable: bool,
write_timeout: Duration,
charset: &Option<String>,
) -> Result<(), CamelError> {
let parent = target_path.parent().ok_or_else(|| {
CamelError::ProcessorError(format!(
"target path has no parent directory: {}",
target_path.display()
))
})?;
let file_name = target_path.file_name().ok_or_else(|| {
CamelError::ProcessorError(format!(
"target path has no file_name component: {}",
target_path.display()
))
})?;
let prefix = temp_prefix.unwrap_or(DEFAULT_TEMP_PREFIX);
let mut temp_name = prefix.to_string();
temp_name.push_str(&file_name.to_string_lossy());
let temp_path = parent.join(&temp_name);
let mut temp_file = tokio::time::timeout(
write_timeout,
tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temp_path),
)
.await
.map_err(|_| CamelError::ProcessorError("Timeout creating temp file".into()))?
.map_err(CamelError::from)?;
let mut guard = TempFileGuard::new(temp_path.clone());
write_body_with_charset(body, charset, &mut temp_file, write_timeout).await?;
if durable {
tokio::time::timeout(write_timeout, temp_file.sync_all())
.await
.map_err(|_| CamelError::ProcessorError("Timeout fsyncing temp file".into()))?
.map_err(CamelError::from)?;
} else {
let _ = temp_file.flush().await;
}
let rename_result =
tokio::time::timeout(write_timeout, tokio::fs::rename(&temp_path, target_path)).await;
match rename_result {
Ok(Ok(_)) => {
guard.disarm();
}
Ok(Err(e)) => {
return Err(map_rename_error(e));
}
Err(_) => {
return Err(CamelError::ProcessorError(
"Timeout renaming temp file".into(),
));
}
}
if durable {
fsync_parent_dir(parent, write_timeout).await?;
}
Ok(())
}
fn map_rename_error(e: std::io::Error) -> CamelError {
if let Some(raw) = e.raw_os_error() {
if raw == 18 {
return CamelError::ProcessorError(format!(
"cross-filesystem rename rejected (EXDEV); target and temp must share a filesystem: {e}"
));
}
}
CamelError::from(e)
}
async fn fsync_parent_dir(parent: &Path, write_timeout: Duration) -> Result<(), CamelError> {
let dir_file = tokio::time::timeout(write_timeout, tokio::fs::File::open(parent))
.await
.map_err(|_| CamelError::ProcessorError("Timeout opening parent dir for fsync".into()))?
.map_err(CamelError::from)?;
tokio::time::timeout(write_timeout, dir_file.sync_all())
.await
.map_err(|_| CamelError::ProcessorError("Timeout fsyncing parent dir".into()))?
.map_err(CamelError::from)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use camel_component_api::Body;
#[tokio::test]
async fn atomic_write_durable_true_succeeds_and_persists_content() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("out.bin");
let body = Body::from(vec![1u8, 2, 3, 4]);
atomic_write(
&target,
body,
None,
true, Duration::from_secs(5),
&None,
)
.await
.unwrap();
assert!(target.exists(), "target should exist after atomic_write");
let bytes = std::fs::read(&target).unwrap();
assert_eq!(bytes, vec![1u8, 2, 3, 4]);
let mut entries: Vec<_> = std::fs::read_dir(dir.path()).unwrap().collect();
entries.retain(|e| {
let name = e.as_ref().unwrap().file_name();
name.to_string_lossy() == "out.bin"
});
assert_eq!(entries.len(), 1, "only the target file should remain");
}
#[tokio::test]
async fn atomic_write_durable_false_succeeds_and_persists_content() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("out2.bin");
let body = Body::from(vec![9u8, 8, 7]);
atomic_write(
&target,
body,
None,
false, Duration::from_secs(5),
&None,
)
.await
.unwrap();
assert!(target.exists());
let bytes = std::fs::read(&target).unwrap();
assert_eq!(bytes, vec![9u8, 8, 7]);
}
#[tokio::test]
async fn atomic_write_honors_custom_temp_prefix() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("final.txt");
let body = Body::from(b"hi".to_vec());
atomic_write(
&target,
body,
Some("custom-"),
false,
Duration::from_secs(5),
&None,
)
.await
.unwrap();
assert!(target.exists());
assert!(
!dir.path().join("custom-final.txt").exists(),
"temp file should have been renamed"
);
}
#[cfg(unix)]
#[test]
fn map_rename_error_translates_exdev_to_processor_error() {
let err = std::io::Error::from_raw_os_error(18);
let mapped = super::map_rename_error(err);
let msg = match mapped {
camel_component_api::CamelError::ProcessorError(m) => m,
other => panic!("expected ProcessorError, got {other:?}"),
};
assert!(
msg.contains("cross-filesystem") && msg.contains("EXDEV"),
"EXDEV error message must mention cross-filesystem and EXDEV, got: {msg}"
);
}
#[cfg(unix)]
#[test]
fn map_rename_error_passes_through_non_exdev() {
use std::io::ErrorKind;
let err = std::io::Error::new(ErrorKind::NotFound, "missing");
let mapped = super::map_rename_error(err);
assert!(
matches!(
mapped,
camel_component_api::CamelError::ProcessorError(_)
| camel_component_api::CamelError::Io(_)
),
"non-EXDEV io error should map to ProcessorError or Io, got {mapped:?}"
);
}
#[tokio::test]
async fn atomic_write_preserves_preexisting_blocker_on_create_fail() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("cleanup.bin");
let temp_path = dir.path().join(".tmp.cleanup.bin");
std::fs::write(&temp_path, b"blocker").unwrap();
let body = Body::from(vec![1u8, 2, 3]);
let result = atomic_write(&target, body, None, false, Duration::from_secs(5), &None).await;
assert!(
result.is_err(),
"expected atomic_write to fail because temp file already exists"
);
assert!(!target.exists(), "target must not exist after failed write");
assert!(
temp_path.exists(),
"pre-existing blocker MUST be preserved (guard was not armed; \
open() failed before TempFileGuard::new)"
);
assert_eq!(
std::fs::read(&temp_path).unwrap(),
b"blocker",
"blocker file content MUST be unchanged"
);
}
#[tokio::test]
async fn atomic_write_cleans_temp_file_when_write_fails_after_open() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("cleanup2.bin");
let temp_path = dir.path().join(".tmp.cleanup2.bin");
let body = Body::from("café ñ ü".to_string());
let result = atomic_write(
&target,
body,
None,
false,
Duration::from_secs(5),
&Some("INVALID_CHARSET_XYZ".to_string()),
)
.await;
assert!(
result.is_err(),
"expected write_body_with_charset to fail on bogus charset"
);
assert!(!target.exists(), "target must not exist after failed write");
assert!(
!temp_path.exists(),
"temp file created by helper MUST be cleaned up when write fails after open"
);
}
#[tokio::test]
async fn atomic_write_temp_file_lives_in_target_parent_not_dir_root() {
let dir = tempfile::tempdir().unwrap();
let nested_parent = dir.path().join("a").join("b");
std::fs::create_dir_all(&nested_parent).unwrap();
let target = nested_parent.join("deep.bin");
let body = Body::from(b"deep".to_vec());
atomic_write(&target, body, None, false, Duration::from_secs(5), &None)
.await
.unwrap();
assert!(target.exists());
assert!(
!dir.path().join(".tmp.a/b/deep.bin").exists(),
"Bug C signature: stray temp path at directory root must not exist"
);
assert!(
!dir.path().join(".tmp.a").exists(),
"Bug C signature: stray temp directory at root must not exist"
);
let parent_entries: Vec<_> = std::fs::read_dir(&nested_parent).unwrap().collect();
assert_eq!(
parent_entries.len(),
1,
"only the target should remain in parent"
);
assert_eq!(
parent_entries[0].as_ref().unwrap().file_name(),
std::ffi::OsString::from("deep.bin")
);
}
}