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}
43
44#[cfg(any(target_os = "macos", test))]
45#[allow(unsafe_code)]
46mod macos_ffi {
47    use std::os::raw::{c_char, c_void};
48    #[allow(dead_code)]
49    pub(super) type os_log_t = *mut c_void;
50    #[repr(transparent)]
51    #[allow(dead_code)]
52    pub(super) struct os_log_type_t(pub(super) u8);
53
54    #[allow(dead_code)]
55    pub(super) const OS_LOG_TYPE_DEFAULT: os_log_type_t =
56        os_log_type_t(0x00);
57    #[allow(dead_code)]
58    pub(super) const OS_LOG_TYPE_INFO: os_log_type_t =
59        os_log_type_t(0x01);
60    #[allow(dead_code)]
61    pub(super) const OS_LOG_TYPE_DEBUG: os_log_type_t =
62        os_log_type_t(0x02);
63    #[allow(dead_code)]
64    pub(super) const OS_LOG_TYPE_ERROR: os_log_type_t =
65        os_log_type_t(0x10);
66    #[allow(dead_code)]
67    pub(super) const OS_LOG_TYPE_FAULT: os_log_type_t =
68        os_log_type_t(0x11);
69
70    unsafe extern "C" {
71        #[allow(dead_code)]
72        pub(super) fn os_log_create(
73            subsystem: *const c_char,
74            category: *const c_char,
75        ) -> os_log_t;
76        #[allow(dead_code)]
77        pub(super) fn _os_log_impl(
78            dso: *mut c_void,
79            log: os_log_t,
80            log_type: os_log_type_t,
81            format: *const c_char,
82            buf: *const u8,
83            size: u32,
84        );
85    }
86}
87
88impl PlatformSink {
89    /// Build a sink from the given [`Config`](crate::config::Config).
90    ///
91    /// Inspects `logging_destinations` in order:
92    /// - `File(path)` → open for append
93    /// - `Stdout` → stdout
94    /// - `Network(_)` → skipped (not yet implemented)
95    ///
96    /// Falls back to [`PlatformSink::native()`] if no destination matches.
97    #[must_use]
98    pub fn from_config(config: &crate::config::Config) -> Self {
99        for dest in &config.logging_destinations {
100            match dest {
101                crate::config::LoggingDestination::File(path) => {
102                    if let Ok(file) = std::fs::OpenOptions::new()
103                        .create(true)
104                        .append(true)
105                        .open(path)
106                    {
107                        return Self::File(file);
108                    }
109                }
110                crate::config::LoggingDestination::Stdout => {
111                    return Self::Stdout;
112                }
113                crate::config::LoggingDestination::Network(_) => {
114                    // Network sinks not yet implemented — fall through.
115                }
116            }
117        }
118        Self::native()
119    }
120
121    /// Detect and return the best native sink for the current OS.
122    #[must_use]
123    #[allow(clippy::missing_const_for_fn)]
124    pub fn native() -> Self {
125        // Allow explicit fallback to stdout via environment variable.
126        if std::env::var("RLG_FALLBACK_STDOUT").is_ok()
127            || std::env::var("GITHUB_ACTIONS").is_ok()
128        {
129            return Self::Stdout;
130        }
131
132        #[cfg(target_os = "macos")]
133        {
134            Self::OsLog
135        }
136        #[cfg(target_os = "linux")]
137        {
138            Self::detect_journald()
139        }
140        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
141        {
142            Self::Stdout
143        }
144    }
145
146    /// Detect the `journald` socket on Linux.
147    #[cfg(target_os = "linux")]
148    fn detect_journald() -> Self {
149        Self::try_journald_socket("/run/systemd/journal/socket")
150    }
151
152    /// Connect a `UnixDatagram` to the given socket path.
153    #[cfg(target_os = "linux")]
154    fn try_journald_socket(path: &str) -> Self {
155        UnixDatagram::unbound()
156            .ok()
157            .and_then(|socket| {
158                socket.connect(path).ok().map(|()| socket)
159            })
160            .map_or(Self::Journald(None), |s| Self::Journald(Some(s)))
161    }
162
163    /// Write a formatted log payload to this sink.
164    #[allow(unused_variables)]
165    #[allow(clippy::too_many_lines)]
166    pub fn emit(&mut self, level: &str, payload: &[u8]) {
167        match self {
168            Self::Stdout => {
169                let _ = std::io::stdout().write_all(payload);
170                let _ = std::io::stdout().write_all(b"\n");
171            }
172            Self::File(f) => {
173                let _ = f.write_all(payload);
174                let _ = f.write_all(b"\n");
175            }
176            Self::OsLog => {
177                #[cfg(target_os = "macos")]
178                {
179                    if std::env::var("RLG_FALLBACK_STDOUT").is_ok()
180                        || std::env::var("GITHUB_ACTIONS").is_ok()
181                    {
182                        let _ = (level, payload);
183                    } else {
184                        #[cfg(not(any(test, miri)))]
185                        {
186                            use macos_ffi::*;
187                            use std::ffi::CString;
188
189                            let subsystem =
190                                CString::new("com.rlg.logger").unwrap();
191                            let category =
192                                CString::new("default").unwrap();
193
194                            // SAFETY: The pointers passed to `os_log_create` and `_os_log_impl` are derived from
195                            // valid, null-terminated `CString`s. The `buf` pointer is valid for `size` bytes.
196                            // We check `log_handle` for null before passing it to `_os_log_impl`.
197                            #[allow(unsafe_code)]
198                            unsafe {
199                                let log_handle = os_log_create(
200                                    subsystem.as_ptr(),
201                                    category.as_ptr(),
202                                );
203                                if log_handle.is_null() {
204                                    // Fallback to stdout if os_log_create fails
205                                    let _ = std::io::stdout()
206                                        .write_all(payload);
207                                    let _ = std::io::stdout()
208                                        .write_all(b"\n");
209                                    return;
210                                }
211                                let log_type = match level {
212                                    "ERROR" | "FATAL" => {
213                                        OS_LOG_TYPE_ERROR
214                                    }
215                                    "CRITICAL" => OS_LOG_TYPE_FAULT,
216                                    "WARN" => OS_LOG_TYPE_DEFAULT,
217                                    "INFO" => OS_LOG_TYPE_INFO,
218                                    "DEBUG" | "TRACE" | "VERBOSE" => {
219                                        OS_LOG_TYPE_DEBUG
220                                    }
221                                    _ => OS_LOG_TYPE_DEFAULT,
222                                };
223
224                                let format =
225                                    CString::new("%{public}s").unwrap();
226                                // Strip null bytes from payload before creating CString
227                                let clean_payload: Vec<u8> = payload
228                                    .iter()
229                                    .copied()
230                                    .filter(|&b| b != 0)
231                                    .collect();
232                                let msg = CString::new(clean_payload)
233                                    .unwrap_or_default();
234
235                                _os_log_impl(
236                                    std::ptr::null_mut(),
237                                    log_handle,
238                                    log_type,
239                                    format.as_ptr(),
240                                    msg.as_ptr().cast::<u8>(),
241                                    msg.as_bytes().len() as u32,
242                                );
243                            }
244                        }
245                        #[cfg(any(test, miri))]
246                        {
247                            let _ = (level, payload);
248                        }
249                    }
250                }
251                #[cfg(not(target_os = "macos"))]
252                {
253                    let _ = (level, payload);
254                }
255            }
256            Self::Journald(socket_opt) => {
257                if let Some(socket) = socket_opt {
258                    #[cfg(any(test, miri))]
259                    let _ = socket;
260                    let priority = match level {
261                        "ERROR" | "FATAL" | "CRITICAL" => "3",
262                        "WARN" => "4",
263                        "INFO" => "6",
264                        "DEBUG" | "TRACE" | "VERBOSE" => "7",
265                        _ => "5",
266                    };
267
268                    // Journald expects newline-separated key-value pairs
269                    let mut journal_payload =
270                        Vec::with_capacity(payload.len() + 32);
271                    journal_payload.extend_from_slice(b"PRIORITY=");
272                    journal_payload
273                        .extend_from_slice(priority.as_bytes());
274                    journal_payload.extend_from_slice(b"\nMESSAGE=");
275                    journal_payload.extend_from_slice(payload);
276                    journal_payload.extend_from_slice(b"\n");
277
278                    if std::env::var("RLG_FALLBACK_STDOUT").is_ok()
279                        || std::env::var("GITHUB_ACTIONS").is_ok()
280                    {
281                        let _ = journal_payload;
282                    } else {
283                        #[cfg(all(
284                            target_os = "linux",
285                            not(any(test, miri))
286                        ))]
287                        let _ = socket.send(&journal_payload);
288                        #[cfg(any(
289                            not(target_os = "linux"),
290                            test,
291                            miri
292                        ))]
293                        {
294                            let _ = journal_payload;
295                        }
296                    }
297                } else {
298                    let _ = std::io::stdout().write_all(payload);
299                    let _ = std::io::stdout().write_all(b"\n");
300                }
301            }
302        }
303    }
304}
305
306#[cfg(all(test, not(miri)))]
307mod tests {
308    use super::*;
309    use serial_test::serial;
310
311    #[test]
312    #[cfg_attr(miri, ignore)]
313    fn test_platform_sink_stdout() {
314        let mut sink = PlatformSink::Stdout;
315        sink.emit("INFO", b"test stdout");
316    }
317
318    #[test]
319    #[cfg_attr(miri, ignore)]
320    #[allow(unsafe_code)]
321    #[serial]
322    fn test_platform_sink_fallback_env_var() {
323        // SAFETY: Test-only; no other threads depend on this env var.
324        unsafe { std::env::set_var("RLG_FALLBACK_STDOUT", "1") };
325        let sink = PlatformSink::native();
326        assert!(matches!(sink, PlatformSink::Stdout));
327        // SAFETY: Test-only cleanup.
328        unsafe { std::env::remove_var("RLG_FALLBACK_STDOUT") };
329    }
330
331    #[test]
332    #[cfg_attr(miri, ignore)]
333    #[allow(unsafe_code)]
334    #[serial]
335    fn test_platform_sink_native_journald_path() {
336        // SAFETY: Test-only env var cleanup so native() reaches platform code.
337        unsafe {
338            std::env::remove_var("RLG_FALLBACK_STDOUT");
339            std::env::remove_var("GITHUB_ACTIONS");
340        }
341        let sink = PlatformSink::native();
342        #[cfg(target_os = "linux")]
343        assert!(matches!(sink, PlatformSink::Journald(_)));
344        #[cfg(target_os = "macos")]
345        assert!(matches!(sink, PlatformSink::OsLog));
346        #[cfg(not(any(target_os = "linux", target_os = "macos")))]
347        assert!(matches!(sink, PlatformSink::Stdout));
348        // SAFETY: Restore fallback for other tests.
349        unsafe { std::env::set_var("RLG_FALLBACK_STDOUT", "1") };
350    }
351
352    #[test]
353    #[cfg_attr(miri, ignore)]
354    #[cfg(target_os = "linux")]
355    fn test_try_journald_socket_failure() {
356        let sink =
357            PlatformSink::try_journald_socket("/nonexistent/path");
358        assert!(matches!(sink, PlatformSink::Journald(None)));
359    }
360
361    #[test]
362    #[cfg_attr(miri, ignore)]
363    fn test_platform_sink_journald_coverage() {
364        #[cfg(unix)]
365        {
366            let (sock1, _sock2) = UnixDatagram::pair().unwrap();
367            let mut sink = PlatformSink::Journald(Some(sock1));
368            sink.emit("INFO", b"test journald");
369        }
370
371        let mut sink_none = PlatformSink::Journald(None);
372        sink_none.emit("INFO", b"test journald fallback");
373    }
374}