use std::io::Write as _;
use std::os::unix::fs::PermissionsExt as _;
use std::path::{Path, PathBuf};
use super::RelayDelivery;
static TEMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn temp_path_for(final_path: &Path, stamp_nanos: u128) -> PathBuf {
let seq = TEMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
final_path.with_extension(format!(
"json.{}.{stamp_nanos}.{seq}.tmp",
std::process::id()
))
}
pub const INBOX_DIR_MODE: u32 = 0o700;
pub const INBOX_FILE_MODE: u32 = 0o600;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum InboxError {
#[error("prepare webhook inbox directory {path}: {source}")]
PrepareDir {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("encode delivery {delivery_id}: {source}")]
Encode {
delivery_id: String,
#[source]
source: serde_json::Error,
},
#[error("write webhook inbox entry {path}: {source}")]
Write {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("commit webhook inbox entry {from} -> {to}: {source}")]
Commit {
from: PathBuf,
to: PathBuf,
#[source]
source: std::io::Error,
},
#[error("fsync webhook inbox directory {path}: {source}")]
SyncDir {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("delivery {delivery_id} collides with the entry already at {path}: {detail}")]
KeyCollision {
path: PathBuf,
delivery_id: String,
detail: String,
},
#[error("read webhook inbox {path}: {source}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ownership {
pub path: PathBuf,
pub already_owned: bool,
}
#[derive(Debug, Clone)]
pub struct Inbox {
root: PathBuf,
}
impl Inbox {
pub fn open(root: impl Into<PathBuf>) -> Result<Self, InboxError> {
let root = root.into();
std::fs::create_dir_all(&root).map_err(|source| InboxError::PrepareDir {
path: root.clone(),
source,
})?;
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(INBOX_DIR_MODE)).map_err(
|source| InboxError::PrepareDir {
path: root.clone(),
source,
},
)?;
Ok(Self { root })
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn entry_path(&self, delivery_id: &str) -> PathBuf {
self.root.join(format!(
"{}-{}.json",
sanitise_delivery_id(delivery_id),
id_digest(delivery_id)
))
}
pub fn take_ownership(&self, delivery: &RelayDelivery) -> Result<Ownership, InboxError> {
let final_path = self.entry_path(&delivery.delivery_id);
let tmp_path = self.write_temp(delivery, &final_path)?;
let already_owned = match std::fs::hard_link(&tmp_path, &final_path) {
Ok(()) => false,
Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => {
if let Err(e) = self.confirm_same_delivery(&final_path, delivery) {
let _ = std::fs::remove_file(&tmp_path);
return Err(e);
}
true
}
Err(source) => {
let _ = std::fs::remove_file(&tmp_path);
return Err(InboxError::Commit {
from: tmp_path,
to: final_path,
source,
});
}
};
let _ = std::fs::remove_file(&tmp_path);
sync_dir(&self.root)?;
Ok(Ownership {
path: final_path,
already_owned,
})
}
pub fn list(&self) -> Result<Vec<(PathBuf, RelayDelivery)>, InboxError> {
let dir = std::fs::read_dir(&self.root).map_err(|source| InboxError::Read {
path: self.root.clone(),
source,
})?;
let mut held: Vec<(PathBuf, RelayDelivery)> = dir
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "json"))
.filter_map(|p| {
let bytes = std::fs::read(&p).ok()?;
let delivery: RelayDelivery = serde_json::from_slice(&bytes).ok()?;
Some((p, delivery))
})
.collect();
held.sort_by_key(|(_, d)| d.received_at_unix_ms);
Ok(held)
}
fn confirm_same_delivery(
&self,
path: &Path,
incoming: &RelayDelivery,
) -> Result<(), InboxError> {
let collision = |detail: &str| InboxError::KeyCollision {
path: path.to_path_buf(),
delivery_id: incoming.delivery_id.clone(),
detail: detail.to_string(),
};
let bytes =
std::fs::read(path).map_err(|e| collision(&format!("held copy unreadable: {e}")))?;
let held: RelayDelivery = serde_json::from_slice(&bytes)
.map_err(|e| collision(&format!("held copy undecodable: {e}")))?;
if held.delivery_id != incoming.delivery_id {
return Err(collision(&format!(
"path is held by delivery {:?}",
held.delivery_id
)));
}
if held.body_b64 != incoming.body_b64 {
return Err(collision(
"same delivery id, different body — the sender re-used an id",
));
}
Ok(())
}
fn write_temp(
&self,
delivery: &RelayDelivery,
final_path: &Path,
) -> Result<PathBuf, InboxError> {
let bytes = serde_json::to_vec_pretty(delivery).map_err(|source| InboxError::Encode {
delivery_id: delivery.delivery_id.clone(),
source,
})?;
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let tmp_path = temp_path_for(final_path, stamp);
let write = || -> std::io::Result<()> {
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp_path)?;
file.set_permissions(std::fs::Permissions::from_mode(INBOX_FILE_MODE))?;
file.write_all(&bytes)?;
file.sync_all()
};
write().map_err(|source| InboxError::Write {
path: tmp_path.clone(),
source,
})?;
Ok(tmp_path)
}
}
pub fn held_count(root: &Path) -> Result<usize, InboxError> {
let dir = match std::fs::read_dir(root) {
Ok(dir) => dir,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
Err(source) => {
return Err(InboxError::Read {
path: root.to_path_buf(),
source,
});
}
};
Ok(dir
.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
.count())
}
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 sanitise_delivery_id(raw: &str) -> String {
let cleaned: String = raw
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.take(64)
.collect();
if cleaned.is_empty() {
"unknown".to_string()
} else {
cleaned
}
}
fn id_digest(delivery_id: &str) -> String {
use sha2::Digest as _;
let digest = sha2::Sha256::digest(delivery_id.as_bytes());
digest[..8].iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod temp_path_tests {
use super::*;
#[test]
fn two_temp_names_within_one_clock_tick_differ() {
let final_path = Path::new("/inbox/delivery-1.json");
let frozen_stamp = 1_700_000_000_000_000_000u128;
let first = temp_path_for(final_path, frozen_stamp);
let second = temp_path_for(final_path, frozen_stamp);
assert_ne!(
first, second,
"two writers in one tick must not share a temp name"
);
}
}