Skip to main content

sonda_core/encoder/
syslog.rs

1//! Syslog encoder (RFC 5424).
2//!
3//! Encodes [`LogEvent`]s in the RFC 5424 syslog format. Each encoded event is a single
4//! line terminated with `\n`.
5//!
6//! RFC 5424 format:
7//! ```text
8//! <priority>VERSION TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [STRUCTURED-DATA] MSG
9//! ```
10//!
11//! Example output (no labels):
12//! ```text
13//! <14>1 2026-03-20T12:00:00.000Z sonda sonda - - - Request from 10.0.0.1\n
14//! ```
15//!
16//! Example output (with labels):
17//! ```text
18//! <14>1 2026-03-20T12:00:00.000Z sonda sonda - - [sonda device="wlan0" hostname="router-01"] Request from 10.0.0.1\n
19//! ```
20//!
21//! Priority is computed as `(facility * 8) + severity`. This encoder uses facility 1
22//! (user-level messages) per RFC 5424 §6.2.1.
23//!
24//! When labels are present, the structured data section contains a `[sonda ...]`
25//! element with label key-value pairs. When labels are empty, the structured data
26//! section is the nil value (`-`). The PROCID and MSGID fields are always nil (`-`).
27//!
28//! Param-values are escaped per RFC 5424 §6.3.3: `\`, `]`, and `"` are prefixed
29//! with a backslash.
30//!
31//! No external crates are needed — the format is constructed entirely via `write!`
32//! into the caller-provided buffer.
33
34use crate::model::log::{LogEvent, Severity};
35use crate::model::metric::MetricEvent;
36use crate::{EncoderError, SondaError};
37
38use super::Encoder;
39
40/// RFC 5424 syslog facility for user-level messages.
41const FACILITY_USER: u8 = 1;
42
43/// RFC 5424 syslog version.
44const SYSLOG_VERSION: u8 = 1;
45
46/// Nil value for optional RFC 5424 header fields (PROCID, MSGID, SD).
47const NILVALUE: &str = "-";
48
49/// Maps a [`Severity`] to the corresponding RFC 5424 numeric severity code.
50///
51/// RFC 5424 §6.2.1 defines severity codes 0–7:
52/// - 0 Emergency
53/// - 1 Alert
54/// - 2 Critical
55/// - 3 Error
56/// - 4 Warning
57/// - 5 Notice
58/// - 6 Informational
59/// - 7 Debug
60fn severity_to_syslog(severity: Severity) -> u8 {
61    match severity {
62        Severity::Fatal => 0, // Emergency
63        Severity::Error => 3, // Error
64        Severity::Warn => 4,  // Warning
65        Severity::Info => 6,  // Informational
66        Severity::Debug => 7, // Debug
67        Severity::Trace => 7, // Debug (no finer-grained syslog severity)
68    }
69}
70
71/// Encodes [`LogEvent`]s in RFC 5424 syslog format.
72///
73/// The hostname and app-name fields in the syslog header are configurable at construction
74/// time. They default to `"sonda"` and `"sonda"` respectively.
75///
76/// Only `encode_log` is supported. `encode_metric` returns an error because syslog is a
77/// log-only format.
78pub struct Syslog {
79    /// The HOSTNAME field in the syslog header.
80    hostname: String,
81    /// The APP-NAME field in the syslog header.
82    app_name: String,
83}
84
85impl Syslog {
86    /// Create a new `Syslog` encoder with the given hostname and app-name.
87    ///
88    /// # Arguments
89    ///
90    /// * `hostname` — The HOSTNAME field. Defaults to `"sonda"` if `None`.
91    /// * `app_name` — The APP-NAME field. Defaults to `"sonda"` if `None`.
92    pub fn new(hostname: Option<String>, app_name: Option<String>) -> Self {
93        Self {
94            hostname: hostname.unwrap_or_else(|| "sonda".to_string()),
95            app_name: app_name.unwrap_or_else(|| "sonda".to_string()),
96        }
97    }
98}
99
100impl Default for Syslog {
101    fn default() -> Self {
102        Self::new(None, None)
103    }
104}
105
106impl Encoder for Syslog {
107    /// Syslog encodes only log events. Returns an error for metric events.
108    fn encode_metric(
109        &self,
110        _event: &MetricEvent,
111        _buf: &mut Vec<u8>,
112    ) -> Result<(), crate::SondaError> {
113        Err(SondaError::Encoder(EncoderError::NotSupported(
114            "metric encoding not supported by syslog encoder".into(),
115        )))
116    }
117
118    /// Encode a log event as an RFC 5424 syslog line appended to `buf`.
119    ///
120    /// Format (no labels): `<priority>1 timestamp hostname app-name - - - message\n`
121    /// Format (with labels): `<priority>1 timestamp hostname app-name - - [sonda k="v" ...] message\n`
122    ///
123    /// Priority = (facility * 8) + syslog_severity. Facility 1 (user-level) is used.
124    fn encode_log(&self, event: &LogEvent, buf: &mut Vec<u8>) -> Result<(), SondaError> {
125        use std::io::Write;
126
127        let syslog_severity = severity_to_syslog(event.severity);
128        let priority = FACILITY_USER * 8 + syslog_severity;
129
130        // Write the priority, version, and space before timestamp.
131        write!(buf, "<{priority}>{version} ", version = SYSLOG_VERSION)
132            .expect("write to Vec<u8> is infallible");
133
134        // Write the RFC 3339 timestamp directly into the output buffer (zero-alloc).
135        super::format_rfc3339_millis(event.timestamp, buf)?;
136
137        // Write the rest of the header fields.
138        write!(
139            buf,
140            " {hostname} {app_name} {procid} {msgid} ",
141            hostname = self.hostname,
142            app_name = self.app_name,
143            procid = NILVALUE,
144            msgid = NILVALUE,
145        )
146        .expect("write to Vec<u8> is infallible");
147
148        // Write structured data section: nil when no labels, [sonda k="v" ...]
149        // when labels are present.
150        if event.labels.is_empty() {
151            buf.extend_from_slice(NILVALUE.as_bytes());
152        } else {
153            buf.extend_from_slice(b"[sonda");
154            for (k, v) in event.labels.iter() {
155                buf.push(b' ');
156                buf.extend_from_slice(k.as_bytes());
157                buf.extend_from_slice(b"=\"");
158                // Escape \, ], " per RFC 5424 §6.3.3
159                for ch in v.bytes() {
160                    match ch {
161                        b'\\' => buf.extend_from_slice(b"\\\\"),
162                        b']' => buf.extend_from_slice(b"\\]"),
163                        b'"' => buf.extend_from_slice(b"\\\""),
164                        _ => buf.push(ch),
165                    }
166                }
167                buf.push(b'"');
168            }
169            buf.push(b']');
170        }
171
172        // Write message and trailing newline.
173        buf.push(b' ');
174        buf.extend_from_slice(event.message.as_bytes());
175        buf.push(b'\n');
176
177        Ok(())
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use std::collections::BTreeMap;
184    use std::time::{Duration, UNIX_EPOCH};
185
186    use crate::model::log::{LogEvent, Severity};
187
188    use super::*;
189
190    /// Build a LogEvent with a fixed timestamp for deterministic tests.
191    fn make_log_event(
192        severity: Severity,
193        message: &str,
194        fields: &[(&str, &str)],
195        ts: std::time::SystemTime,
196    ) -> LogEvent {
197        let mut map = BTreeMap::new();
198        for (k, v) in fields {
199            map.insert(k.to_string(), v.to_string());
200        }
201        LogEvent::with_timestamp(
202            ts,
203            severity,
204            message.to_string(),
205            crate::model::metric::Labels::default(),
206            map,
207        )
208    }
209
210    // -----------------------------------------------------------------------
211    // encode_metric: must return an error (syslog is log-only)
212    // -----------------------------------------------------------------------
213
214    #[test]
215    fn encode_metric_returns_not_supported_error() {
216        use crate::model::metric::{Labels, MetricEvent};
217        let labels = Labels::from_pairs(&[]).unwrap();
218        let event =
219            MetricEvent::with_timestamp("cpu".to_string(), 1.0, labels, UNIX_EPOCH).unwrap();
220        let encoder = Syslog::default();
221        let mut buf = Vec::new();
222        let result = encoder.encode_metric(&event, &mut buf);
223        assert!(
224            result.is_err(),
225            "syslog encoder must return error for encode_metric"
226        );
227        let msg = result.unwrap_err().to_string();
228        assert!(
229            msg.contains("metric encoding not supported"),
230            "error message must mention 'metric encoding not supported', got: {msg}"
231        );
232    }
233
234    #[test]
235    fn encode_metric_does_not_write_to_buffer() {
236        use crate::model::metric::{Labels, MetricEvent};
237        let labels = Labels::from_pairs(&[]).unwrap();
238        let event = MetricEvent::with_timestamp("up".to_string(), 1.0, labels, UNIX_EPOCH).unwrap();
239        let encoder = Syslog::default();
240        let mut buf = Vec::new();
241        let _ = encoder.encode_metric(&event, &mut buf);
242        assert!(
243            buf.is_empty(),
244            "buffer must remain empty when encode_metric returns error"
245        );
246    }
247
248    // -----------------------------------------------------------------------
249    // encode_log: happy path — valid RFC 5424 format
250    // -----------------------------------------------------------------------
251
252    #[test]
253    fn encode_log_produces_line_ending_with_newline() {
254        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
255        let event = make_log_event(Severity::Info, "hello", &[], ts);
256        let encoder = Syslog::default();
257        let mut buf = Vec::new();
258        encoder.encode_log(&event, &mut buf).unwrap();
259        assert_eq!(
260            *buf.last().unwrap(),
261            b'\n',
262            "syslog line must end with newline"
263        );
264    }
265
266    #[test]
267    fn encode_log_starts_with_priority_marker() {
268        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
269        let event = make_log_event(Severity::Info, "hello", &[], ts);
270        let encoder = Syslog::default();
271        let mut buf = Vec::new();
272        encoder.encode_log(&event, &mut buf).unwrap();
273        let line = String::from_utf8(buf).unwrap();
274        assert!(
275            line.starts_with('<'),
276            "syslog line must start with '<': {line}"
277        );
278    }
279
280    #[test]
281    fn encode_log_contains_version_one() {
282        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
283        let event = make_log_event(Severity::Info, "test", &[], ts);
284        let encoder = Syslog::default();
285        let mut buf = Vec::new();
286        encoder.encode_log(&event, &mut buf).unwrap();
287        let line = String::from_utf8(buf).unwrap();
288        // After the priority, the next token is the version number
289        let after_priority = line.find('>').unwrap();
290        let version_token: &str = line[after_priority + 1..]
291            .split_whitespace()
292            .next()
293            .unwrap();
294        assert_eq!(version_token, "1", "RFC 5424 version must be 1");
295    }
296
297    #[test]
298    fn encode_log_contains_hostname_in_output() {
299        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
300        let event = make_log_event(Severity::Info, "hello", &[], ts);
301        let encoder = Syslog::new(Some("myhost".to_string()), None);
302        let mut buf = Vec::new();
303        encoder.encode_log(&event, &mut buf).unwrap();
304        let line = String::from_utf8(buf).unwrap();
305        assert!(
306            line.contains("myhost"),
307            "syslog line must contain hostname 'myhost': {line}"
308        );
309    }
310
311    #[test]
312    fn encode_log_contains_app_name_in_output() {
313        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
314        let event = make_log_event(Severity::Info, "hello", &[], ts);
315        let encoder = Syslog::new(None, Some("myapp".to_string()));
316        let mut buf = Vec::new();
317        encoder.encode_log(&event, &mut buf).unwrap();
318        let line = String::from_utf8(buf).unwrap();
319        assert!(
320            line.contains("myapp"),
321            "syslog line must contain app-name 'myapp': {line}"
322        );
323    }
324
325    #[test]
326    fn encode_log_default_hostname_and_app_name_are_sonda() {
327        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
328        let event = make_log_event(Severity::Info, "hello", &[], ts);
329        let encoder = Syslog::default();
330        let mut buf = Vec::new();
331        encoder.encode_log(&event, &mut buf).unwrap();
332        let line = String::from_utf8(buf).unwrap();
333        // "sonda sonda" should appear as consecutive tokens (hostname app_name)
334        assert!(
335            line.contains("sonda sonda"),
336            "default hostname and app_name must both be 'sonda': {line}"
337        );
338    }
339
340    #[test]
341    fn encode_log_contains_message_in_output() {
342        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
343        let event = make_log_event(Severity::Info, "request completed", &[], ts);
344        let encoder = Syslog::default();
345        let mut buf = Vec::new();
346        encoder.encode_log(&event, &mut buf).unwrap();
347        let line = String::from_utf8(buf).unwrap();
348        assert!(
349            line.contains("request completed"),
350            "syslog line must contain the message: {line}"
351        );
352    }
353
354    // -----------------------------------------------------------------------
355    // encode_log: priority calculation — (facility * 8) + syslog_severity
356    // Facility 1 (user-level). Facility bits = 1 * 8 = 8.
357    // -----------------------------------------------------------------------
358
359    fn extract_priority(buf: &[u8]) -> u8 {
360        let line = std::str::from_utf8(buf).unwrap();
361        let end = line.find('>').expect("syslog line must contain '>'");
362        line[1..end]
363            .parse::<u8>()
364            .expect("priority must be a number")
365    }
366
367    #[test]
368    fn priority_for_trace_is_facility_user_plus_debug_syslog_severity() {
369        // Trace maps to Debug (7) in syslog. Facility 1: 1*8 + 7 = 15
370        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
371        let event = make_log_event(Severity::Trace, "trace msg", &[], ts);
372        let encoder = Syslog::default();
373        let mut buf = Vec::new();
374        encoder.encode_log(&event, &mut buf).unwrap();
375        let priority = extract_priority(&buf);
376        assert_eq!(
377            priority, 15,
378            "Trace priority must be 15 (facility=1, severity=7)"
379        );
380    }
381
382    #[test]
383    fn priority_for_debug_is_facility_user_plus_debug_syslog_severity() {
384        // Debug maps to 7 in syslog. 1*8 + 7 = 15
385        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
386        let event = make_log_event(Severity::Debug, "debug msg", &[], ts);
387        let encoder = Syslog::default();
388        let mut buf = Vec::new();
389        encoder.encode_log(&event, &mut buf).unwrap();
390        let priority = extract_priority(&buf);
391        assert_eq!(
392            priority, 15,
393            "Debug priority must be 15 (facility=1, severity=7)"
394        );
395    }
396
397    #[test]
398    fn priority_for_info_is_facility_user_plus_informational_syslog_severity() {
399        // Info maps to 6 (Informational). 1*8 + 6 = 14
400        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
401        let event = make_log_event(Severity::Info, "info msg", &[], ts);
402        let encoder = Syslog::default();
403        let mut buf = Vec::new();
404        encoder.encode_log(&event, &mut buf).unwrap();
405        let priority = extract_priority(&buf);
406        assert_eq!(
407            priority, 14,
408            "Info priority must be 14 (facility=1, severity=6)"
409        );
410    }
411
412    #[test]
413    fn priority_for_warn_is_facility_user_plus_warning_syslog_severity() {
414        // Warn maps to 4 (Warning). 1*8 + 4 = 12
415        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
416        let event = make_log_event(Severity::Warn, "warn msg", &[], ts);
417        let encoder = Syslog::default();
418        let mut buf = Vec::new();
419        encoder.encode_log(&event, &mut buf).unwrap();
420        let priority = extract_priority(&buf);
421        assert_eq!(
422            priority, 12,
423            "Warn priority must be 12 (facility=1, severity=4)"
424        );
425    }
426
427    #[test]
428    fn priority_for_error_is_facility_user_plus_error_syslog_severity() {
429        // Error maps to 3 (Error). 1*8 + 3 = 11
430        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
431        let event = make_log_event(Severity::Error, "error msg", &[], ts);
432        let encoder = Syslog::default();
433        let mut buf = Vec::new();
434        encoder.encode_log(&event, &mut buf).unwrap();
435        let priority = extract_priority(&buf);
436        assert_eq!(
437            priority, 11,
438            "Error priority must be 11 (facility=1, severity=3)"
439        );
440    }
441
442    #[test]
443    fn priority_for_fatal_is_facility_user_plus_emergency_syslog_severity() {
444        // Fatal maps to 0 (Emergency). 1*8 + 0 = 8
445        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
446        let event = make_log_event(Severity::Fatal, "fatal msg", &[], ts);
447        let encoder = Syslog::default();
448        let mut buf = Vec::new();
449        encoder.encode_log(&event, &mut buf).unwrap();
450        let priority = extract_priority(&buf);
451        assert_eq!(
452            priority, 8,
453            "Fatal priority must be 8 (facility=1, severity=0)"
454        );
455    }
456
457    // -----------------------------------------------------------------------
458    // encode_log: RFC 5424 format structure — nil values for PROCID, MSGID, SD
459    // -----------------------------------------------------------------------
460
461    #[test]
462    fn encode_log_contains_nil_values_for_procid_msgid_and_sd() {
463        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
464        let event = make_log_event(Severity::Info, "hello", &[], ts);
465        let encoder = Syslog::default();
466        let mut buf = Vec::new();
467        encoder.encode_log(&event, &mut buf).unwrap();
468        let line = String::from_utf8(buf).unwrap();
469        // After the header fields we expect three consecutive nil values (- - -)
470        assert!(
471            line.contains("- - -"),
472            "syslog line must contain '- - -' (PROCID MSGID SD): {line}"
473        );
474    }
475
476    // -----------------------------------------------------------------------
477    // encode_log: timestamp format — RFC 3339 with millisecond precision
478    // -----------------------------------------------------------------------
479
480    #[test]
481    fn encode_log_timestamp_is_rfc3339_with_millisecond_precision() {
482        // 2026-03-20T12:00:00.000Z = 1774008000 Unix seconds
483        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
484        let event = make_log_event(Severity::Info, "hello", &[], ts);
485        let encoder = Syslog::default();
486        let mut buf = Vec::new();
487        encoder.encode_log(&event, &mut buf).unwrap();
488        let line = String::from_utf8(buf).unwrap();
489        assert!(
490            line.contains("2026-03-20T12:00:00.000Z"),
491            "syslog line must contain RFC 3339 timestamp: {line}"
492        );
493    }
494
495    // -----------------------------------------------------------------------
496    // encode_log: message with special characters
497    // -----------------------------------------------------------------------
498
499    #[test]
500    fn encode_log_message_with_spaces_is_included_verbatim() {
501        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
502        let event = make_log_event(
503            Severity::Info,
504            "Request from 10.0.0.1 to /api/v2/metrics",
505            &[],
506            ts,
507        );
508        let encoder = Syslog::default();
509        let mut buf = Vec::new();
510        encoder.encode_log(&event, &mut buf).unwrap();
511        let line = String::from_utf8(buf).unwrap();
512        assert!(
513            line.contains("Request from 10.0.0.1 to /api/v2/metrics"),
514            "message with spaces must be preserved: {line}"
515        );
516    }
517
518    #[test]
519    fn encode_log_message_with_unicode_characters() {
520        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
521        let event = make_log_event(Severity::Warn, "Ошибка: сервер недоступен", &[], ts);
522        let encoder = Syslog::default();
523        let mut buf = Vec::new();
524        encoder.encode_log(&event, &mut buf).unwrap();
525        let line = String::from_utf8(buf).unwrap();
526        assert!(
527            line.contains("Ошибка: сервер недоступен"),
528            "unicode message must be preserved: {line}"
529        );
530    }
531
532    #[test]
533    fn encode_log_message_with_angle_brackets() {
534        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
535        let event = make_log_event(Severity::Error, "value <nil> detected", &[], ts);
536        let encoder = Syslog::default();
537        let mut buf = Vec::new();
538        encoder.encode_log(&event, &mut buf).unwrap();
539        let line = String::from_utf8(buf).unwrap();
540        assert!(
541            line.contains("value <nil> detected"),
542            "message with angle brackets must be preserved: {line}"
543        );
544    }
545
546    // -----------------------------------------------------------------------
547    // encode_log: regression anchor — exact byte output
548    // -----------------------------------------------------------------------
549
550    #[test]
551    fn regression_anchor_info_severity_exact_output() {
552        // Timestamp: 2026-03-20T12:00:00.000Z = 1774008000 Unix seconds
553        // Severity::Info -> syslog 6, priority = 1*8 + 6 = 14
554        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
555        let event = make_log_event(Severity::Info, "Request from 10.0.0.1", &[], ts);
556        let encoder = Syslog::new(Some("sonda".to_string()), Some("sonda".to_string()));
557        let mut buf = Vec::new();
558        encoder.encode_log(&event, &mut buf).unwrap();
559        let output = String::from_utf8(buf).unwrap();
560        assert_eq!(
561            output,
562            "<14>1 2026-03-20T12:00:00.000Z sonda sonda - - - Request from 10.0.0.1\n"
563        );
564    }
565
566    #[test]
567    fn regression_anchor_error_severity_exact_output() {
568        // Severity::Error -> syslog 3, priority = 1*8 + 3 = 11
569        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
570        let event = make_log_event(Severity::Error, "connection refused", &[], ts);
571        let encoder = Syslog::new(Some("web01".to_string()), Some("nginx".to_string()));
572        let mut buf = Vec::new();
573        encoder.encode_log(&event, &mut buf).unwrap();
574        let output = String::from_utf8(buf).unwrap();
575        assert_eq!(
576            output,
577            "<11>1 2026-03-20T12:00:00.000Z web01 nginx - - - connection refused\n"
578        );
579    }
580
581    #[test]
582    fn regression_anchor_fatal_severity_exact_output() {
583        // Severity::Fatal -> syslog 0 (Emergency), priority = 1*8 + 0 = 8
584        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
585        let event = make_log_event(Severity::Fatal, "system crash", &[], ts);
586        let encoder = Syslog::default();
587        let mut buf = Vec::new();
588        encoder.encode_log(&event, &mut buf).unwrap();
589        let output = String::from_utf8(buf).unwrap();
590        assert_eq!(
591            output,
592            "<8>1 2026-03-20T12:00:00.000Z sonda sonda - - - system crash\n"
593        );
594    }
595
596    // -----------------------------------------------------------------------
597    // encode_log: labels in structured data section
598    // -----------------------------------------------------------------------
599
600    /// Build a LogEvent with labels for testing structured data output.
601    fn make_log_event_with_labels(
602        severity: Severity,
603        message: &str,
604        labels: &[(&str, &str)],
605        fields: &[(&str, &str)],
606        ts: std::time::SystemTime,
607    ) -> LogEvent {
608        let mut field_map = BTreeMap::new();
609        for (k, v) in fields {
610            field_map.insert(k.to_string(), v.to_string());
611        }
612        let label_set = crate::model::metric::Labels::from_pairs(labels).unwrap();
613        LogEvent::with_timestamp(ts, severity, message.to_string(), label_set, field_map)
614    }
615
616    #[test]
617    fn encode_log_with_labels_includes_structured_data() {
618        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
619        let event = make_log_event_with_labels(
620            Severity::Info,
621            "labeled event",
622            &[("device", "wlan0")],
623            &[],
624            ts,
625        );
626        let encoder = Syslog::default();
627        let mut buf = Vec::new();
628        encoder.encode_log(&event, &mut buf).unwrap();
629        let line = String::from_utf8(buf).unwrap();
630        assert!(
631            line.contains("[sonda device=\"wlan0\"]"),
632            "syslog line must contain structured data [sonda device=\"wlan0\"]: {line}"
633        );
634    }
635
636    #[test]
637    fn encode_log_with_multiple_labels_includes_all_in_structured_data() {
638        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
639        let event = make_log_event_with_labels(
640            Severity::Info,
641            "multi-label event",
642            &[("device", "wlan0"), ("hostname", "router_01")],
643            &[],
644            ts,
645        );
646        let encoder = Syslog::default();
647        let mut buf = Vec::new();
648        encoder.encode_log(&event, &mut buf).unwrap();
649        let line = String::from_utf8(buf).unwrap();
650        // Labels are sorted by key (BTreeMap), so device comes before hostname
651        assert!(
652            line.contains("[sonda device=\"wlan0\" hostname=\"router_01\"]"),
653            "syslog line must contain sorted labels in structured data: {line}"
654        );
655    }
656
657    #[test]
658    fn encode_log_without_labels_uses_nil_structured_data() {
659        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
660        let event = make_log_event(Severity::Info, "no labels", &[], ts);
661        let encoder = Syslog::default();
662        let mut buf = Vec::new();
663        encoder.encode_log(&event, &mut buf).unwrap();
664        let line = String::from_utf8(buf).unwrap();
665        // Without labels, SD should be nil: "- - -" pattern (PROCID MSGID SD)
666        assert!(
667            line.contains("- - -"),
668            "syslog line without labels must use nil SD (- - -): {line}"
669        );
670        assert!(
671            !line.contains("[sonda"),
672            "syslog line without labels must not contain [sonda: {line}"
673        );
674    }
675
676    #[test]
677    fn encode_log_with_labels_escapes_backslash_in_value() {
678        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
679        let event = make_log_event_with_labels(
680            Severity::Info,
681            "escape test",
682            &[("path", "C:\\Users\\admin")],
683            &[],
684            ts,
685        );
686        let encoder = Syslog::default();
687        let mut buf = Vec::new();
688        encoder.encode_log(&event, &mut buf).unwrap();
689        let line = String::from_utf8(buf).unwrap();
690        // Backslashes in values must be escaped to \\ per RFC 5424 §6.3.3
691        assert!(
692            line.contains("path=\"C:\\\\Users\\\\admin\""),
693            "backslashes in label values must be escaped: {line}"
694        );
695    }
696
697    #[test]
698    fn encode_log_with_labels_escapes_closing_bracket_in_value() {
699        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
700        let event = make_log_event_with_labels(
701            Severity::Info,
702            "bracket test",
703            &[("tag", "foo]bar")],
704            &[],
705            ts,
706        );
707        let encoder = Syslog::default();
708        let mut buf = Vec::new();
709        encoder.encode_log(&event, &mut buf).unwrap();
710        let line = String::from_utf8(buf).unwrap();
711        // Closing brackets must be escaped to \] per RFC 5424 §6.3.3
712        assert!(
713            line.contains("tag=\"foo\\]bar\""),
714            "closing bracket in label value must be escaped: {line}"
715        );
716    }
717
718    #[test]
719    fn encode_log_with_labels_escapes_double_quote_in_value() {
720        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
721        let event = make_log_event_with_labels(
722            Severity::Info,
723            "quote test",
724            &[("desc", "it said \"hello\"")],
725            &[],
726            ts,
727        );
728        let encoder = Syslog::default();
729        let mut buf = Vec::new();
730        encoder.encode_log(&event, &mut buf).unwrap();
731        let line = String::from_utf8(buf).unwrap();
732        // Double quotes must be escaped to \" per RFC 5424 §6.3.3
733        assert!(
734            line.contains("desc=\"it said \\\"hello\\\"\""),
735            "double quotes in label value must be escaped: {line}"
736        );
737    }
738
739    #[test]
740    fn encode_log_with_labels_escapes_all_special_characters_combined() {
741        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
742        let event = make_log_event_with_labels(
743            Severity::Info,
744            "combined escape",
745            &[("mixed", "a\\b]c\"d")],
746            &[],
747            ts,
748        );
749        let encoder = Syslog::default();
750        let mut buf = Vec::new();
751        encoder.encode_log(&event, &mut buf).unwrap();
752        let line = String::from_utf8(buf).unwrap();
753        // All three special chars must be escaped
754        assert!(
755            line.contains("mixed=\"a\\\\b\\]c\\\"d\""),
756            "all special characters must be escaped: {line}"
757        );
758    }
759
760    #[test]
761    fn regression_anchor_info_severity_with_labels_exact_output() {
762        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
763        let event = make_log_event_with_labels(
764            Severity::Info,
765            "Request from 10.0.0.1",
766            &[("device", "wlan0"), ("hostname", "router_01")],
767            &[],
768            ts,
769        );
770        let encoder = Syslog::new(Some("sonda".to_string()), Some("sonda".to_string()));
771        let mut buf = Vec::new();
772        encoder.encode_log(&event, &mut buf).unwrap();
773        let output = String::from_utf8(buf).unwrap();
774        assert_eq!(
775            output,
776            "<14>1 2026-03-20T12:00:00.000Z sonda sonda - - [sonda device=\"wlan0\" hostname=\"router_01\"] Request from 10.0.0.1\n"
777        );
778    }
779
780    // -----------------------------------------------------------------------
781    // Send + Sync contract
782    // -----------------------------------------------------------------------
783
784    #[test]
785    fn syslog_encoder_is_send_and_sync() {
786        fn assert_send_sync<T: Send + Sync>() {}
787        assert_send_sync::<Syslog>();
788    }
789
790    // -----------------------------------------------------------------------
791    // EncoderConfig::Syslog: deserialization and factory wiring
792    // -----------------------------------------------------------------------
793
794    #[cfg(feature = "config")]
795    #[test]
796    fn encoder_config_syslog_deserializes_without_optional_fields() {
797        use crate::encoder::{create_encoder, EncoderConfig};
798        let yaml = "type: syslog";
799        let config: EncoderConfig = serde_yaml_ng::from_str(yaml).unwrap();
800        assert!(
801            matches!(
802                config,
803                EncoderConfig::Syslog {
804                    hostname: None,
805                    app_name: None
806                }
807            ),
808            "syslog config without optional fields should have None for hostname and app_name"
809        );
810        // Also verify it can create an encoder
811        let _enc = create_encoder(&config).unwrap();
812    }
813
814    #[cfg(feature = "config")]
815    #[test]
816    fn encoder_config_syslog_deserializes_with_hostname() {
817        use crate::encoder::EncoderConfig;
818        let yaml = "type: syslog\nhostname: myhost";
819        let config: EncoderConfig = serde_yaml_ng::from_str(yaml).unwrap();
820        assert!(matches!(
821            config,
822            EncoderConfig::Syslog {
823                hostname: Some(ref h),
824                app_name: None,
825            } if h == "myhost"
826        ));
827    }
828
829    #[cfg(feature = "config")]
830    #[test]
831    fn encoder_config_syslog_deserializes_with_both_hostname_and_app_name() {
832        use crate::encoder::EncoderConfig;
833        let yaml = "type: syslog\nhostname: prod-01\napp_name: api-server";
834        let config: EncoderConfig = serde_yaml_ng::from_str(yaml).unwrap();
835        assert!(matches!(
836            config,
837            EncoderConfig::Syslog {
838                hostname: Some(ref h),
839                app_name: Some(ref a),
840            } if h == "prod-01" && a == "api-server"
841        ));
842    }
843
844    #[test]
845    fn create_encoder_syslog_via_factory_encodes_log_event() {
846        use crate::encoder::{create_encoder, EncoderConfig};
847        let config = EncoderConfig::Syslog {
848            hostname: Some("testhost".to_string()),
849            app_name: Some("testapp".to_string()),
850        };
851        let encoder = create_encoder(&config).unwrap();
852        let ts = UNIX_EPOCH + Duration::from_millis(1_774_008_000_000);
853        let event = make_log_event(Severity::Info, "factory test", &[], ts);
854        let mut buf = Vec::new();
855        encoder.encode_log(&event, &mut buf).unwrap();
856        let output = String::from_utf8(buf).unwrap();
857        assert!(
858            output.contains("testhost"),
859            "factory-created encoder must use configured hostname"
860        );
861        assert!(
862            output.contains("testapp"),
863            "factory-created encoder must use configured app_name"
864        );
865        assert!(
866            output.contains("factory test"),
867            "factory-created encoder must include the message"
868        );
869    }
870}