Skip to main content

talon/
logging.rs

1//! Structured logging: `tracing-subscriber` with an `EnvFilter`
2//! (`RUST_LOG`), human-readable or JSON output (`TALAHON_LOG_FORMAT=json`),
3//! and **redaction of every line** before it reaches the terminal or the
4//! service log file.
5//!
6//! The fmt layer formats one event into a buffer and writes it with a single
7//! `write_all`; [`RedactingWriter`] collects that buffer and, when the
8//! writer is dropped (end of the event), passes the text through the shared
9//! [`Redactor`] and writes it to stdout/stderr in one locked write.
10
11use std::io::{self, Write};
12use std::sync::Arc;
13
14use talon_security::redact::Redactor;
15use tracing_subscriber::EnvFilter;
16use tracing_subscriber::fmt::MakeWriter;
17
18/// Output format of log lines.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum LogFormat {
21    /// Human-readable lines.
22    Pretty,
23    /// One JSON object per line.
24    Json,
25}
26
27impl LogFormat {
28    /// `json` (case-insensitive) => [`LogFormat::Json`], anything else pretty.
29    pub fn parse(value: Option<&str>) -> Self {
30        match value.map(str::trim) {
31            Some(v) if v.eq_ignore_ascii_case("json") => LogFormat::Json,
32            _ => LogFormat::Pretty,
33        }
34    }
35}
36
37/// Where log lines go.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum LogTarget {
40    /// stdout (the service log file under launchd/systemd).
41    Stdout,
42    /// stderr (CLI commands: stdout carries their machine-readable output).
43    Stderr,
44}
45
46/// [`MakeWriter`] that redacts every event before writing it.
47#[derive(Clone)]
48pub struct RedactingMakeWriter {
49    redactor: Arc<Redactor>,
50    target: LogTarget,
51}
52
53impl RedactingMakeWriter {
54    /// Writer factory for `target` using `redactor`.
55    pub fn new(redactor: Arc<Redactor>, target: LogTarget) -> Self {
56        Self { redactor, target }
57    }
58}
59
60impl<'a> MakeWriter<'a> for RedactingMakeWriter {
61    type Writer = RedactingWriter;
62
63    fn make_writer(&'a self) -> Self::Writer {
64        RedactingWriter {
65            buf: Vec::with_capacity(256),
66            redactor: Arc::clone(&self.redactor),
67            sink: Sink::Std(self.target),
68        }
69    }
70}
71
72enum Sink {
73    Std(LogTarget),
74    #[cfg(test)]
75    Memory(Arc<std::sync::Mutex<Vec<u8>>>),
76}
77
78/// Buffers one formatted event; redacts and writes it on drop.
79pub struct RedactingWriter {
80    buf: Vec<u8>,
81    redactor: Arc<Redactor>,
82    sink: Sink,
83}
84
85impl RedactingWriter {
86    #[cfg(test)]
87    fn memory(redactor: Arc<Redactor>, out: Arc<std::sync::Mutex<Vec<u8>>>) -> Self {
88        Self {
89            buf: Vec::new(),
90            redactor,
91            sink: Sink::Memory(out),
92        }
93    }
94
95    fn emit(&mut self) {
96        if self.buf.is_empty() {
97            return;
98        }
99        let text = String::from_utf8_lossy(&self.buf);
100        let redacted = self.redactor.redact(&text);
101        let bytes = redacted.as_bytes();
102        // Logging must never fail the caller: write errors are dropped.
103        match &self.sink {
104            Sink::Std(LogTarget::Stdout) => {
105                let _ = io::stdout().lock().write_all(bytes);
106            }
107            Sink::Std(LogTarget::Stderr) => {
108                let _ = io::stderr().lock().write_all(bytes);
109            }
110            #[cfg(test)]
111            Sink::Memory(out) => {
112                if let Ok(mut out) = out.lock() {
113                    out.extend_from_slice(bytes);
114                }
115            }
116        }
117        self.buf.clear();
118    }
119}
120
121impl Write for RedactingWriter {
122    fn write(&mut self, data: &[u8]) -> io::Result<usize> {
123        self.buf.extend_from_slice(data);
124        Ok(data.len())
125    }
126
127    fn flush(&mut self) -> io::Result<()> {
128        Ok(())
129    }
130}
131
132impl Drop for RedactingWriter {
133    fn drop(&mut self) {
134        self.emit();
135    }
136}
137
138/// Install the global subscriber. `default_filter` applies when `RUST_LOG`
139/// is unset or invalid. Calling it twice is harmless (the second call is
140/// ignored).
141pub fn init(format: LogFormat, target: LogTarget, default_filter: &str, redactor: Arc<Redactor>) {
142    let filter = EnvFilter::try_from_default_env()
143        .or_else(|_| EnvFilter::try_new(default_filter))
144        .unwrap_or_else(|_| EnvFilter::new("info"));
145    let writer = RedactingMakeWriter::new(redactor, target);
146    let builder = tracing_subscriber::fmt()
147        .with_env_filter(filter)
148        .with_writer(writer)
149        .with_target(true);
150    let result = match format {
151        LogFormat::Json => builder
152            .json()
153            .flatten_event(true)
154            .with_current_span(false)
155            .try_init(),
156        LogFormat::Pretty => builder.with_ansi(false).try_init(),
157    };
158    if let Err(e) = result {
159        eprintln!("talahon: logging already initialized: {e}");
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use std::sync::Mutex;
166
167    use super::*;
168
169    #[test]
170    fn format_parsing() {
171        assert_eq!(LogFormat::parse(Some("json")), LogFormat::Json);
172        assert_eq!(LogFormat::parse(Some(" JSON ")), LogFormat::Json);
173        assert_eq!(LogFormat::parse(Some("text")), LogFormat::Pretty);
174        assert_eq!(LogFormat::parse(None), LogFormat::Pretty);
175    }
176
177    #[test]
178    fn writer_redacts_registered_secrets_and_builtin_patterns() {
179        let redactor = Arc::new(Redactor::with_secrets(&["sup3r-s3cret-value".to_string()]));
180        let out = Arc::new(Mutex::new(Vec::new()));
181        {
182            let mut w = RedactingWriter::memory(redactor, Arc::clone(&out));
183            w.write_all(b"token=sup3r-s3cret-value ").unwrap();
184            w.write_all(b"Authorization: Bearer abcdefghijklmnopqrstuvwxyz0123456789\n")
185                .unwrap();
186        }
187        let text = String::from_utf8(out.lock().unwrap().clone()).unwrap();
188        assert!(!text.contains("sup3r-s3cret-value"), "{text}");
189        assert!(
190            !text.contains("abcdefghijklmnopqrstuvwxyz0123456789"),
191            "{text}"
192        );
193        assert!(text.contains("REDACTED"));
194        assert!(text.ends_with('\n'));
195    }
196
197    #[test]
198    fn subscriber_events_pass_through_the_redactor() {
199        let redactor = Arc::new(Redactor::with_secrets(&["leaky-api-key-42".to_string()]));
200        let out = Arc::new(Mutex::new(Vec::new()));
201        let make = {
202            let out = Arc::clone(&out);
203            let redactor = Arc::clone(&redactor);
204            move || RedactingWriter::memory(Arc::clone(&redactor), Arc::clone(&out))
205        };
206        let subscriber = tracing_subscriber::fmt()
207            .with_writer(make)
208            .with_ansi(false)
209            .finish();
210        tracing::subscriber::with_default(subscriber, || {
211            tracing::info!(key = "leaky-api-key-42", "connecting with leaky-api-key-42");
212        });
213        let text = String::from_utf8(out.lock().unwrap().clone()).unwrap();
214        assert!(text.contains("connecting with"), "{text}");
215        assert!(!text.contains("leaky-api-key-42"), "{text}");
216    }
217}