use regex::Regex;
use std::sync::LazyLock;
static ANSI_SGR_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\x1b\[[0-9;]*m").expect("hardcoded ANSI SGR regex is valid"));
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EscapeMode {
#[default]
Minimal,
Strict,
JsonSafe,
}
#[derive(Debug, Clone)]
pub struct SanitizerConfig {
pub mode: EscapeMode,
pub max_length: usize,
pub sensitive_patterns: Vec<(Regex, String)>,
pub custom_replacements: Vec<(String, String)>,
}
impl Default for SanitizerConfig {
fn default() -> Self {
Self {
mode: EscapeMode::Minimal,
max_length: 0,
sensitive_patterns: Vec::new(),
custom_replacements: vec![
("\r\n".to_string(), "\\n".to_string()),
("\n".to_string(), "\\n".to_string()),
("\r".to_string(), "\\r".to_string()),
],
}
}
}
#[derive(Debug, Clone)]
pub struct LogSanitizer {
config: SanitizerConfig,
sensitive_regexes: Vec<(Regex, String)>,
}
static DEFAULT_SENSITIVE_PATTERNS: LazyLock<Vec<(Regex, String)>> = LazyLock::new(|| {
vec![
(
Regex::new(r"\b\d{13,16}\b").expect("hardcoded card number regex is valid"),
"[CARD_NUM]".to_string(),
),
(
Regex::new(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b")
.expect("hardcoded email regex is valid"),
"[EMAIL]".to_string(),
),
(
Regex::new(r"\b\d{3}-\d{2}-\d{4}\b").expect("hardcoded SSN regex is valid"),
"[SSN]".to_string(),
),
(
Regex::new(r"(?i)password\s*[=:]\s*\S+").expect("hardcoded password regex is valid"),
"password=[REDACTED]".to_string(),
),
(
Regex::new(r"(?i)token\s*[=:]\s*\S+").expect("hardcoded token regex is valid"),
"token=[REDACTED]".to_string(),
),
(
Regex::new(r"(?i)api[_-]?key\s*[=:]\s*\S+").expect("hardcoded api_key regex is valid"),
"api_key=[REDACTED]".to_string(),
),
(
Regex::new(r"Bearer\s+[A-Za-z0-9\-\.]+")
.expect("hardcoded Bearer token regex is valid"),
"Bearer [TOKEN]".to_string(),
),
(
Regex::new(r"Basic\s+[A-Za-z0-9+/=]+").expect("hardcoded Basic auth regex is valid"),
"Basic [AUTH]".to_string(),
),
]
});
impl LogSanitizer {
pub fn new() -> Self {
Self {
config: SanitizerConfig::default(),
sensitive_regexes: Self::default_sensitive_patterns(),
}
}
pub fn with_config(mut config: SanitizerConfig) -> Self {
let mut sensitive_regexes = Self::default_sensitive_patterns();
sensitive_regexes.append(&mut config.sensitive_patterns);
Self {
config,
sensitive_regexes,
}
}
fn default_sensitive_patterns() -> Vec<(Regex, String)> {
DEFAULT_SENSITIVE_PATTERNS.clone()
}
pub fn sanitize(&self, message: &str) -> String {
let mut result = self.strip_ansi(message).into_owned();
for (pattern, replacement) in &self.sensitive_regexes {
result = pattern
.replace_all(&result, replacement.as_str())
.to_string();
}
for (from, to) in &self.config.custom_replacements {
result = result.replace(from, to);
}
match self.config.mode {
EscapeMode::Minimal => {
result = self.escape_minimal(&result);
}
EscapeMode::Strict => {
result = self.escape_strict(&result);
}
EscapeMode::JsonSafe => {
result = self.escape_json(&result);
}
}
if self.config.max_length > 0 && result.len() > self.config.max_length {
let mut end = self.config.max_length;
while end > 0 && !result.is_char_boundary(end) {
end -= 1;
}
result.truncate(end);
result.push_str("...[truncated]");
}
result
}
fn escape_minimal(&self, s: &str) -> String {
let mut result = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\n' => result.push_str("\\n"),
'\r' => result.push_str("\\r"),
'\t' => result.push_str("\\t"),
c if c.is_control() && c != '\n' && c != '\r' && c != '\t' => {
result.push_str(&format!("\\x{:02x}", c as u8));
}
_ => result.push(c),
}
}
result
}
fn escape_strict(&self, s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
if chars.peek() == Some(&'u') {
result.push(c);
} else {
result.push_str(&format!("\\u{:04x}", c as u32));
}
} else if c.is_control() || c == '"' {
result.push_str(&format!("\\u{:04x}", c as u32));
} else {
result.push(c);
}
}
result
}
fn escape_json(&self, s: &str) -> String {
let mut result = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' => result.push_str("\\\""),
'\\' => result.push_str("\\\\"),
'\n' => result.push_str("\\n"),
'\r' => result.push_str("\\r"),
'\t' => result.push_str("\\t"),
c if c.is_control() => {
result.push_str(&format!("\\u{:04x}", c as u32));
}
_ => result.push(c),
}
}
result
}
pub fn add_pattern(&mut self, pattern: Regex, replacement: String) {
self.sensitive_regexes.push((pattern, replacement));
}
pub fn add_replacement(&mut self, from: String, to: String) {
self.config.custom_replacements.push((from, to));
}
pub fn strip_ansi<'a>(&self, input: &'a str) -> std::borrow::Cow<'a, str> {
if !input.contains('\x1b') {
return std::borrow::Cow::Borrowed(input);
}
std::borrow::Cow::Owned(ANSI_SGR_REGEX.replace_all(input, "").into_owned())
}
}
impl Default for LogSanitizer {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_newline_escaping() {
let sanitizer = LogSanitizer::new();
let result = sanitizer.sanitize("Hello\nWorld");
assert!(result.contains("\\n"));
assert!(!result.contains('\n'));
}
#[test]
fn test_sensitive_data_redaction() {
let sanitizer = LogSanitizer::new();
let result = sanitizer.sanitize("User password=secret123");
assert!(result.contains("[REDACTED]"));
assert!(!result.contains("secret123"));
let result = sanitizer.sanitize("api_key=sk-1234567890");
assert!(result.contains("[REDACTED]"));
assert!(!result.contains("sk-1234567890"));
}
#[test]
fn test_email_redaction() {
let sanitizer = LogSanitizer::new();
let result = sanitizer.sanitize("Contact user@example.com");
assert!(result.contains("[EMAIL]"));
assert!(!result.contains("user@example.com"));
}
#[test]
fn test_escape_modes() {
let config = SanitizerConfig {
mode: EscapeMode::JsonSafe,
..Default::default()
};
let sanitizer = LogSanitizer::with_config(config);
let result = sanitizer.sanitize("Hello\"World");
assert!(result.contains("\\\""));
}
#[test]
fn test_max_length() {
let config = SanitizerConfig {
max_length: 10,
..Default::default()
};
let sanitizer = LogSanitizer::with_config(config);
let result = sanitizer.sanitize("This is a very long message");
assert!(result.len() <= 10 + "...[truncated]".len());
assert!(result.contains("...[truncated]"));
}
#[test]
fn test_control_character_escaping() {
let sanitizer = LogSanitizer::new();
let result = sanitizer.sanitize("Hello\x00World");
assert!(result.contains("\\x00"));
}
#[test]
fn test_default_log_sanitizer() {
let sanitizer = LogSanitizer::default();
let result = sanitizer.sanitize("test\nmessage");
assert!(result.contains("\\n"));
assert!(!result.contains('\n'));
}
#[test]
fn test_strict_escape_mode() {
let config = SanitizerConfig {
mode: EscapeMode::Strict,
custom_replacements: Vec::new(),
..Default::default()
};
let sanitizer = LogSanitizer::with_config(config);
let result = sanitizer.sanitize("Hello\nWorld");
assert!(result.contains("\\u000a"));
assert!(!result.contains('\n'));
}
#[test]
fn test_escape_strict_with_backslash_and_quote() {
let config = SanitizerConfig {
mode: EscapeMode::Strict,
custom_replacements: Vec::new(),
..Default::default()
};
let sanitizer = LogSanitizer::with_config(config);
let result = sanitizer.sanitize("path\\to\"file");
assert!(result.contains("\\u005c"));
assert!(result.contains("\\u0022"));
assert!(!result.contains("\""));
}
#[test]
fn test_escape_strict_preserves_printable() {
let config = SanitizerConfig {
mode: EscapeMode::Strict,
custom_replacements: Vec::new(),
..Default::default()
};
let sanitizer = LogSanitizer::with_config(config);
let result = sanitizer.sanitize("Hello World 123");
assert_eq!(result, "Hello World 123");
}
#[test]
fn test_escape_minimal_with_newline_tab_carriage_return() {
let config = SanitizerConfig {
mode: EscapeMode::Minimal,
custom_replacements: Vec::new(),
..Default::default()
};
let sanitizer = LogSanitizer::with_config(config);
let result = sanitizer.sanitize("line1\nline2\r\ttabbed");
assert_eq!(result, "line1\\nline2\\r\\ttabbed");
assert!(!result.contains('\n'));
assert!(!result.contains('\r'));
assert!(!result.contains('\t'));
}
#[test]
fn test_escape_json_all_special_chars() {
let config = SanitizerConfig {
mode: EscapeMode::JsonSafe,
custom_replacements: Vec::new(),
..Default::default()
};
let sanitizer = LogSanitizer::with_config(config);
let input = "quote\"backslash\\newline\ncarriage\rtab\t";
let result = sanitizer.sanitize(input);
assert!(result.contains("\\\""));
assert!(result.contains("\\\\"));
assert!(result.contains("\\n"));
assert!(result.contains("\\r"));
assert!(result.contains("\\t"));
assert!(!result.contains('\n'));
assert!(!result.contains('\r'));
assert!(!result.contains('\t'));
}
#[test]
fn test_escape_json_control_character() {
let config = SanitizerConfig {
mode: EscapeMode::JsonSafe,
custom_replacements: Vec::new(),
..Default::default()
};
let sanitizer = LogSanitizer::with_config(config);
let result = sanitizer.sanitize("null\x00byte");
assert!(result.contains("\\u0000"));
}
#[test]
fn test_add_pattern() {
let mut sanitizer = LogSanitizer::new();
let pattern = Regex::new(r"SECRET-\d+").expect("valid regex");
sanitizer.add_pattern(pattern, "[SECRET]".to_string());
let result = sanitizer.sanitize("found SECRET-12345 here");
assert!(result.contains("[SECRET]"));
assert!(!result.contains("SECRET-12345"));
}
#[test]
fn test_add_replacement() {
let mut sanitizer = LogSanitizer::new();
sanitizer.add_replacement("foo".to_string(), "bar".to_string());
let result = sanitizer.sanitize("hello foo world");
assert!(result.contains("bar"));
assert!(!result.contains("foo"));
}
#[test]
fn test_add_pattern_and_replacement_combined() {
let mut sanitizer = LogSanitizer::new();
let pattern = Regex::new(r"\bPHONE-\d+\b").expect("valid regex");
sanitizer.add_pattern(pattern, "[PHONE]".to_string());
sanitizer.add_replacement("internal".to_string(), "external".to_string());
let result = sanitizer.sanitize("call PHONE-555 internal line");
assert!(result.contains("[PHONE]"));
assert!(result.contains("external"));
assert!(!result.contains("PHONE-555"));
assert!(!result.contains("internal"));
}
#[test]
fn test_sanitize_truncate_respects_utf8_boundaries() {
let mut config = super::SanitizerConfig::default();
config.max_length = 7;
let sanitizer = super::LogSanitizer::with_config(config);
let result = sanitizer.sanitize("你好世界");
assert!(result.starts_with("你好"));
assert!(result.contains("...[truncated]"));
}
#[test]
fn test_strip_ansi_sgr_sequence() {
let sanitizer = LogSanitizer::new();
assert_eq!(sanitizer.strip_ansi("\x1b[31mERROR\x1b[0m"), "ERROR");
}
#[test]
fn test_strip_ansi_multiple_sequences() {
let sanitizer = LogSanitizer::new();
assert_eq!(sanitizer.strip_ansi("\x1b[1;32mOK\x1b[0m"), "OK");
}
#[test]
fn test_strip_ansi_fast_path_no_esc() {
let sanitizer = LogSanitizer::new();
let input = "no ansi here";
let result = sanitizer.strip_ansi(input);
assert_eq!(result, "no ansi here");
assert!(matches!(result, std::borrow::Cow::Borrowed(_)));
}
#[test]
fn test_strip_ansi_nested_sequences() {
let sanitizer = LogSanitizer::new();
assert_eq!(
sanitizer.strip_ansi("\x1b[1m\x1b[31mBOLD RED\x1b[0m\x1b[0m"),
"BOLD RED"
);
}
#[test]
fn test_strip_ansi_empty_input() {
let sanitizer = LogSanitizer::new();
assert_eq!(sanitizer.strip_ansi(""), "");
}
#[test]
fn test_strip_ansi_only_esc_char() {
let sanitizer = LogSanitizer::new();
let result = sanitizer.strip_ansi("\x1b");
assert_eq!(result, "\x1b");
}
#[test]
fn test_sanitize_strips_ansi_before_processing() {
let sanitizer = LogSanitizer::new();
let result = sanitizer.sanitize("\x1b[31mERROR\x1b[0m");
assert_eq!(result, "ERROR");
assert!(!result.contains('\x1b'));
}
}