mod disk;
pub(super) mod in_flight;
mod inspect;
pub mod reserve;
use std::collections::{BTreeMap, VecDeque};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::time::Duration;
use secrecy::{ExposeSecret, SecretSlice};
use tokio::sync::Notify;
use tokio::task::JoinError;
use disk::{check_private, create_private, file, io, probe, scan, sync_directory, write_durably};
use in_flight::InFlight;
const STORED: &str = "msg";
const PARTIAL: &str = "partial";
pub const QUARANTINE: &str = "quarantine";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum Content {
#[default]
SyslogFrames,
#[cfg(feature = "balp")]
AuditEvents,
}
impl Content {
fn holds(self, bytes: &[u8]) -> bool {
match self {
Self::SyslogFrames => super::syslog::is_frame(bytes),
#[cfg(feature = "balp")]
Self::AuditEvents => {
serde_json::from_slice::<fhir_types::r4::audit_event::AuditEvent>(bytes).is_ok()
}
}
}
fn mismatch(self) -> &'static str {
match self {
Self::SyslogFrames => "it is no whole RFC 5425 frame",
#[cfg(feature = "balp")]
Self::AuditEvents => "it is no FHIR AuditEvent",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Bounds {
pub max_messages: usize,
pub max_bytes: u64,
pub write_timeout: Duration,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SpoolError {
#[error("the audit spool is full: {messages} messages, {bytes} bytes")]
Full {
messages: usize,
bytes: u64,
},
#[error("the audit spool directory {0} is open to its group or to other users")]
Exposed(PathBuf),
#[error(
"the audit spool directory {} holds {}, which the sender did not write: move that file out of the spool directory, keeping it if it may be an audit record, and start again",
directory.display(),
path.display()
)]
Foreign {
directory: PathBuf,
path: PathBuf,
},
#[error("{} is no directory, so no audit spool can be opened there", .0.display())]
NotADirectory(PathBuf),
#[error(
"the audit spool directory {} gives no user write access; grant the sender's user write access to it",
.0.display()
)]
Unwritable(PathBuf),
#[error("the audit spool at {path} could not be {action}")]
Io {
action: &'static str,
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("the audit spool did not store the message within {bound:?}")]
Late {
bound: Duration,
},
#[error("the audit spool task did not finish")]
Task(#[source] JoinError),
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Depth {
pub messages: usize,
pub bytes: u64,
pub quarantined: usize,
}
impl Depth {
#[must_use]
pub fn waiting(&self) -> usize {
self.messages.saturating_sub(self.quarantined)
}
}
pub struct Stored {
pub sequence: u64,
pub message: SecretSlice<u8>,
}
impl std::fmt::Debug for Stored {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Stored")
.field("sequence", &self.sequence)
.finish_non_exhaustive()
}
}
#[derive(Clone)]
pub struct Spool {
inner: Arc<Mutex<Inner>>,
shown: Arc<Mutex<Ledger>>,
turn: Arc<tokio::sync::Mutex<()>>,
arrived: Arc<Notify>,
bounds: Bounds,
durable: bool,
}
impl std::fmt::Debug for Spool {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Spool")
.field("on_disk", &self.durable)
.field("depth", &self.depth())
.field("bounds", &self.bounds)
.finish_non_exhaustive()
}
}
struct Inner {
store: Store,
bounds: Bounds,
depth: Depth,
shown: Arc<Mutex<Ledger>>,
next: u64,
}
#[derive(Debug, Default)]
struct Ledger {
depth: Depth,
queued: usize,
queued_bytes: u64,
refused: u64,
}
impl Ledger {
fn refuse(&mut self) -> u64 {
self.refused = self.refused.saturating_add(1);
self.refused
}
}
struct Place {
ledger: Arc<Mutex<Ledger>>,
size: u64,
}
impl Drop for Place {
fn drop(&mut self) {
let mut ledger = lock(&self.ledger);
ledger.queued = ledger.queued.saturating_sub(1);
ledger.queued_bytes = ledger.queued_bytes.saturating_sub(self.size);
}
}
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(PoisonError::into_inner)
}
enum Store {
Disk {
directory: PathBuf,
content: Content,
sizes: BTreeMap<u64, u64>,
quarantined: BTreeMap<u64, u64>,
},
Memory {
queue: VecDeque<(u64, SecretSlice<u8>)>,
held: Vec<(u64, SecretSlice<u8>)>,
},
}
impl Spool {
pub fn open(directory: &Path, bounds: Bounds) -> Result<Self, SpoolError> {
Self::open_for(directory, bounds, Content::SyslogFrames)
}
pub fn open_for(
directory: &Path,
bounds: Bounds,
content: Content,
) -> Result<Self, SpoolError> {
let held = directory.join(QUARANTINE);
create_private(directory)?;
check_private(directory)?;
create_private(&held)?;
check_private(&held)?;
probe(directory)?;
let sizes = scan(directory, Some(&held))?;
let quarantined = scan(&held, None)?;
let depth = Depth {
messages: sizes.len().saturating_add(quarantined.len()),
bytes: sizes
.values()
.chain(quarantined.values())
.fold(0, |sum, size| sum.saturating_add(*size)),
quarantined: quarantined.len(),
};
let next = sizes
.keys()
.chain(quarantined.keys())
.max()
.map_or(0, |last| last.saturating_add(1));
Ok(Self::with(
Store::Disk {
directory: directory.to_owned(),
content,
sizes,
quarantined,
},
bounds,
depth,
next,
))
}
#[must_use]
pub fn in_memory(bounds: Bounds) -> Self {
Self::with(
Store::Memory {
queue: VecDeque::new(),
held: Vec::new(),
},
bounds,
Depth::default(),
0,
)
}
fn with(store: Store, bounds: Bounds, depth: Depth, next: u64) -> Self {
let durable = matches!(store, Store::Disk { .. });
let shown = Arc::new(Mutex::new(Ledger {
depth,
..Ledger::default()
}));
Self {
inner: Arc::new(Mutex::new(Inner {
store,
bounds,
depth,
shown: Arc::clone(&shown),
next,
})),
shown,
turn: Arc::new(tokio::sync::Mutex::new(())),
arrived: Arc::new(Notify::new()),
bounds,
durable,
}
}
#[must_use]
pub fn is_durable(&self) -> bool {
self.durable
}
#[must_use]
pub fn depth(&self) -> Depth {
lock(&self.shown).depth
}
#[must_use]
pub fn queued(&self) -> usize {
lock(&self.shown).queued
}
#[must_use]
pub fn refused(&self) -> u64 {
lock(&self.shown).refused
}
pub async fn push(&self, message: SecretSlice<u8>) -> Result<(), SpoolError> {
let size = u64::try_from(message.expose_secret().len()).unwrap_or(u64::MAX);
let place = self.admit(size)?;
self.push_in(message, place).await
}
async fn push_in(&self, message: SecretSlice<u8>, place: Place) -> Result<(), SpoolError> {
let bound = self.bounds.write_timeout;
let store = self.store(message, place);
match tokio::time::Instant::now().checked_add(bound) {
Some(deadline) => tokio::time::timeout_at(deadline, store)
.await
.map_err(|_elapsed| SpoolError::Late { bound })?,
None => store.await,
}
}
pub async fn stored(&self) {
self.arrived.notified().await;
}
async fn store(&self, message: SecretSlice<u8>, place: Place) -> Result<(), SpoolError> {
let turn = Arc::clone(&self.turn);
let inner = Arc::clone(&self.inner);
let arrived = Arc::clone(&self.arrived);
let write = InFlight::new(tokio::spawn(async move {
let turn = turn.lock_owned().await;
tokio::task::spawn_blocking(move || {
let stored = {
let mut inner = lock(&inner);
let stored = inner.push(message);
inner.publish();
stored
};
drop(place);
drop(turn);
if stored.is_ok() {
arrived.notify_one();
}
stored
})
.await
.map_err(SpoolError::Task)?
}));
write.await.map_err(SpoolError::Task)?.map(|_sequence| ())
}
fn admit(&self, size: u64) -> Result<Place, SpoolError> {
let mut ledger = lock(&self.shown);
let messages = ledger.depth.messages.saturating_add(ledger.queued);
let bytes = ledger.depth.bytes.saturating_add(ledger.queued_bytes);
if messages >= self.bounds.max_messages
|| bytes.saturating_add(size) > self.bounds.max_bytes
{
let refused = ledger.refuse();
drop(ledger);
tracing::warn!(target: crate::atna::targets::SPOOL,
refused,
messages,
bytes,
"an audit message was refused: the spool and the writes queued for it are at their bounds"
);
return Err(SpoolError::Full { messages, bytes });
}
ledger.queued = ledger.queued.saturating_add(1);
ledger.queued_bytes = ledger.queued_bytes.saturating_add(size);
Ok(Place {
ledger: Arc::clone(&self.shown),
size,
})
}
async fn blocking<T: Send + 'static>(
&self,
step: impl FnOnce(&mut Inner) -> Result<T, SpoolError> + Send + 'static,
) -> Result<T, SpoolError> {
let inner = Arc::clone(&self.inner);
tokio::task::spawn_blocking(move || {
let mut inner = lock(&inner);
let done = step(&mut inner);
inner.publish();
done
})
.await
.map_err(SpoolError::Task)?
}
pub async fn oldest(&self) -> Result<Option<Stored>, SpoolError> {
self.blocking(Inner::oldest).await
}
pub async fn reject(&self, sequence: u64) -> Result<(), SpoolError> {
self.blocking(move |inner| inner.reject(sequence)).await
}
pub async fn remove(&self, sequence: u64) -> Result<(), SpoolError> {
self.blocking(move |inner| inner.remove(sequence)).await
}
}
impl Inner {
fn publish(&self) {
lock(&self.shown).depth = self.depth;
}
fn push(&mut self, message: SecretSlice<u8>) -> Result<u64, SpoolError> {
let size = u64::try_from(message.expose_secret().len()).unwrap_or(u64::MAX);
let bytes = self.depth.bytes.saturating_add(size);
if self.depth.messages >= self.bounds.max_messages || bytes > self.bounds.max_bytes {
let refused = lock(&self.shown).refuse();
tracing::warn!(target: crate::atna::targets::SPOOL,
refused,
messages = self.depth.messages,
bytes = self.depth.bytes,
"an audit message was refused: the spool is at its bounds"
);
return Err(SpoolError::Full {
messages: self.depth.messages,
bytes: self.depth.bytes,
});
}
let sequence = self.next;
match &mut self.store {
Store::Disk {
directory, sizes, ..
} => {
write_durably(directory, sequence, &message)?;
sizes.insert(sequence, size);
}
Store::Memory { queue, .. } => queue.push_back((sequence, message)),
}
self.next = sequence.saturating_add(1);
self.depth.messages = self.depth.messages.saturating_add(1);
self.depth.bytes = bytes;
Ok(sequence)
}
fn oldest(&mut self) -> Result<Option<Stored>, SpoolError> {
loop {
let (directory, content, sequence) = match &self.store {
Store::Memory { queue, .. } => {
return Ok(queue.front().map(|(sequence, message)| Stored {
sequence: *sequence,
message: SecretSlice::from(message.expose_secret().to_vec()),
}));
}
Store::Disk {
directory,
content,
sizes,
..
} => match sizes.keys().next() {
None => return Ok(None),
Some(sequence) => (directory.clone(), *content, *sequence),
},
};
match fs::read(file(&directory, sequence, STORED)) {
Ok(message) if content.holds(&message) => {
return Ok(Some(Stored {
sequence,
message: SecretSlice::from(message),
}));
}
Ok(_) => self.quarantine(&directory, sequence, content.mismatch())?,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
tracing::error!(target: crate::atna::targets::SPOOL, sequence, "a spooled audit message is gone from the spool");
self.forget(sequence);
}
Err(error) => {
tracing::error!(target: crate::atna::targets::SPOOL, sequence, error = %error, "a spooled audit message cannot be read");
self.quarantine(&directory, sequence, "it cannot be read")?;
}
}
}
}
fn forget(&mut self, sequence: u64) {
if let Store::Disk { sizes, .. } = &mut self.store
&& let Some(size) = sizes.remove(&sequence)
{
self.depth.messages = self.depth.messages.saturating_sub(1);
self.depth.bytes = self.depth.bytes.saturating_sub(size);
}
}
fn quarantine(
&mut self,
directory: &Path,
sequence: u64,
why: &'static str,
) -> Result<(), SpoolError> {
let held = directory.join(QUARANTINE);
let from = file(directory, sequence, STORED);
let to = file(&held, sequence, STORED);
fs::rename(&from, &to).map_err(io("moved to the quarantine", &from))?;
sync_directory(directory)?;
sync_directory(&held)?;
if let Store::Disk {
sizes, quarantined, ..
} = &mut self.store
&& let Some(size) = sizes.remove(&sequence)
{
quarantined.insert(sequence, size);
self.depth.quarantined = self.depth.quarantined.saturating_add(1);
}
tracing::error!(target: crate::atna::targets::SPOOL,
sequence,
reason = why,
"a spooled audit message was moved to the quarantine; the drain goes on"
);
Ok(())
}
fn reject(&mut self, sequence: u64) -> Result<(), SpoolError> {
match &mut self.store {
Store::Disk {
directory, sizes, ..
} => {
if !sizes.contains_key(&sequence) {
return Ok(());
}
let directory = directory.clone();
self.quarantine(&directory, sequence, "the audit repository refused it")
}
Store::Memory { queue, held } => {
let Some(position) = queue.iter().position(|(kept, _)| *kept == sequence) else {
return Ok(());
};
if let Some(message) = queue.remove(position) {
held.push(message);
self.depth.quarantined = self.depth.quarantined.saturating_add(1);
tracing::error!(target: crate::atna::targets::SPOOL,
sequence,
reason = "the audit repository refused it",
"an audit message held in memory was set aside; the drain goes on"
);
}
Ok(())
}
}
}
fn remove(&mut self, sequence: u64) -> Result<(), SpoolError> {
let size = match &mut self.store {
Store::Disk {
directory, sizes, ..
} => {
let Some(size) = sizes.get(&sequence).copied() else {
return Ok(());
};
let path = file(directory, sequence, STORED);
fs::remove_file(&path).map_err(io("removed", &path))?;
sizes.remove(&sequence);
size
}
Store::Memory { queue, .. } => {
let Some(position) = queue.iter().position(|(held, _)| *held == sequence) else {
return Ok(());
};
queue.remove(position).map_or(0, |(_, message)| {
u64::try_from(message.expose_secret().len()).unwrap_or(u64::MAX)
})
}
};
self.depth.messages = self.depth.messages.saturating_sub(1);
self.depth.bytes = self.depth.bytes.saturating_sub(size);
Ok(())
}
}