Skip to main content

logfence_client/
builder.rs

1//! Fluent [`MessageBuilder`] for constructing RFC 5424 syslog messages.
2//!
3//! # Example
4//!
5//! ```rust,no_run
6//! use logfence_client::{MessageBuilder, UnixTransport};
7//! use logfence_proto::syslog::{Facility, Severity};
8//!
9//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
10//! let transport = UnixTransport::new("/run/logfenced/logfenced.sock", 65536);
11//!
12//! MessageBuilder::new(Facility::Local0, Severity::Info)
13//!     .app_name("myapp")
14//!     .msgid("REQUEST")
15//!     .kv("user_id", 42_u32)?
16//!     .kv("action", "login")?
17//!     .send(&transport)
18//!     .await?;
19//! # Ok(())
20//! # }
21//! ```
22
23use std::collections::BTreeMap;
24
25use serde_json::Value;
26
27use logfence_proto::syslog::{Facility, Priority, Severity, SyslogMessage};
28
29use crate::{
30    error::{BuildError, ClientError},
31    transport::Transport,
32};
33
34// ── MessageBuilder ────────────────────────────────────────────────────────────
35
36/// The MITRE CEE cookie prepended to the MSG field in CEE-formatted messages.
37pub const CEE_COOKIE: &str = "@cee:";
38
39/// Fluent builder for a single syslog message with a JSON key-value payload.
40///
41/// Fields default to the RFC 5424 nil value (`-`) unless set explicitly,
42/// except `proc_id` which defaults to the current process ID.
43///
44/// Keys provided to [`kv`](Self::kv) are sorted alphabetically in the
45/// serialized JSON object. Duplicate keys are deduplicated — the last value
46/// provided for a given key is used.
47///
48/// Call [`cee_cookie`](Self::cee_cookie) to prefix the JSON payload with
49/// `@cee:`, producing a MITRE CEE-compatible syslog message.
50#[derive(Debug)]
51pub struct MessageBuilder {
52    facility: Facility,
53    severity: Severity,
54    timestamp: Option<String>,
55    hostname: Option<String>,
56    app_name: Option<String>,
57    proc_id: Option<String>,
58    msg_id: Option<String>,
59    fields: BTreeMap<String, Value>,
60    cee_cookie: bool,
61}
62
63impl MessageBuilder {
64    /// Create a new builder with the given facility and severity.
65    ///
66    /// The process ID is populated automatically from [`std::process::id`].
67    /// All other optional header fields default to nil (`-`).
68    #[must_use]
69    pub fn new(facility: Facility, severity: Severity) -> Self {
70        Self {
71            facility,
72            severity,
73            timestamp: None,
74            hostname: None,
75            app_name: None,
76            proc_id: Some(std::process::id().to_string()),
77            msg_id: None,
78            fields: BTreeMap::new(),
79            cee_cookie: false,
80        }
81    }
82
83    /// Prefix the JSON payload with the MITRE CEE cookie (`@cee:`).
84    ///
85    /// When enabled, the built message body will be `@cee:{...}` instead of
86    /// `{...}`, making it compatible with CEE-aware log processors (e.g.
87    /// rsyslog's `mmjsonparse` module).
88    ///
89    /// The default is `false` (no CEE cookie).
90    #[must_use]
91    pub fn cee_cookie(mut self, enabled: bool) -> Self {
92        self.cee_cookie = enabled;
93        self
94    }
95
96    /// Set the RFC 5424 TIMESTAMP field (RFC 3339 formatted string).
97    ///
98    /// Use [`now_rfc3339`] to obtain the current UTC time without pulling
99    /// in an external time library.
100    #[must_use]
101    pub fn timestamp(mut self, ts: impl Into<String>) -> Self {
102        self.timestamp = Some(ts.into());
103        self
104    }
105
106    /// Set the RFC 5424 HOSTNAME field.
107    #[must_use]
108    pub fn hostname(mut self, h: impl Into<String>) -> Self {
109        self.hostname = Some(h.into());
110        self
111    }
112
113    /// Set the RFC 5424 APP-NAME field.
114    #[must_use]
115    pub fn app_name(mut self, name: impl Into<String>) -> Self {
116        self.app_name = Some(name.into());
117        self
118    }
119
120    /// Override the RFC 5424 PROCID field (defaults to the current PID).
121    #[must_use]
122    pub fn proc_id(mut self, pid: impl Into<String>) -> Self {
123        self.proc_id = Some(pid.into());
124        self
125    }
126
127    /// Set the RFC 5424 MSGID field.
128    #[must_use]
129    pub fn msgid(mut self, id: impl Into<String>) -> Self {
130        self.msg_id = Some(id.into());
131        self
132    }
133
134    /// Add a JSON key-value field to the message payload.
135    ///
136    /// `val` can be any type that implements [`serde::Serialize`]: strings,
137    /// integers, booleans, floats, nested objects, and arrays are all accepted.
138    ///
139    /// If the same `key` is added more than once, the last value is used.
140    ///
141    /// # Errors
142    ///
143    /// Returns [`BuildError::EmptyKey`] if `key` is the empty string.
144    /// Returns [`BuildError::Serialize`] if `val` cannot be serialized to JSON
145    /// (rare — only custom `Serialize` implementations can fail).
146    pub fn kv(
147        mut self,
148        key: impl Into<String>,
149        val: impl serde::Serialize,
150    ) -> Result<Self, BuildError> {
151        let key = key.into();
152        if key.is_empty() {
153            return Err(BuildError::EmptyKey);
154        }
155        let value = serde_json::to_value(val)?;
156        self.fields.insert(key, value);
157        Ok(self)
158    }
159
160    /// Assemble the accumulated fields into a [`SyslogMessage`].
161    ///
162    /// The JSON payload is serialized with keys in alphabetical order.
163    ///
164    /// # Errors
165    ///
166    /// Returns [`BuildError::Serialize`] if the field map cannot be serialized
167    /// to a JSON string (should not happen in practice for well-formed values).
168    pub fn build(self) -> Result<SyslogMessage, BuildError> {
169        let json = serde_json::to_string(&self.fields)?;
170        let msg = if self.cee_cookie {
171            format!("{CEE_COOKIE}{json}")
172        } else {
173            json
174        };
175        Ok(SyslogMessage {
176            priority: Priority {
177                facility: self.facility,
178                severity: self.severity,
179            },
180            timestamp: self.timestamp,
181            hostname: self.hostname,
182            app_name: self.app_name,
183            proc_id: self.proc_id,
184            msg_id: self.msg_id,
185            structured_data: "-".to_owned(),
186            msg,
187        })
188    }
189
190    /// Build the message and send it via `transport` in one step.
191    ///
192    /// # Errors
193    ///
194    /// Returns [`ClientError::Build`] if [`build`](Self::build) fails, or a
195    /// transport-specific error if delivery fails.
196    pub async fn send<T: Transport>(self, transport: &T) -> Result<(), ClientError> {
197        let msg = self.build()?;
198        transport.send(&msg).await
199    }
200}
201
202// ── Timestamp utility ─────────────────────────────────────────────────────────
203
204/// Return the current UTC time as an RFC 3339 string with seconds precision,
205/// e.g. `"2026-05-14T12:00:00Z"`.
206///
207/// Uses only `std::time::SystemTime` — no external time library required.
208/// Suitable for use with [`MessageBuilder::timestamp`].
209#[must_use]
210pub fn now_rfc3339() -> String {
211    use std::time::{SystemTime, UNIX_EPOCH};
212    let secs = SystemTime::now()
213        .duration_since(UNIX_EPOCH)
214        .unwrap_or_default()
215        .as_secs();
216    epoch_secs_to_rfc3339(secs)
217}
218
219fn epoch_secs_to_rfc3339(secs: u64) -> String {
220    let days = secs / 86_400;
221    let rem = secs % 86_400;
222    let h = rem / 3_600;
223    let m = (rem % 3_600) / 60;
224    let s = rem % 60;
225    let (year, month, day) = civil_from_days(days);
226    format!("{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}Z")
227}
228
229/// Howard Hinnant's algorithm: days since the Unix epoch → (year, month, day).
230///
231/// Reference: <https://howardhinnant.github.io/date_algorithms.html#civil_from_days>
232///
233/// All intermediate values are provably bounded for any Unix timestamp
234/// representable as a `u64`, so the `as` casts are safe.
235#[allow(
236    clippy::cast_sign_loss,
237    clippy::cast_possible_truncation,
238    clippy::cast_possible_wrap,
239    reason = "intermediate values are mathematically bounded by the Unix epoch range; \
240              results fit in i32/u32 for any date within ±millions of years"
241)]
242fn civil_from_days(days: u64) -> (i32, u32, u32) {
243    let z: i64 = days as i64 + 719_468;
244    let era: i64 = if z >= 0 { z } else { z - 146_096 } / 146_097;
245    let doe: u64 = (z - era * 146_097) as u64;
246    let yoe: u64 = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
247    let y: i64 = yoe as i64 + era * 400;
248    let doy: u64 = doe - (365 * yoe + yoe / 4 - yoe / 100);
249    let mp: u64 = (5 * doy + 2) / 153;
250    let d: u64 = doy - (153 * mp + 2) / 5 + 1;
251    let m: u64 = if mp < 10 { mp + 3 } else { mp - 9 };
252    let y = if m <= 2 { y + 1 } else { y };
253    (y as i32, m as u32, d as u32)
254}
255
256// ── Tests ─────────────────────────────────────────────────────────────────────
257
258#[cfg(test)]
259#[allow(
260    clippy::unwrap_used,
261    reason = "unwrap is appropriate in test assertions"
262)]
263mod tests {
264    use logfence_proto::syslog::{Facility, Severity};
265
266    use super::*;
267
268    // ── MessageBuilder ────────────────────────────────────────────────────────
269
270    #[test]
271    fn build_defaults() {
272        let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
273            .build()
274            .unwrap();
275
276        assert_eq!(msg.priority.as_integer(), 134); // Local0=16, Info=6 → 16*8+6=134
277        assert!(msg.timestamp.is_none());
278        assert!(msg.hostname.is_none());
279        assert!(msg.app_name.is_none());
280        // proc_id is auto-set to current PID
281        assert!(msg.proc_id.is_some());
282        assert!(msg.msg_id.is_none());
283        assert_eq!(msg.structured_data, "-");
284        assert_eq!(msg.msg, "{}"); // empty JSON object
285    }
286
287    #[test]
288    fn build_with_all_header_fields() {
289        let msg = MessageBuilder::new(Facility::Daemon, Severity::Warning)
290            .timestamp("2026-05-14T12:00:00Z")
291            .hostname("myhost")
292            .app_name("myapp")
293            .proc_id("9999")
294            .msgid("AUDIT")
295            .build()
296            .unwrap();
297
298        assert_eq!(msg.timestamp.as_deref(), Some("2026-05-14T12:00:00Z"));
299        assert_eq!(msg.hostname.as_deref(), Some("myhost"));
300        assert_eq!(msg.app_name.as_deref(), Some("myapp"));
301        assert_eq!(msg.proc_id.as_deref(), Some("9999"));
302        assert_eq!(msg.msg_id.as_deref(), Some("AUDIT"));
303    }
304
305    #[test]
306    fn build_kv_string() {
307        let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
308            .kv("action", "login")
309            .unwrap()
310            .build()
311            .unwrap();
312
313        let v: serde_json::Value = serde_json::from_str(&msg.msg).unwrap();
314        assert_eq!(v["action"], "login");
315    }
316
317    #[test]
318    fn build_kv_integer() {
319        let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
320            .kv("user_id", 42_u32)
321            .unwrap()
322            .build()
323            .unwrap();
324
325        let v: serde_json::Value = serde_json::from_str(&msg.msg).unwrap();
326        assert_eq!(v["user_id"], 42);
327    }
328
329    #[test]
330    fn build_kv_bool() {
331        let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
332            .kv("success", true)
333            .unwrap()
334            .build()
335            .unwrap();
336
337        let v: serde_json::Value = serde_json::from_str(&msg.msg).unwrap();
338        assert_eq!(v["success"], true);
339    }
340
341    #[test]
342    fn build_kv_nested_object() {
343        let nested = serde_json::json!({"code": 200, "reason": "OK"});
344        let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
345            .kv("response", nested)
346            .unwrap()
347            .build()
348            .unwrap();
349
350        let v: serde_json::Value = serde_json::from_str(&msg.msg).unwrap();
351        assert_eq!(v["response"]["code"], 200);
352    }
353
354    #[test]
355    fn build_kv_multiple_fields_sorted() {
356        let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
357            .kv("z_last", 3_u32)
358            .unwrap()
359            .kv("a_first", 1_u32)
360            .unwrap()
361            .kv("m_middle", 2_u32)
362            .unwrap()
363            .build()
364            .unwrap();
365
366        // BTreeMap serializes keys in alphabetical order.
367        assert_eq!(msg.msg, r#"{"a_first":1,"m_middle":2,"z_last":3}"#);
368    }
369
370    #[test]
371    fn build_kv_duplicate_key_last_wins() {
372        let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
373            .kv("key", "first")
374            .unwrap()
375            .kv("key", "second")
376            .unwrap()
377            .build()
378            .unwrap();
379
380        let v: serde_json::Value = serde_json::from_str(&msg.msg).unwrap();
381        assert_eq!(v["key"], "second");
382    }
383
384    #[test]
385    fn build_kv_empty_key_rejected() {
386        let err = MessageBuilder::new(Facility::Local0, Severity::Info)
387            .kv("", "value")
388            .unwrap_err();
389        assert!(matches!(err, BuildError::EmptyKey));
390    }
391
392    #[test]
393    fn build_produces_valid_syslog_message() {
394        // The built message must round-trip through the RFC 5424 parser.
395        let msg = MessageBuilder::new(Facility::Local7, Severity::Debug)
396            .app_name("roundtrip")
397            .msgid("TEST")
398            .kv("hello", "world")
399            .unwrap()
400            .build()
401            .unwrap();
402
403        let wire = msg.to_string();
404        let parsed = SyslogMessage::parse(&wire).unwrap();
405        assert_eq!(parsed, msg);
406    }
407
408    // ── now_rfc3339 ───────────────────────────────────────────────────────────
409
410    #[test]
411    fn now_rfc3339_format() {
412        let ts = now_rfc3339();
413        // Basic structural check: YYYY-MM-DDTHH:MM:SSZ
414        assert_eq!(ts.len(), 20);
415        assert_eq!(&ts[4..5], "-");
416        assert_eq!(&ts[7..8], "-");
417        assert_eq!(&ts[10..11], "T");
418        assert_eq!(&ts[13..14], ":");
419        assert_eq!(&ts[16..17], ":");
420        assert_eq!(&ts[19..20], "Z");
421    }
422
423    // ── CEE cookie ────────────────────────────────────────────────────────────
424
425    #[test]
426    fn build_cee_cookie_prefixes_json() {
427        let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
428            .cee_cookie(true)
429            .kv("event", "login")
430            .unwrap()
431            .build()
432            .unwrap();
433
434        assert!(
435            msg.msg.starts_with("@cee:"),
436            "expected @cee: prefix, got: {}",
437            msg.msg
438        );
439        let json_part = &msg.msg["@cee:".len()..];
440        let v: serde_json::Value = serde_json::from_str(json_part).unwrap();
441        assert_eq!(v["event"], "login");
442    }
443
444    #[test]
445    fn build_without_cee_cookie_has_no_prefix() {
446        let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
447            .kv("event", "login")
448            .unwrap()
449            .build()
450            .unwrap();
451
452        assert!(
453            !msg.msg.starts_with("@cee:"),
454            "expected no @cee: prefix, got: {}",
455            msg.msg
456        );
457    }
458
459    #[test]
460    fn build_cee_cookie_empty_fields() {
461        let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
462            .cee_cookie(true)
463            .build()
464            .unwrap();
465
466        assert_eq!(msg.msg, "@cee:{}");
467    }
468
469    #[test]
470    fn build_cee_cookie_multiple_fields_sorted() {
471        let msg = MessageBuilder::new(Facility::Local0, Severity::Info)
472            .cee_cookie(true)
473            .kv("z", 3_u32)
474            .unwrap()
475            .kv("a", 1_u32)
476            .unwrap()
477            .build()
478            .unwrap();
479
480        assert_eq!(msg.msg, r#"@cee:{"a":1,"z":3}"#);
481    }
482
483    #[test]
484    fn epoch_secs_known_dates() {
485        // Unix epoch itself
486        assert_eq!(epoch_secs_to_rfc3339(0), "1970-01-01T00:00:00Z");
487        // 2000-01-01T00:00:00Z = 946_684_800
488        assert_eq!(epoch_secs_to_rfc3339(946_684_800), "2000-01-01T00:00:00Z");
489        // Leap day: 2000-02-29T00:00:00Z = 951_782_400
490        assert_eq!(epoch_secs_to_rfc3339(951_782_400), "2000-02-29T00:00:00Z");
491        // 2026-05-14T00:00:00Z = 1_778_716_800 (= 1_747_180_800 + 365*86400, 2025→2026)
492        assert_eq!(epoch_secs_to_rfc3339(1_778_716_800), "2026-05-14T00:00:00Z");
493        // Time component: 2026-05-14T13:45:30Z
494        assert_eq!(
495            epoch_secs_to_rfc3339(1_778_716_800 + 13 * 3_600 + 45 * 60 + 30),
496            "2026-05-14T13:45:30Z"
497        );
498    }
499}