use std::io::Write as _;
use std::os::unix::fs::PermissionsExt as _;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::inbox::{INBOX_DIR_MODE, INBOX_FILE_MODE, InboxError};
pub const QUARANTINE_DIR_NAME: &str = "quarantine";
pub const ATTEMPT_EXTENSION: &str = "attempt";
pub const PROCESSED_DIR_NAME: &str = "processed";
pub const PROCESSED_EXTENSION: &str = "done";
pub const PROCESSED_RETENTION: std::time::Duration =
std::time::Duration::from_secs(30 * 24 * 60 * 60);
pub const DEFAULT_MAX_ATTEMPTS: u32 = 5;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AttemptRecord {
pub attempts: u32,
pub last_error: String,
pub first_failed_at_unix_ms: u64,
pub last_failed_at_unix_ms: u64,
}
pub fn attempt_path(entry: &Path) -> PathBuf {
entry.with_extension(ATTEMPT_EXTENSION)
}
pub fn load_attempts(entry: &Path) -> AttemptRecord {
std::fs::read(attempt_path(entry))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
}
pub fn record_failure(
entry: &Path,
reason: &str,
now_unix_ms: u64,
) -> Result<AttemptRecord, InboxError> {
let previous = load_attempts(entry);
let record = AttemptRecord {
attempts: previous.attempts.saturating_add(1),
last_error: reason.to_string(),
first_failed_at_unix_ms: if previous.attempts == 0 {
now_unix_ms
} else {
previous.first_failed_at_unix_ms
},
last_failed_at_unix_ms: now_unix_ms,
};
let path = attempt_path(entry);
let bytes = serde_json::to_vec_pretty(&record).map_err(|source| InboxError::Encode {
delivery_id: path.display().to_string(),
source,
})?;
write_replace(&path, &bytes)?;
Ok(record)
}
pub fn remove_processed(entry: &Path) -> Result<(), InboxError> {
std::fs::remove_file(entry).map_err(|source| InboxError::Write {
path: entry.to_path_buf(),
source,
})?;
let _ = std::fs::remove_file(attempt_path(entry));
sync_parent(entry);
Ok(())
}
pub fn processed_dir(inbox_root: &Path) -> PathBuf {
inbox_root.join(PROCESSED_DIR_NAME)
}
pub fn processed_marker_path(inbox_root: &Path, entry: &Path) -> PathBuf {
let stem = entry.file_stem().unwrap_or_default();
processed_dir(inbox_root).join(format!("{}.{PROCESSED_EXTENSION}", stem.to_string_lossy()))
}
pub fn is_processed(inbox_root: &Path, entry: &Path) -> bool {
processed_marker_path(inbox_root, entry).exists()
}
pub fn mark_processed(
inbox_root: &Path,
entry: &Path,
delivery_id: &str,
now_unix_ms: u64,
) -> Result<PathBuf, InboxError> {
let dir = processed_dir(inbox_root);
std::fs::create_dir_all(&dir).map_err(|source| InboxError::PrepareDir {
path: dir.clone(),
source,
})?;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(INBOX_DIR_MODE)).map_err(
|source| InboxError::PrepareDir {
path: dir.clone(),
source,
},
)?;
let marker = processed_marker_path(inbox_root, entry);
let record = serde_json::json!({
"delivery_id": delivery_id,
"processed_at_unix_ms": now_unix_ms,
});
let bytes = serde_json::to_vec(&record).map_err(|source| InboxError::Encode {
delivery_id: delivery_id.to_string(),
source,
})?;
write_replace(&marker, &bytes)?;
sync_dir(&dir)?;
Ok(marker)
}
pub fn prune_processed(inbox_root: &Path, retention: std::time::Duration) {
let Ok(read) = std::fs::read_dir(processed_dir(inbox_root)) else {
return;
};
let cutoff = std::time::SystemTime::now() - retention;
for path in read
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == PROCESSED_EXTENSION))
{
let stale = std::fs::metadata(&path)
.and_then(|m| m.modified())
.is_ok_and(|m| m < cutoff);
if stale {
let _ = std::fs::remove_file(&path);
}
}
}
pub fn quarantine(inbox_root: &Path, entry: &Path) -> Result<PathBuf, InboxError> {
let dir = quarantine_dir(inbox_root);
std::fs::create_dir_all(&dir).map_err(|source| InboxError::PrepareDir {
path: dir.clone(),
source,
})?;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(INBOX_DIR_MODE)).map_err(
|source| InboxError::PrepareDir {
path: dir.clone(),
source,
},
)?;
let name = entry
.file_name()
.ok_or_else(|| InboxError::Write {
path: entry.to_path_buf(),
source: std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"inbox entry has no file name",
),
})?
.to_owned();
let target = dir.join(&name);
if let Ok(bytes) = std::fs::read(attempt_path(entry)) {
let _ = write_replace(&attempt_path(&target), &bytes);
}
match std::fs::hard_link(entry, &target) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(source) => {
return Err(InboxError::Commit {
from: entry.to_path_buf(),
to: target,
source,
});
}
}
sync_dir(&dir)?;
match std::fs::remove_file(entry) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(source) => {
return Err(InboxError::Write {
path: entry.to_path_buf(),
source,
});
}
}
let _ = std::fs::remove_file(attempt_path(entry));
sync_parent(entry);
Ok(target)
}
pub fn quarantine_dir(inbox_root: &Path) -> PathBuf {
inbox_root.join(QUARANTINE_DIR_NAME)
}
pub fn quarantined_count(inbox_root: &Path) -> Result<usize, InboxError> {
let dir = quarantine_dir(inbox_root);
let read = match std::fs::read_dir(&dir) {
Ok(read) => read,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
Err(source) => return Err(InboxError::Read { path: dir, source }),
};
Ok(read
.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
.count())
}
fn write_replace(path: &Path, bytes: &[u8]) -> Result<(), InboxError> {
let tmp = path.with_extension(format!("{ATTEMPT_EXTENSION}.{}.tmp", std::process::id()));
let write = || -> std::io::Result<()> {
let mut file = std::fs::File::create(&tmp)?;
file.set_permissions(std::fs::Permissions::from_mode(INBOX_FILE_MODE))?;
file.write_all(bytes)?;
file.sync_all()?;
std::fs::rename(&tmp, path)
};
write().map_err(|source| {
let _ = std::fs::remove_file(&tmp);
InboxError::Write {
path: path.to_path_buf(),
source,
}
})
}
fn sync_dir(path: &Path) -> Result<(), InboxError> {
std::fs::File::open(path)
.and_then(|d| d.sync_all())
.map_err(|source| InboxError::SyncDir {
path: path.to_path_buf(),
source,
})
}
fn sync_parent(entry: &Path) {
if let Some(parent) = entry.parent() {
let _ = sync_dir(parent);
}
}