use http::HeaderMap;
use serde::Serialize;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum LogLevel {
Info,
Warn,
Error,
}
impl LogLevel {
fn as_str(self) -> &'static str {
match self {
Self::Info => "INFO",
Self::Warn => "WARN",
Self::Error => "ERROR",
}
}
}
#[derive(Clone, Debug, Serialize)]
pub struct RequestLogEvent {
pub request_id: Option<String>,
pub method: String,
pub path: String,
pub status_code: u16,
pub duration_ms: u128,
pub client_ip: Option<String>,
pub headers: Vec<(String, String)>,
}
pub trait LogSink: Send + Sync {
fn log_text(&self, level: LogLevel, target: &str, message: &str);
fn log_request(&self, target: &str, event: &RequestLogEvent);
}
#[derive(Default)]
pub struct StdIoLogSink;
impl LogSink for StdIoLogSink {
fn log_text(&self, level: LogLevel, target: &str, message: &str) {
let event = TextLogEvent {
level: level.as_str(),
target,
message: sanitize_log_message(message),
};
if let Ok(serialized) = serde_json::to_string(&event) {
match level {
LogLevel::Error => eprintln!("{serialized}"),
LogLevel::Info | LogLevel::Warn => println!("{serialized}"),
}
} else {
let line = format!("[{}] {}: {}", level.as_str(), target, event.message);
match level {
LogLevel::Error => eprintln!("{line}"),
LogLevel::Info | LogLevel::Warn => println!("{line}"),
}
}
}
fn log_request(&self, target: &str, event: &RequestLogEvent) {
if let Ok(serialized) = serde_json::to_string(event) {
println!("[INFO] {target}: {serialized}");
} else {
println!(
"[INFO] {target}: {} {} -> {}",
event.method, event.path, event.status_code
);
}
}
}
pub fn emit_default_log(level: LogLevel, target: &str, message: &str) {
StdIoLogSink.log_text(level, target, message);
}
pub fn redact_headers(headers: &HeaderMap) -> Vec<(String, String)> {
headers
.iter()
.map(|(key, value)| {
let key_name = key.as_str();
let redacted = if is_sensitive_header(key_name) {
"<redacted>".to_string()
} else {
value.to_str().unwrap_or_default().to_string()
};
(key_name.to_string(), redacted)
})
.collect()
}
pub fn sanitize_path_for_logs(path: &str) -> String {
let segments = path
.split('/')
.map(|segment| {
if segment.is_empty() {
String::new()
} else if segment.len() > 32
|| segment.chars().all(|ch| ch.is_ascii_digit())
|| looks_like_identifier(segment)
{
":redacted".to_string()
} else {
segment.to_string()
}
})
.collect::<Vec<_>>();
let sanitized = segments.join("/");
if sanitized.len() > 128 {
format!("{}...", &sanitized[..128])
} else {
sanitized
}
}
pub fn sanitize_log_message(message: &str) -> String {
let mut sanitized = message.to_string();
for key in [
"token",
"secret",
"passwd",
"password",
"cookie",
"authorization",
] {
sanitized = redact_assignment_value(&sanitized, key);
}
sanitized
}
#[derive(Serialize)]
struct TextLogEvent<'a> {
level: &'a str,
target: &'a str,
message: String,
}
fn is_sensitive_header(header_name: &str) -> bool {
let header_name = header_name.to_ascii_lowercase();
header_name.contains("authorization")
|| header_name.contains("cookie")
|| header_name.contains("token")
|| header_name.contains("secret")
|| matches!(header_name.as_str(), "x-api-key" | "api-key")
}
fn looks_like_identifier(segment: &str) -> bool {
let compact = segment.replace('-', "");
compact.len() >= 16 && compact.chars().all(|ch| ch.is_ascii_hexdigit())
}
fn redact_assignment_value(input: &str, key: &str) -> String {
let lowercase = input.to_ascii_lowercase();
let mut sanitized = String::with_capacity(input.len());
let mut cursor = 0usize;
while let Some(relative_index) = lowercase[cursor..].find(key) {
let key_start = cursor + relative_index;
let separator_index = key_start + key.len();
let separator = lowercase[separator_index..].chars().next();
if !matches!(separator, Some('=') | Some(':')) {
sanitized.push_str(&input[cursor..separator_index]);
cursor = separator_index;
continue;
}
sanitized.push_str(&input[cursor..=separator_index]);
let mut value_start = separator_index + 1;
while let Some(ch) = input[value_start..].chars().next() {
if ch.is_whitespace() {
sanitized.push(ch);
value_start += ch.len_utf8();
} else {
break;
}
}
let mut value_end = value_start;
while let Some(ch) = input[value_end..].chars().next() {
if matches!(ch, ',' | ';' | '&') {
break;
}
value_end += ch.len_utf8();
}
sanitized.push_str("<redacted>");
cursor = value_end;
}
sanitized.push_str(&input[cursor..]);
sanitized
}
#[cfg(test)]
mod tests {
use super::{redact_headers, sanitize_log_message, sanitize_path_for_logs};
#[test]
fn redacts_sensitive_headers() {
let mut headers = http::HeaderMap::new();
headers.insert("authorization", "Bearer secret-token".parse().unwrap());
headers.insert("x-api-key", "super-secret".parse().unwrap());
headers.insert("accept", "application/json".parse().unwrap());
let redacted = redact_headers(&headers);
assert!(
redacted
.iter()
.any(|(key, value)| key == "authorization" && value == "<redacted>")
);
assert!(
redacted
.iter()
.any(|(key, value)| key == "x-api-key" && value == "<redacted>")
);
assert!(
redacted
.iter()
.any(|(key, value)| key == "accept" && value == "application/json")
);
}
#[test]
fn sanitizes_sensitive_log_message_values() {
let sanitized = sanitize_log_message(
"login failed authorization: Bearer abc123 password=hunter2 token=secret",
);
assert!(!sanitized.contains("abc123"));
assert!(!sanitized.contains("hunter2"));
assert!(!sanitized.contains("secret"));
assert!(sanitized.contains("<redacted>"));
}
#[test]
fn sanitizes_log_paths() {
let sanitized =
sanitize_path_for_logs("/users/123456/orders/0123456789abcdef0123456789abcdef");
assert_eq!(sanitized, "/users/:redacted/orders/:redacted");
}
}