1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum LogFormat {
21 Pretty,
23 Json,
25}
26
27impl LogFormat {
28 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum LogTarget {
40 Stdout,
42 Stderr,
44}
45
46#[derive(Clone)]
48pub struct RedactingMakeWriter {
49 redactor: Arc<Redactor>,
50 target: LogTarget,
51}
52
53impl RedactingMakeWriter {
54 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
78pub 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 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
138pub 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}