use std::fmt;
use jiff::Timestamp;
use secrecy::{ExposeSecret, SecretSlice};
pub const PRI: &str = "<85>";
pub const MSGID: &str = "IHE+RFC-3881";
pub const TLS_PORT: u16 = 6514;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("the syslog {field} is not 1 to {limit} printable US-ASCII characters")]
pub struct HeaderError {
pub field: &'static str,
pub limit: usize,
}
#[derive(Clone, PartialEq, Eq)]
struct Field(String);
impl Field {
fn new(field: &'static str, limit: usize, value: &str) -> Result<Self, HeaderError> {
let printable = value.bytes().all(|byte| (33..=126).contains(&byte));
if value.is_empty() || value.len() > limit || !printable {
return Err(HeaderError { field, limit });
}
Ok(Self(value.to_owned()))
}
}
impl fmt::Debug for Field {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(&self.0, f)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Sender {
hostname: Field,
app_name: Field,
procid: Field,
}
impl Sender {
pub fn new(hostname: &str, app_name: &str, procid: &str) -> Result<Self, HeaderError> {
Ok(Self {
hostname: Field::new("HOSTNAME", 255, hostname)?,
app_name: Field::new("APP-NAME", 48, app_name)?,
procid: Field::new("PROCID", 128, procid)?,
})
}
#[must_use]
pub fn hostname(&self) -> &str {
&self.hostname.0
}
#[must_use]
pub fn frame(&self, at: Timestamp, xml: &SecretSlice<u8>) -> SecretSlice<u8> {
let timestamp = at.strftime("%Y-%m-%dT%H:%M:%S%.6fZ").to_string();
let header = format!(
"{PRI}1 {timestamp} {} {} {} {MSGID} - ",
self.hostname.0, self.app_name.0, self.procid.0
);
let body = xml.expose_secret();
let length = header.len().saturating_add(body.len());
let mut frame = format!("{length} ").into_bytes();
frame.reserve(length);
frame.extend_from_slice(header.as_bytes());
frame.extend_from_slice(body);
SecretSlice::from(frame)
}
}
#[must_use]
pub fn is_frame(bytes: &[u8]) -> bool {
let Some(space) = bytes.iter().position(|byte| *byte == b' ') else {
return false;
};
let (length, rest) = bytes.split_at(space);
let digits = !length.is_empty()
&& length.first() != Some(&b'0')
&& length.iter().all(u8::is_ascii_digit);
digits
&& std::str::from_utf8(length)
.ok()
.and_then(|text| text.parse::<usize>().ok())
.is_some_and(|declared| declared.saturating_add(1) == rest.len())
}
#[cfg(test)]
mod tests {
use jiff::Timestamp;
use secrecy::{ExposeSecret, SecretSlice};
use super::{HeaderError, Sender};
#[test]
fn a_frame_counts_the_octets_of_the_message_it_carries() {
let sender = Sender::new("gateway.example.org", "ihe-atna", "4242").expect("a sender");
let at: Timestamp = "2026-10-04T01:02:03.123456789Z".parse().expect("a time");
let xml = SecretSlice::from(b"<AuditMessage/>".to_vec());
let frame = sender.frame(at, &xml);
let text = std::str::from_utf8(frame.expose_secret()).expect("UTF-8");
let message = "<85>1 2026-10-04T01:02:03.123456Z gateway.example.org ihe-atna 4242 IHE+RFC-3881 - <AuditMessage/>";
assert_eq!(format!("{} {message}", message.len()), text);
}
#[test]
fn a_header_field_with_a_space_or_over_its_limit_is_refused() {
assert_eq!(
Err(HeaderError {
field: "HOSTNAME",
limit: 255
}),
Sender::new("gate way", "ihe-atna", "1")
);
assert_eq!(
Err(HeaderError {
field: "APP-NAME",
limit: 48
}),
Sender::new("gateway", &"a".repeat(49), "1")
);
assert!(Sender::new("gateway", "ihe-atna", "").is_err());
}
}