Skip to main content

vfa_tui/logging/
audit.rs

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/// Outcome of an audited action.
16#[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/// Structured audit event for compliance logging.
32#[derive(Debug, Clone)]
33pub struct AuditEvent {
34    pub action: String,
35    pub outcome: Outcome,
36    pub detail: Option<String>,
37}
38
39/// Log a user-initiated action at INFO level.
40///
41/// Includes session_id, action type, and outcome in the structured event.
42#[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/// Log a validation failure at WARN level.
55#[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/// Log a subprocess failure or security rejection at ERROR level.
68#[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
80/// A writer that applies secret redaction before writing to the inner writer.
81struct 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/// A MakeWriter that wraps stderr with secret redaction.
100#[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/// A MakeWriter that wraps a shared file handle with secret redaction.
114#[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
131/// A writer backed by a shared file handle behind a Mutex.
132struct 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
154/// Initialize the logging subsystem with JSON output.
155///
156/// Configures tracing-subscriber with:
157/// - JSON format for structured logging with ISO 8601 timestamps (ms precision)
158/// - Configurable level filter from the `log_level` string
159/// - Session ID included in all events via a default span
160/// - Output to stderr (always) and optionally to a file (via --log-file)
161/// - Secret redaction applied to all log output
162/// - If the file cannot be opened, logs a warning to stderr and continues with stderr only
163///
164/// # Log Levels
165/// - INFO: user-initiated actions (browse, search, filter, validate, export)
166/// - WARN: validation failures
167/// - ERROR: subprocess failures and security rejections
168pub 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    // Create the base stderr layer with JSON format and redacting writer.
176    // SystemTime provides ISO 8601 timestamps with subsecond (ms) precision.
177    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                // Fall back to stderr only, but still initialize
207                tracing_subscriber::registry()
208                    .with(filter)
209                    .with(stderr_layer)
210                    .init();
211
212                // Emit warning about the failed log file
213                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        // Invalid level should fall back to info (our code handles this)
265        let filter = EnvFilter::try_new("not_a_level");
266        // This may or may not error depending on the string, but our code
267        // wraps it with unwrap_or_else
268        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        // UUID v4 has version nibble = 4 at position 14
278        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        // write() returns the original buffer length, not the redacted length
332        assert_eq!(written, secret.len());
333    }
334}