use std::path::Path;
use std::time::{Duration, SystemTime};
use camel_component_api::{Body, CamelError};
use tokio::io::AsyncWriteExt;
use tracing::{debug, warn};
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(())
}
pub(crate) async fn sweep_stale_temps(
dir: &Path,
temp_prefix: &str,
stale_age: Duration,
) -> Result<(), CamelError> {
let mut entries = tokio::fs::read_dir(dir).await.map_err(|e| {
CamelError::ProcessorError(format!("sweep_stale_temps read_dir failed: {e}"))
})?;
let now = SystemTime::now();
let threshold = now.checked_sub(stale_age).ok_or_else(|| {
CamelError::ProcessorError("sweep_stale_temps: stale_age underflows SystemTime".into())
})?;
while let Some(entry) = entries
.next_entry()
.await
.map_err(|e| CamelError::ProcessorError(format!("sweep_stale_temps entry failed: {e}")))?
{
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.starts_with(temp_prefix) {
continue;
}
if let Ok(meta) = entry.metadata().await
&& let Ok(mtime) = meta.modified()
&& mtime < threshold
{
let path = entry.path();
if let Err(e) = tokio::fs::remove_file(&path).await {
warn!(path = %path.display(), error = %e, "Failed to remove stale temp");
} else {
debug!(path = %path.display(), "Removed stale temp file");
}
}
}
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 sweep_stale_temps_removes_old_preserves_fresh() {
use std::fs;
use std::time::SystemTime;
let dir = tempfile::tempdir().unwrap();
let stale = dir.path().join(".tmp.output.txt");
fs::write(&stale, "stale").unwrap();
let old_time = SystemTime::now() - Duration::from_secs(300);
filetime::set_file_mtime(&stale, filetime::FileTime::from_system_time(old_time)).unwrap();
let fresh = dir.path().join(".tmp.other.txt");
fs::write(&fresh, "fresh").unwrap();
let normal = dir.path().join("normal.txt");
fs::write(&normal, "normal").unwrap();
sweep_stale_temps(dir.path(), ".tmp.", Duration::from_secs(60))
.await
.unwrap();
assert!(!stale.exists(), "stale temp should be removed");
assert!(fresh.exists(), "fresh temp should be preserved");
assert!(normal.exists(), "non-temp file should be preserved");
}
#[tokio::test]
async fn sweep_stale_temps_ignores_non_matching_prefix() {
use std::fs;
use std::time::SystemTime;
let dir = tempfile::tempdir().unwrap();
let other_prefix = dir.path().join(".bak.old.txt");
fs::write(&other_prefix, "keep me").unwrap();
let old_time = SystemTime::now() - Duration::from_secs(300);
filetime::set_file_mtime(
&other_prefix,
filetime::FileTime::from_system_time(old_time),
)
.unwrap();
sweep_stale_temps(dir.path(), ".tmp.", Duration::from_secs(60))
.await
.unwrap();
assert!(
other_prefix.exists(),
"file with non-matching prefix should be preserved"
);
}
#[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")
);
}
}