zerodds-security-logging 1.0.0-rc.1

Security-Logging-Backends fuer DDS-Security 1.1 §8.6: stderr + JSON-lines + RFC-5424-UDP-Syslog + FanOut.
Documentation
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 ZeroDDS Contributors

//! Stderr-Backend.

use alloc::string::String;
use std::io::{self, Write};
use std::sync::Mutex;

use zerodds_security::logging::{LogLevel, LoggingPlugin};

/// Loggt Security-Events nach `stderr` als Human-Readable-Text.
///
/// Format:
/// ```text
/// [SEC][<LEVEL>] participant=<hex16> category=<...> msg=<...>
/// ```
///
/// Mutex um `io::Stderr` serialisiert Writes — sonst koennten parallele
/// Writer die Zeilen ineinander mischen (stderr ist line-buffered
/// **nur** wenn es ans Terminal geht; in der Pipeline ist es
/// fully-buffered bzw. per write(2) atomisch nur bis PIPE_BUF Bytes).
pub struct StderrLoggingPlugin {
    min_level: LogLevel,
    serializer: Mutex<()>,
}

impl Default for StderrLoggingPlugin {
    fn default() -> Self {
        Self::new()
    }
}

impl StderrLoggingPlugin {
    /// Konstruktor mit Default-Level `Warning`.
    #[must_use]
    pub fn new() -> Self {
        Self::with_level(LogLevel::Warning)
    }

    /// Mit explizitem Min-Level.
    #[must_use]
    pub fn with_level(min_level: LogLevel) -> Self {
        Self {
            min_level,
            serializer: Mutex::new(()),
        }
    }
}

fn level_label(l: LogLevel) -> &'static str {
    match l {
        LogLevel::Emergency => "EMERG",
        LogLevel::Alert => "ALERT",
        LogLevel::Critical => "CRIT",
        LogLevel::Error => "ERROR",
        LogLevel::Warning => "WARN",
        LogLevel::Notice => "NOTICE",
        LogLevel::Informational => "INFO",
        LogLevel::Debug => "DEBUG",
    }
}

fn hex16(bytes: [u8; 16]) -> String {
    let mut s = String::with_capacity(32);
    for b in bytes {
        s.push_str(&alloc::format!("{b:02x}"));
    }
    s
}

impl LoggingPlugin for StderrLoggingPlugin {
    fn log(&self, level: LogLevel, participant: [u8; 16], category: &str, message: &str) {
        // `level <= min_level` weil LogLevel 0=Emergency und 7=Debug.
        if level > self.min_level {
            return;
        }
        let _guard = self.serializer.lock().ok();
        let mut out = io::stderr().lock();
        let _ = writeln!(
            out,
            "[SEC][{level}] participant={pid} category={cat} msg={msg}",
            level = level_label(level),
            pid = hex16(participant),
            cat = category,
            msg = message,
        );
    }

    fn plugin_class_id(&self) -> &str {
        "DDS:Logging:stderr"
    }
}

#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
    use super::*;

    #[test]
    fn plugin_class_id_stable() {
        assert_eq!(
            StderrLoggingPlugin::new().plugin_class_id(),
            "DDS:Logging:stderr"
        );
    }

    #[test]
    fn level_label_covers_all_variants() {
        for &lvl in &[
            LogLevel::Emergency,
            LogLevel::Alert,
            LogLevel::Critical,
            LogLevel::Error,
            LogLevel::Warning,
            LogLevel::Notice,
            LogLevel::Informational,
            LogLevel::Debug,
        ] {
            assert!(!level_label(lvl).is_empty());
        }
    }

    #[test]
    fn hex16_pads_single_digits() {
        let bytes = [0x01, 0x0a, 0xff, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
        assert_eq!(hex16(bytes), "010aff00000000000000000000000000");
    }
}