use std::io::Write;
use std::path::Path;
use std::sync::Mutex;
use tracing_subscriber::fmt::format::FmtSpan;
use tracing_subscriber::fmt::time::SystemTime;
use tracing_subscriber::fmt::MakeWriter;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{fmt, EnvFilter};
use uuid::Uuid;
use crate::security::redact::redact_secrets;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
Success,
Failure,
}
impl std::fmt::Display for Outcome {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Outcome::Success => write!(f, "success"),
Outcome::Failure => write!(f, "failure"),
}
}
}
#[derive(Debug, Clone)]
pub struct AuditEvent {
pub action: String,
pub outcome: Outcome,
pub detail: Option<String>,
}
#[macro_export]
macro_rules! audit_info {
($session_id:expr, $action:expr, $outcome:expr $(, $($field:tt)*)?) => {
tracing::info!(
session_id = %$session_id,
action = %$action,
outcome = %$outcome,
$($($field)*)?
)
};
}
#[macro_export]
macro_rules! audit_warn {
($session_id:expr, $action:expr, $outcome:expr $(, $($field:tt)*)?) => {
tracing::warn!(
session_id = %$session_id,
action = %$action,
outcome = %$outcome,
$($($field)*)?
)
};
}
#[macro_export]
macro_rules! audit_error {
($session_id:expr, $action:expr, $outcome:expr $(, $($field:tt)*)?) => {
tracing::error!(
session_id = %$session_id,
action = %$action,
outcome = %$outcome,
$($($field)*)?
)
};
}
struct RedactingWriter<W: Write> {
inner: W,
}
impl<W: Write> Write for RedactingWriter<W> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let original_len = buf.len();
let text = String::from_utf8_lossy(buf);
let redacted = redact_secrets(&text);
self.inner.write_all(redacted.as_bytes())?;
Ok(original_len)
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
#[derive(Clone)]
struct RedactingStderrMakeWriter;
impl<'a> MakeWriter<'a> for RedactingStderrMakeWriter {
type Writer = RedactingWriter<std::io::Stderr>;
fn make_writer(&'a self) -> Self::Writer {
RedactingWriter {
inner: std::io::stderr(),
}
}
}
#[derive(Clone)]
struct RedactingFileMakeWriter {
file: std::sync::Arc<Mutex<std::fs::File>>,
}
impl<'a> MakeWriter<'a> for RedactingFileMakeWriter {
type Writer = RedactingWriter<SharedFileWriter>;
fn make_writer(&'a self) -> Self::Writer {
RedactingWriter {
inner: SharedFileWriter {
file: self.file.clone(),
},
}
}
}
struct SharedFileWriter {
file: std::sync::Arc<Mutex<std::fs::File>>,
}
impl Write for SharedFileWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let mut f = self
.file
.lock()
.map_err(|_| std::io::Error::other("file lock poisoned"))?;
f.write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
let mut f = self
.file
.lock()
.map_err(|_| std::io::Error::other("file lock poisoned"))?;
f.flush()
}
}
pub fn init_logging(
log_file: Option<&Path>,
log_level: &str,
session_id: Uuid,
) -> anyhow::Result<()> {
let filter = EnvFilter::try_new(log_level).unwrap_or_else(|_| EnvFilter::new("info"));
let stderr_layer = fmt::layer()
.json()
.with_target(true)
.with_timer(SystemTime)
.with_span_events(FmtSpan::NONE)
.with_writer(RedactingStderrMakeWriter);
if let Some(file_path) = log_file {
match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(file_path)
{
Ok(file) => {
let shared_file = std::sync::Arc::new(Mutex::new(file));
let file_layer = fmt::layer()
.json()
.with_target(true)
.with_timer(SystemTime)
.with_span_events(FmtSpan::NONE)
.with_writer(RedactingFileMakeWriter { file: shared_file });
tracing_subscriber::registry()
.with(filter)
.with(stderr_layer)
.with(file_layer)
.init();
}
Err(e) => {
tracing_subscriber::registry()
.with(filter)
.with(stderr_layer)
.init();
tracing::warn!(
session_id = %session_id,
path = %file_path.display(),
error = %e,
action = "init_logging",
outcome = "failure",
"Failed to open log file, falling back to stderr only"
);
}
}
} else {
tracing_subscriber::registry()
.with(filter)
.with(stderr_layer)
.init();
}
tracing::info!(
session_id = %session_id,
action = "session_start",
outcome = "success",
"Logging initialized"
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn env_filter_parses_valid_levels() {
let filter = EnvFilter::try_new("debug");
assert!(filter.is_ok());
let filter = EnvFilter::try_new("warn");
assert!(filter.is_ok());
let filter = EnvFilter::try_new("info");
assert!(filter.is_ok());
let filter = EnvFilter::try_new("trace");
assert!(filter.is_ok());
let filter = EnvFilter::try_new("error");
assert!(filter.is_ok());
}
#[test]
fn env_filter_falls_back_on_invalid() {
let filter = EnvFilter::try_new("not_a_level");
let _ = filter;
}
#[test]
fn session_id_is_valid_uuid_v4() {
let id = Uuid::new_v4();
let s = id.to_string();
assert_eq!(s.len(), 36);
assert!(Uuid::parse_str(&s).is_ok());
assert_eq!(s.as_bytes()[14], b'4');
}
#[test]
fn outcome_display() {
assert_eq!(format!("{}", Outcome::Success), "success");
assert_eq!(format!("{}", Outcome::Failure), "failure");
}
#[test]
fn audit_event_construction() {
let event = AuditEvent {
action: "browse".to_string(),
outcome: Outcome::Success,
detail: Some("viewed agent list".to_string()),
};
assert_eq!(event.action, "browse");
assert_eq!(event.outcome, Outcome::Success);
assert_eq!(event.detail.unwrap(), "viewed agent list");
}
#[test]
fn redacting_writer_redacts_secrets() {
let mut output = Vec::new();
{
let mut writer = RedactingWriter { inner: &mut output };
let secret = "token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn\n";
writer.write_all(secret.as_bytes()).unwrap();
}
let result = String::from_utf8(output).unwrap();
assert!(!result.contains("ghp_"));
assert!(result.contains("[REDACTED]"));
assert!(result.contains("token: "));
}
#[test]
fn redacting_writer_preserves_normal_text() {
let mut output = Vec::new();
{
let mut writer = RedactingWriter { inner: &mut output };
let normal = "INFO session started successfully\n";
writer.write_all(normal.as_bytes()).unwrap();
}
let result = String::from_utf8(output).unwrap();
assert_eq!(result, "INFO session started successfully\n");
}
#[test]
fn redacting_writer_returns_original_length() {
let mut output = Vec::new();
let mut writer = RedactingWriter { inner: &mut output };
let secret = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn";
let written = writer.write(secret.as_bytes()).unwrap();
assert_eq!(written, secret.len());
}
}