Skip to main content

rlg/
sink.rs

1// sink.rs
2// Copyright © 2024-2026 RustLogs (RLG). All rights reserved.
3// SPDX-License-Identifier: Apache-2.0
4// SPDX-License-Identifier: MIT
5
6//! Platform-native logging sinks.
7//!
8//! [`PlatformSink`] routes formatted log payloads to the best available
9//! output: `os_log` on macOS, `journald` on Linux, or stdout/file as fallback.
10//! Construct via [`PlatformSink::native()`] or [`PlatformSink::from_config()`].
11
12use std::io::Write;
13
14#[cfg(unix)]
15use std::os::unix::net::UnixDatagram;
16
17#[cfg(not(unix))]
18#[allow(dead_code)]
19#[derive(Debug)]
20pub struct UnixDatagram;
21
22#[cfg(not(unix))]
23#[allow(dead_code)]
24impl UnixDatagram {
25    pub fn send(&self, _: &[u8]) -> std::io::Result<usize> {
26        Ok(0)
27    }
28}
29
30/// Unified interface for platform-native log output.
31#[derive(Debug)]
32#[allow(variant_size_differences)]
33pub enum PlatformSink {
34    /// Standard output fallback.
35    Stdout,
36    /// File sink fallback.
37    File(std::fs::File),
38    /// Native OS Log on macOS.
39    OsLog,
40    /// Systemd Journald socket on Linux.
41    Journald(Option<UnixDatagram>),
42    /// Linux `io_uring`-backed file sink. Enabled with the `uring`
43    /// feature. Only compiles on Linux — see
44    /// `docs/adr/0011-io-uring-file-sink.md` for the current
45    /// implementation status.
46    ///
47    /// The variant currently stores the underlying `File` and
48    /// delegates writes to the standard synchronous path; the
49    /// `io_uring` submission-queue integration lands in Phase 20.1.
50    /// Consumers can already select this variant to future-proof
51    /// their sink pipeline, and the `io-uring` dependency is
52    /// resolved so the SQE wiring is drop-in.
53    #[cfg(all(target_os = "linux", feature = "uring"))]
54    #[cfg_attr(
55        docsrs,
56        doc(cfg(all(target_os = "linux", feature = "uring")))
57    )]
58    UringFile(std::fs::File),
59}
60
61/// POSIX `syslog(3)` bindings.
62///
63/// `syslog(3)` is the supported, stable-ABI path for emitting log records
64/// from a non-Objective-C process. On macOS (Sierra and newer) the syslog
65/// gateway is routed into `os_log`, so records still appear under
66/// `log stream` / Console.app. This avoids the `_os_log_impl` private
67/// symbol, whose binary-trailer calling convention can only be produced
68/// by the compiler-expanded `os_log` macro and is undefined behaviour to
69/// call directly from Rust.
70#[cfg(target_os = "macos")]
71#[allow(unsafe_code)]
72mod syslog_ffi {
73    use std::ffi::CStr;
74    use std::os::raw::{c_char, c_int};
75    use std::sync::OnceLock;
76
77    pub(super) const LOG_CRIT: c_int = 2;
78    pub(super) const LOG_ERR: c_int = 3;
79    pub(super) const LOG_WARNING: c_int = 4;
80    pub(super) const LOG_NOTICE: c_int = 5;
81    pub(super) const LOG_INFO: c_int = 6;
82    pub(super) const LOG_DEBUG: c_int = 7;
83    const LOG_USER: c_int = 1 << 3;
84    const LOG_PID: c_int = 0x01;
85
86    unsafe extern "C" {
87        fn openlog(
88            ident: *const c_char,
89            logopt: c_int,
90            facility: c_int,
91        );
92        fn syslog(
93            priority: c_int,
94            format: *const c_char,
95            arg: *const c_char,
96        );
97    }
98
99    static INIT: OnceLock<()> = OnceLock::new();
100
101    fn ensure_open() {
102        INIT.get_or_init(|| {
103            // POSIX requires the ident pointer to remain valid for the
104            // lifetime of the syslog connection. A `c""` literal is a
105            // `&'static CStr` embedded in the binary — no allocation,
106            // no leak, no fallible construction.
107            const IDENT: &CStr = c"rlg";
108            // SAFETY: `IDENT.as_ptr()` is a valid null-terminated string with
109            // a 'static lifetime; LOG_PID + LOG_USER are valid bit flags.
110            unsafe { openlog(IDENT.as_ptr(), LOG_PID, LOG_USER) };
111        });
112    }
113
114    /// Emit a single record. `msg` must be a valid null-terminated string.
115    ///
116    /// # Safety
117    /// `msg` must point to a valid `\0`-terminated byte sequence that
118    /// remains valid for the duration of the call.
119    pub(super) unsafe fn emit(priority: c_int, msg: *const c_char) {
120        ensure_open();
121        // SAFETY: caller upholds `msg` validity. We pass a static "%s"
122        // format with exactly one `%s` argument, which matches the variadic
123        // contract `syslog(3)` expects (no varargs UB).
124        unsafe { syslog(priority, c"%s".as_ptr(), msg) };
125    }
126}
127
128impl PlatformSink {
129    /// Build a sink from the given [`Config`](crate::config::Config).
130    ///
131    /// Inspects `logging_destinations` in order:
132    /// - `File(path)` → open for append
133    /// - `Stdout` → stdout
134    /// - `Network(_)` → skipped (not yet implemented)
135    ///
136    /// Falls back to [`PlatformSink::native()`] if no destination matches.
137    #[must_use]
138    pub fn from_config(config: &crate::config::Config) -> Self {
139        for dest in &config.logging_destinations {
140            match dest {
141                crate::config::LoggingDestination::File(path) => {
142                    if let Ok(file) = std::fs::OpenOptions::new()
143                        .create(true)
144                        .append(true)
145                        .open(path)
146                    {
147                        return Self::File(file);
148                    }
149                }
150                crate::config::LoggingDestination::Stdout => {
151                    return Self::Stdout;
152                }
153                crate::config::LoggingDestination::Network(_) => {
154                    // Network sinks not yet implemented — fall through.
155                }
156            }
157        }
158        Self::native()
159    }
160
161    /// Detect and return the best native sink for the current OS.
162    #[must_use]
163    #[allow(clippy::missing_const_for_fn)]
164    pub fn native() -> Self {
165        // Allow explicit fallback to stdout via environment variable.
166        if std::env::var("RLG_FALLBACK_STDOUT").is_ok()
167            || std::env::var("GITHUB_ACTIONS").is_ok()
168        {
169            return Self::Stdout;
170        }
171
172        #[cfg(target_os = "macos")]
173        {
174            Self::OsLog
175        }
176        #[cfg(target_os = "linux")]
177        {
178            Self::detect_journald()
179        }
180        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
181        {
182            Self::Stdout
183        }
184    }
185
186    /// Detect the `journald` socket on Linux.
187    #[cfg(target_os = "linux")]
188    fn detect_journald() -> Self {
189        Self::try_journald_socket("/run/systemd/journal/socket")
190    }
191
192    /// Connect a `UnixDatagram` to the given socket path.
193    #[cfg(target_os = "linux")]
194    fn try_journald_socket(path: &str) -> Self {
195        UnixDatagram::unbound()
196            .ok()
197            .and_then(|socket| {
198                socket.connect(path).ok().map(|()| socket)
199            })
200            .map_or(Self::Journald(None), |s| Self::Journald(Some(s)))
201    }
202
203    /// Write a formatted log payload to this sink.
204    #[allow(unused_variables)]
205    pub fn emit(&mut self, level: &str, payload: &[u8]) {
206        match self {
207            Self::Stdout => {
208                let _ = std::io::stdout().write_all(payload);
209                let _ = std::io::stdout().write_all(b"\n");
210            }
211            Self::File(f) => {
212                let _ = f.write_all(payload);
213                let _ = f.write_all(b"\n");
214            }
215            Self::OsLog => Self::emit_os_log(level, payload),
216            Self::Journald(socket_opt) => {
217                Self::emit_journald(
218                    level,
219                    payload,
220                    socket_opt.as_ref(),
221                );
222            }
223            #[cfg(all(target_os = "linux", feature = "uring"))]
224            Self::UringFile(f) => {
225                // Phase 20 scaffold: delegates to the sync write
226                // path for correctness. Phase 20.1 wires up the
227                // io_uring submission queue for zero-copy
228                // batched writes. Consumers who need the io_uring
229                // performance profile today can construct their
230                // own SQE loop against the underlying `File`
231                // via the `io-uring` crate — the feature already
232                // resolves the dep.
233                let _ = f.write_all(payload);
234                let _ = f.write_all(b"\n");
235            }
236        }
237    }
238
239    /// Emit one record to macOS `os_log` via the `syslog(3)` gateway.
240    ///
241    /// On non-macOS targets this is a no-op so the `OsLog` variant remains
242    /// callable cross-platform.
243    #[cfg(target_os = "macos")]
244    fn emit_os_log(level: &str, payload: &[u8]) {
245        use syslog_ffi::{
246            LOG_CRIT, LOG_DEBUG, LOG_ERR, LOG_INFO, LOG_NOTICE,
247            LOG_WARNING, emit,
248        };
249
250        if std::env::var("RLG_FALLBACK_STDOUT").is_ok()
251            || std::env::var("GITHUB_ACTIONS").is_ok()
252        {
253            let _ = std::io::stdout().write_all(payload);
254            let _ = std::io::stdout().write_all(b"\n");
255            return;
256        }
257
258        let priority = match level {
259            "FATAL" | "CRITICAL" => LOG_CRIT,
260            "ERROR" => LOG_ERR,
261            "WARN" => LOG_WARNING,
262            "INFO" => LOG_INFO,
263            "DEBUG" | "TRACE" | "VERBOSE" => LOG_DEBUG,
264            _ => LOG_NOTICE,
265        };
266        // Strip embedded NULs so the C string is well-formed, then
267        // append our own terminator.
268        let mut buf: Vec<u8> =
269            payload.iter().copied().filter(|&b| b != 0).collect();
270        buf.push(0);
271        // SAFETY: `buf` is owned for the duration of this call, ends
272        // in a `\0`, and `syslog(3)` is thread-safe.
273        #[allow(unsafe_code)]
274        unsafe {
275            emit(priority, buf.as_ptr().cast::<std::os::raw::c_char>());
276        }
277    }
278
279    /// No-op on non-macOS targets.
280    #[cfg(not(target_os = "macos"))]
281    const fn emit_os_log(_level: &str, _payload: &[u8]) {}
282
283    /// Emit one record to the `journald` Unix-datagram socket.
284    ///
285    /// Falls back to stdout when the socket is unavailable, when the host
286    /// is not Linux, or when `RLG_FALLBACK_STDOUT` / `GITHUB_ACTIONS` is
287    /// set.
288    fn emit_journald(
289        level: &str,
290        payload: &[u8],
291        socket_opt: Option<&UnixDatagram>,
292    ) {
293        let Some(socket) = socket_opt else {
294            let _ = std::io::stdout().write_all(payload);
295            let _ = std::io::stdout().write_all(b"\n");
296            return;
297        };
298
299        if std::env::var("RLG_FALLBACK_STDOUT").is_ok()
300            || std::env::var("GITHUB_ACTIONS").is_ok()
301        {
302            let _ = socket;
303            return;
304        }
305
306        #[cfg(target_os = "linux")]
307        {
308            let priority = match level {
309                "ERROR" | "FATAL" | "CRITICAL" => "3",
310                "WARN" => "4",
311                "INFO" => "6",
312                "DEBUG" | "TRACE" | "VERBOSE" => "7",
313                _ => "5",
314            };
315            let mut journal_payload =
316                Vec::with_capacity(payload.len() + 32);
317            journal_payload.extend_from_slice(b"PRIORITY=");
318            journal_payload.extend_from_slice(priority.as_bytes());
319            journal_payload.extend_from_slice(b"\nMESSAGE=");
320            journal_payload.extend_from_slice(payload);
321            journal_payload.extend_from_slice(b"\n");
322            let _ = socket.send(&journal_payload);
323        }
324        #[cfg(not(target_os = "linux"))]
325        {
326            let _ = (level, payload, socket);
327        }
328    }
329}
330
331#[cfg(all(test, not(miri)))]
332mod tests {
333    use super::*;
334    use serial_test::serial;
335
336    #[test]
337    #[cfg_attr(miri, ignore)]
338    fn test_platform_sink_stdout() {
339        let mut sink = PlatformSink::Stdout;
340        sink.emit("INFO", b"test stdout");
341    }
342
343    /// `UringFile` variant scaffold — currently delegates to the
344    /// sync write path. Test verifies the variant compiles,
345    /// constructs from a real `File`, and successfully emits.
346    #[test]
347    #[cfg_attr(miri, ignore)]
348    #[cfg(all(target_os = "linux", feature = "uring"))]
349    fn test_platform_sink_uring_file_scaffold_emits() {
350        let tmp = tempfile::NamedTempFile::new().unwrap();
351        let file = tmp.reopen().unwrap();
352        let mut sink = PlatformSink::UringFile(file);
353        sink.emit("INFO", b"uring scaffold test");
354        // Read back the file and confirm the payload landed.
355        let contents = std::fs::read_to_string(tmp.path()).unwrap();
356        assert!(contents.contains("uring scaffold test"));
357    }
358
359    #[test]
360    #[cfg_attr(miri, ignore)]
361    #[allow(unsafe_code)]
362    #[serial]
363    fn test_platform_sink_fallback_env_var() {
364        // SAFETY: Test-only; no other threads depend on this env var.
365        unsafe { std::env::set_var("RLG_FALLBACK_STDOUT", "1") };
366        let sink = PlatformSink::native();
367        assert!(matches!(sink, PlatformSink::Stdout));
368        // SAFETY: Test-only cleanup.
369        unsafe { std::env::remove_var("RLG_FALLBACK_STDOUT") };
370    }
371
372    #[test]
373    #[cfg_attr(miri, ignore)]
374    #[allow(unsafe_code)]
375    #[serial]
376    fn test_platform_sink_native_journald_path() {
377        // SAFETY: Test-only env var cleanup so native() reaches platform code.
378        unsafe {
379            std::env::remove_var("RLG_FALLBACK_STDOUT");
380            std::env::remove_var("GITHUB_ACTIONS");
381        }
382        let sink = PlatformSink::native();
383        #[cfg(target_os = "linux")]
384        assert!(matches!(sink, PlatformSink::Journald(_)));
385        #[cfg(target_os = "macos")]
386        assert!(matches!(sink, PlatformSink::OsLog));
387        #[cfg(not(any(target_os = "linux", target_os = "macos")))]
388        assert!(matches!(sink, PlatformSink::Stdout));
389        // SAFETY: Restore fallback for other tests.
390        unsafe { std::env::set_var("RLG_FALLBACK_STDOUT", "1") };
391    }
392
393    #[test]
394    #[cfg_attr(miri, ignore)]
395    #[cfg(target_os = "linux")]
396    fn test_try_journald_socket_failure() {
397        let sink =
398            PlatformSink::try_journald_socket("/nonexistent/path");
399        assert!(matches!(sink, PlatformSink::Journald(None)));
400    }
401
402    #[test]
403    #[cfg_attr(miri, ignore)]
404    fn test_platform_sink_journald_coverage() {
405        #[cfg(unix)]
406        {
407            let (sock1, _sock2) = UnixDatagram::pair().unwrap();
408            let mut sink = PlatformSink::Journald(Some(sock1));
409            sink.emit("INFO", b"test journald");
410        }
411
412        let mut sink_none = PlatformSink::Journald(None);
413        sink_none.emit("INFO", b"test journald fallback");
414    }
415
416    #[test]
417    #[cfg_attr(miri, ignore)]
418    #[allow(unsafe_code)]
419    #[serial]
420    fn test_platform_sink_oslog_fallback_stdout() {
421        // Drive the `RLG_FALLBACK_STDOUT` branch in `emit_os_log`.
422        // SAFETY: serial test; sole writer of this env var.
423        unsafe { std::env::set_var("RLG_FALLBACK_STDOUT", "1") };
424        let mut sink = PlatformSink::OsLog;
425        sink.emit("INFO", b"fallback-test");
426        // SAFETY: restore for following tests.
427        unsafe { std::env::remove_var("RLG_FALLBACK_STDOUT") };
428    }
429
430    #[cfg(target_os = "macos")]
431    #[test]
432    #[cfg_attr(miri, ignore)]
433    #[allow(unsafe_code)]
434    #[serial]
435    fn test_platform_sink_oslog_real_syslog_call() {
436        // Exercise the actual `syslog(3)` FFI path on macOS, once per
437        // process. The serial lock ensures no other test toggles
438        // RLG_FALLBACK_STDOUT mid-flight.
439        unsafe {
440            std::env::remove_var("RLG_FALLBACK_STDOUT");
441            std::env::remove_var("GITHUB_ACTIONS");
442        }
443        let mut sink = PlatformSink::OsLog;
444        // Every level branch must be exercised to cover the priority
445        // mapping match in `emit_os_log`.
446        for level in [
447            "FATAL",
448            "CRITICAL",
449            "ERROR",
450            "WARN",
451            "INFO",
452            "DEBUG",
453            "TRACE",
454            "VERBOSE",
455            "UNKNOWN_LEVEL",
456        ] {
457            sink.emit(level, b"rlg coverage test record");
458        }
459        // Restore fallback so later tests stay deterministic.
460        unsafe { std::env::set_var("RLG_FALLBACK_STDOUT", "1") };
461    }
462
463    #[test]
464    #[cfg_attr(miri, ignore)]
465    fn test_platform_sink_oslog_with_embedded_nulls() {
466        // Drives the NUL-stripping branch in `emit_os_log` even on
467        // non-macOS targets (the path is a no-op there but the
468        // dispatch still exercises the match arm).
469        let mut sink = PlatformSink::OsLog;
470        sink.emit("INFO", b"with\0embedded\0nulls");
471    }
472}