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