1use std::io::Write;
2use std::path::Path;
3use std::sync::Mutex;
4
5use tracing_subscriber::fmt::format::FmtSpan;
6use tracing_subscriber::fmt::time::SystemTime;
7use tracing_subscriber::fmt::MakeWriter;
8use tracing_subscriber::layer::SubscriberExt;
9use tracing_subscriber::util::SubscriberInitExt;
10use tracing_subscriber::{fmt, EnvFilter};
11use uuid::Uuid;
12
13use crate::security::redact::redact_secrets;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Outcome {
18 Success,
19 Failure,
20}
21
22impl std::fmt::Display for Outcome {
23 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24 match self {
25 Outcome::Success => write!(f, "success"),
26 Outcome::Failure => write!(f, "failure"),
27 }
28 }
29}
30
31#[derive(Debug, Clone)]
33pub struct AuditEvent {
34 pub action: String,
35 pub outcome: Outcome,
36 pub detail: Option<String>,
37}
38
39#[macro_export]
43macro_rules! audit_info {
44 ($session_id:expr, $action:expr, $outcome:expr $(, $($field:tt)*)?) => {
45 tracing::info!(
46 session_id = %$session_id,
47 action = %$action,
48 outcome = %$outcome,
49 $($($field)*)?
50 )
51 };
52}
53
54#[macro_export]
56macro_rules! audit_warn {
57 ($session_id:expr, $action:expr, $outcome:expr $(, $($field:tt)*)?) => {
58 tracing::warn!(
59 session_id = %$session_id,
60 action = %$action,
61 outcome = %$outcome,
62 $($($field)*)?
63 )
64 };
65}
66
67#[macro_export]
69macro_rules! audit_error {
70 ($session_id:expr, $action:expr, $outcome:expr $(, $($field:tt)*)?) => {
71 tracing::error!(
72 session_id = %$session_id,
73 action = %$action,
74 outcome = %$outcome,
75 $($($field)*)?
76 )
77 };
78}
79
80struct RedactingWriter<W: Write> {
82 inner: W,
83}
84
85impl<W: Write> Write for RedactingWriter<W> {
86 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
87 let original_len = buf.len();
88 let text = String::from_utf8_lossy(buf);
89 let redacted = redact_secrets(&text);
90 self.inner.write_all(redacted.as_bytes())?;
91 Ok(original_len)
92 }
93
94 fn flush(&mut self) -> std::io::Result<()> {
95 self.inner.flush()
96 }
97}
98
99#[derive(Clone)]
101struct RedactingStderrMakeWriter;
102
103impl<'a> MakeWriter<'a> for RedactingStderrMakeWriter {
104 type Writer = RedactingWriter<std::io::Stderr>;
105
106 fn make_writer(&'a self) -> Self::Writer {
107 RedactingWriter {
108 inner: std::io::stderr(),
109 }
110 }
111}
112
113#[derive(Clone)]
115struct RedactingFileMakeWriter {
116 file: std::sync::Arc<Mutex<std::fs::File>>,
117}
118
119impl<'a> MakeWriter<'a> for RedactingFileMakeWriter {
120 type Writer = RedactingWriter<SharedFileWriter>;
121
122 fn make_writer(&'a self) -> Self::Writer {
123 RedactingWriter {
124 inner: SharedFileWriter {
125 file: self.file.clone(),
126 },
127 }
128 }
129}
130
131struct SharedFileWriter {
133 file: std::sync::Arc<Mutex<std::fs::File>>,
134}
135
136impl Write for SharedFileWriter {
137 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
138 let mut f = self
139 .file
140 .lock()
141 .map_err(|_| std::io::Error::other("file lock poisoned"))?;
142 f.write(buf)
143 }
144
145 fn flush(&mut self) -> std::io::Result<()> {
146 let mut f = self
147 .file
148 .lock()
149 .map_err(|_| std::io::Error::other("file lock poisoned"))?;
150 f.flush()
151 }
152}
153
154pub fn init_logging(
169 log_file: Option<&Path>,
170 log_level: &str,
171 session_id: Uuid,
172) -> anyhow::Result<()> {
173 let filter = EnvFilter::try_new(log_level).unwrap_or_else(|_| EnvFilter::new("info"));
174
175 let stderr_layer = fmt::layer()
178 .json()
179 .with_target(true)
180 .with_timer(SystemTime)
181 .with_span_events(FmtSpan::NONE)
182 .with_writer(RedactingStderrMakeWriter);
183
184 if let Some(file_path) = log_file {
185 match std::fs::OpenOptions::new()
186 .create(true)
187 .append(true)
188 .open(file_path)
189 {
190 Ok(file) => {
191 let shared_file = std::sync::Arc::new(Mutex::new(file));
192 let file_layer = fmt::layer()
193 .json()
194 .with_target(true)
195 .with_timer(SystemTime)
196 .with_span_events(FmtSpan::NONE)
197 .with_writer(RedactingFileMakeWriter { file: shared_file });
198
199 tracing_subscriber::registry()
200 .with(filter)
201 .with(stderr_layer)
202 .with(file_layer)
203 .init();
204 }
205 Err(e) => {
206 tracing_subscriber::registry()
208 .with(filter)
209 .with(stderr_layer)
210 .init();
211
212 tracing::warn!(
214 session_id = %session_id,
215 path = %file_path.display(),
216 error = %e,
217 action = "init_logging",
218 outcome = "failure",
219 "Failed to open log file, falling back to stderr only"
220 );
221 }
222 }
223 } else {
224 tracing_subscriber::registry()
225 .with(filter)
226 .with(stderr_layer)
227 .init();
228 }
229
230 tracing::info!(
231 session_id = %session_id,
232 action = "session_start",
233 outcome = "success",
234 "Logging initialized"
235 );
236
237 Ok(())
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn env_filter_parses_valid_levels() {
246 let filter = EnvFilter::try_new("debug");
247 assert!(filter.is_ok());
248
249 let filter = EnvFilter::try_new("warn");
250 assert!(filter.is_ok());
251
252 let filter = EnvFilter::try_new("info");
253 assert!(filter.is_ok());
254
255 let filter = EnvFilter::try_new("trace");
256 assert!(filter.is_ok());
257
258 let filter = EnvFilter::try_new("error");
259 assert!(filter.is_ok());
260 }
261
262 #[test]
263 fn env_filter_falls_back_on_invalid() {
264 let filter = EnvFilter::try_new("not_a_level");
266 let _ = filter;
269 }
270
271 #[test]
272 fn session_id_is_valid_uuid_v4() {
273 let id = Uuid::new_v4();
274 let s = id.to_string();
275 assert_eq!(s.len(), 36);
276 assert!(Uuid::parse_str(&s).is_ok());
277 assert_eq!(s.as_bytes()[14], b'4');
279 }
280
281 #[test]
282 fn outcome_display() {
283 assert_eq!(format!("{}", Outcome::Success), "success");
284 assert_eq!(format!("{}", Outcome::Failure), "failure");
285 }
286
287 #[test]
288 fn audit_event_construction() {
289 let event = AuditEvent {
290 action: "browse".to_string(),
291 outcome: Outcome::Success,
292 detail: Some("viewed agent list".to_string()),
293 };
294 assert_eq!(event.action, "browse");
295 assert_eq!(event.outcome, Outcome::Success);
296 assert_eq!(event.detail.unwrap(), "viewed agent list");
297 }
298
299 #[test]
300 fn redacting_writer_redacts_secrets() {
301 let mut output = Vec::new();
302 {
303 let mut writer = RedactingWriter { inner: &mut output };
304 let secret = "token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn\n";
305 writer.write_all(secret.as_bytes()).unwrap();
306 }
307 let result = String::from_utf8(output).unwrap();
308 assert!(!result.contains("ghp_"));
309 assert!(result.contains("[REDACTED]"));
310 assert!(result.contains("token: "));
311 }
312
313 #[test]
314 fn redacting_writer_preserves_normal_text() {
315 let mut output = Vec::new();
316 {
317 let mut writer = RedactingWriter { inner: &mut output };
318 let normal = "INFO session started successfully\n";
319 writer.write_all(normal.as_bytes()).unwrap();
320 }
321 let result = String::from_utf8(output).unwrap();
322 assert_eq!(result, "INFO session started successfully\n");
323 }
324
325 #[test]
326 fn redacting_writer_returns_original_length() {
327 let mut output = Vec::new();
328 let mut writer = RedactingWriter { inner: &mut output };
329 let secret = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn";
330 let written = writer.write(secret.as_bytes()).unwrap();
331 assert_eq!(written, secret.len());
333 }
334}