use regex::Regex;
use std::path::PathBuf;
use std::sync::OnceLock;
use crate::config::{LogFileInfo, LogFormat, LogRotationConfig, RotationPolicy};
#[allow(dead_code)] static SECRET_PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new();
#[allow(dead_code)] fn secret_patterns() -> &'static Vec<Regex> {
SECRET_PATTERNS.get_or_init(|| {
vec![
Regex::new(r"\b\d{8,12}:[A-Za-z0-9_-]{30,50}\b").unwrap(),
Regex::new(r"(?i)bearer\s+[A-Za-z0-9._~+/=-]{20,}").unwrap(),
Regex::new(r#"(?i)(api[_-]?key|token|secret|password)\s*[=:]\s*['"]?[A-Za-z0-9._~+/=-]{16,}['"]?"#).unwrap(),
Regex::new(r"xox[bpa]-[A-Za-z0-9-]{10,}").unwrap(),
Regex::new(r"eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+").unwrap(),
]
})
}
#[derive(Debug, Clone)]
#[allow(dead_code)] pub struct SecretRedactor {
replacement: String,
}
impl SecretRedactor {
#[allow(dead_code)] pub fn new() -> Self {
Self {
replacement: "***REDACTED***".to_string(),
}
}
#[allow(dead_code)] pub fn with_replacement(replacement: &str) -> Self {
Self {
replacement: replacement.to_string(),
}
}
#[allow(dead_code)] pub fn redact(&self, input: &str) -> String {
let mut result = input.to_string();
for pattern in secret_patterns() {
result = pattern
.replace_all(&result, self.replacement.as_str())
.to_string();
}
result
}
}
impl Default for SecretRedactor {
fn default() -> Self {
Self::new()
}
}
const SENSITIVE_FIELDS: &[&str] = &[
"bot_token",
"botToken",
"app_token",
"appToken",
"token",
"password",
"secret",
"api_key",
"apiKey",
"connection_string",
"connectionString",
];
pub fn redact_config(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut redacted = serde_json::Map::new();
for (key, val) in map {
if SENSITIVE_FIELDS.iter().any(|&f| key == f) {
match val {
serde_json::Value::String(s) if !s.is_empty() => {
redacted.insert(key.clone(), serde_json::Value::String("***".into()));
}
_ => {
redacted.insert(key.clone(), redact_config(val));
}
}
} else {
redacted.insert(key.clone(), redact_config(val));
}
}
serde_json::Value::Object(redacted)
}
serde_json::Value::Array(arr) => {
serde_json::Value::Array(arr.iter().map(redact_config).collect())
}
other => other.clone(),
}
}
#[derive(Debug, Clone)]
pub struct TelemetrySetup {
pub json_format: bool,
pub pretty_format: bool,
pub otel_endpoint: Option<String>,
pub log_dir: Option<String>,
pub rotation_config: LogRotationConfig,
}
impl TelemetrySetup {
pub fn from_config(config: &crate::config::TelemetryConfig) -> Self {
let effective_format = config
.log_rotation
.format
.as_ref()
.unwrap_or(&config.log_format);
Self {
json_format: matches!(effective_format, LogFormat::Json),
pretty_format: matches!(effective_format, LogFormat::Pretty | LogFormat::Text),
otel_endpoint: config.otel_endpoint.clone(),
log_dir: config.log_dir.clone(),
rotation_config: config.log_rotation.clone(),
}
}
fn effective_format_from_env(&self) -> (bool, bool) {
match std::env::var("LOG_FORMAT").ok().as_deref() {
Some("json") => (true, false),
Some("pretty") => (false, true),
_ => (self.json_format, self.pretty_format),
}
}
pub fn init(&self, filter: tracing_subscriber::EnvFilter) {
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt};
let (use_json, _use_pretty) = self.effective_format_from_env();
let file_guard = if let Some(ref log_dir) = self.log_dir {
std::fs::create_dir_all(log_dir).ok();
let file_appender = match &self.rotation_config.rotation {
RotationPolicy::Hourly => {
tracing_appender::rolling::hourly(log_dir, "adk-gateway.log")
}
RotationPolicy::Size { .. } => {
tracing_appender::rolling::daily(log_dir, "adk-gateway.log")
}
RotationPolicy::Daily => {
tracing_appender::rolling::daily(log_dir, "adk-gateway.log")
}
};
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
Box::leak(Box::new(guard));
Some(non_blocking)
} else {
None
};
if use_json {
let registry = tracing_subscriber::registry()
.with(filter)
.with(fmt::layer().json().with_target(true));
if let Some(writer) = file_guard {
registry
.with(fmt::layer().json().with_target(true).with_writer(writer))
.init();
} else {
registry.init();
}
} else {
let registry = tracing_subscriber::registry()
.with(filter)
.with(fmt::layer().with_target(true));
if let Some(writer) = file_guard {
registry
.with(fmt::layer().with_target(true).with_ansi(false).with_writer(writer))
.init();
} else {
registry.init();
}
}
}
pub fn describe(&self) -> String {
let (use_json, use_pretty) = self.effective_format_from_env();
let format = if use_json {
"JSON"
} else if use_pretty {
"pretty"
} else {
"text"
};
let otel = match &self.otel_endpoint {
Some(ep) => format!("enabled ({})", ep),
None => "disabled".to_string(),
};
let file = match &self.log_dir {
Some(dir) => {
let rotation = match &self.rotation_config.rotation {
RotationPolicy::Daily => "daily",
RotationPolicy::Hourly => "hourly",
RotationPolicy::Size { .. } => "size-based",
};
format!(
"{} rotation in {}, retention={}d, max_size={}MB",
rotation, dir, self.rotation_config.retention_days, self.rotation_config.max_file_size_mb
)
}
None => "disabled".to_string(),
};
format!("log_format={format}, otel={otel}, file_logs={file}")
}
}
pub struct LogRetentionManager {
config: LogRotationConfig,
log_dir: PathBuf,
}
impl LogRetentionManager {
pub fn new(log_dir: impl Into<PathBuf>, config: LogRotationConfig) -> Self {
Self {
config,
log_dir: log_dir.into(),
}
}
pub fn scan_log_files(&self) -> Vec<LogFileInfo> {
let mut files = Vec::new();
let entries = match std::fs::read_dir(&self.log_dir) {
Ok(entries) => entries,
Err(_) => return files,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let file_name = match path.file_name().and_then(|n| n.to_str()) {
Some(name) => name.to_string(),
None => continue,
};
if !file_name.starts_with("adk-gateway.log") {
continue;
}
let metadata = match std::fs::metadata(&path) {
Ok(m) => m,
Err(_) => continue,
};
let created_at = Self::parse_log_date_from_filename(&file_name)
.or_else(|| {
metadata
.created()
.ok()
.map(|t| chrono::DateTime::<chrono::Utc>::from(t))
})
.unwrap_or_else(|| chrono::Utc::now());
files.push(LogFileInfo {
path,
created_at,
size_bytes: metadata.len(),
});
}
files
}
fn parse_log_date_from_filename(filename: &str) -> Option<chrono::DateTime<chrono::Utc>> {
let date_part = filename.strip_prefix("adk-gateway.log.")?;
let naive_date = chrono::NaiveDate::parse_from_str(date_part, "%Y-%m-%d").ok()?;
let naive_datetime = naive_date.and_hms_opt(0, 0, 0)?;
Some(chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
naive_datetime,
chrono::Utc,
))
}
pub fn cleanup(&self) -> Vec<PathBuf> {
let now = chrono::Utc::now();
let files = self.scan_log_files();
let to_delete = self.config.files_to_delete(&files, now);
let mut deleted = Vec::new();
for path in to_delete {
if std::fs::remove_file(&path).is_ok() {
tracing::info!(path = %path.display(), "Deleted expired log file");
deleted.push(path);
} else {
tracing::warn!(path = %path.display(), "Failed to delete expired log file");
}
}
deleted
}
pub fn oversized_files(&self) -> Vec<LogFileInfo> {
let max_bytes = self.config.max_file_size_mb * 1024 * 1024;
self.scan_log_files()
.into_iter()
.filter(|f| f.size_bytes > max_bytes)
.collect()
}
}
pub fn spawn_log_retention_task(
log_dir: impl Into<PathBuf>,
config: LogRotationConfig,
cancel_token: tokio_util::sync::CancellationToken,
) -> tokio::task::JoinHandle<()> {
let manager = LogRetentionManager::new(log_dir, config);
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600));
interval.tick().await;
loop {
tokio::select! {
_ = cancel_token.cancelled() => {
tracing::info!("Log retention task cancelled");
break;
}
_ = interval.tick() => {
let deleted = manager.cleanup();
if !deleted.is_empty() {
tracing::info!(count = deleted.len(), "Log retention cleanup completed");
}
let oversized = manager.oversized_files();
if !oversized.is_empty() {
tracing::warn!(
count = oversized.len(),
"detected oversized log files exceeding max_file_size_mb"
);
}
}
}
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{LogFileInfo, LogRotationConfig, RotationPolicy};
use chrono::{Duration, Utc};
use serde_json::json;
use std::path::PathBuf;
#[test]
fn test_redact_no_secrets() {
let redactor = SecretRedactor::new();
let input = "this is a normal log message with no secrets";
let result = redactor.redact(input);
assert_eq!(result, input);
}
#[test]
fn test_redact_custom_replacement() {
let redactor = SecretRedactor::with_replacement("[HIDDEN]");
let input = "token: xoxb-test-placeholder";
let result = redactor.redact(input);
assert!(result.contains("[HIDDEN]"));
}
#[test]
fn test_redact_config_simple() {
let config = json!({
"gateway": {
"port": 18789,
"auth": {
"token": "my-secret-token",
"password": "my-password"
}
},
"channels": {
"telegram": {
"botToken": "123:ABC",
"enabled": true
}
}
});
let redacted = redact_config(&config);
assert_eq!(redacted["gateway"]["auth"]["token"], "***");
assert_eq!(redacted["gateway"]["auth"]["password"], "***");
assert_eq!(redacted["channels"]["telegram"]["botToken"], "***");
assert_eq!(redacted["gateway"]["port"], 18789);
assert_eq!(redacted["channels"]["telegram"]["enabled"], true);
}
#[test]
fn test_redact_config_nested_array() {
let config = json!({
"items": [
{"token": "secret1", "name": "a"},
{"token": "secret2", "name": "b"}
]
});
let redacted = redact_config(&config);
assert_eq!(redacted["items"][0]["token"], "***");
assert_eq!(redacted["items"][1]["token"], "***");
assert_eq!(redacted["items"][0]["name"], "a");
}
#[test]
fn test_redact_config_empty_token() {
let config = json!({
"token": "",
"password": null
});
let redacted = redact_config(&config);
assert_eq!(redacted["token"], "");
assert!(redacted["password"].is_null());
}
#[test]
fn test_telemetry_setup_describe() {
let setup = TelemetrySetup {
json_format: true,
pretty_format: false,
otel_endpoint: Some("http://localhost:4317".into()),
log_dir: None,
rotation_config: LogRotationConfig::default(),
};
let desc = setup.describe();
assert!(desc.contains("JSON"));
assert!(desc.contains("localhost:4317"));
}
#[test]
fn test_telemetry_setup_no_otel() {
let setup = TelemetrySetup {
json_format: false,
pretty_format: true,
otel_endpoint: None,
log_dir: None,
rotation_config: LogRotationConfig::default(),
};
let desc = setup.describe();
assert!(desc.contains("pretty"));
assert!(desc.contains("disabled"));
}
#[test]
fn test_files_to_delete_empty_list() {
let config = LogRotationConfig::default();
let now = Utc::now();
let result = config.files_to_delete(&[], now);
assert!(result.is_empty());
}
#[test]
fn test_files_to_delete_all_within_retention() {
let config = LogRotationConfig {
retention_days: 7,
..Default::default()
};
let now = Utc::now();
let files = vec![
LogFileInfo {
path: PathBuf::from("/logs/adk-gateway.log.2026-05-14"),
created_at: now - Duration::days(1),
size_bytes: 1024,
},
LogFileInfo {
path: PathBuf::from("/logs/adk-gateway.log.2026-05-13"),
created_at: now - Duration::days(3),
size_bytes: 2048,
},
LogFileInfo {
path: PathBuf::from("/logs/adk-gateway.log.2026-05-10"),
created_at: now - Duration::days(6),
size_bytes: 4096,
},
];
let result = config.files_to_delete(&files, now);
assert!(result.is_empty(), "No files should be deleted within retention window");
}
#[test]
fn test_files_to_delete_some_expired() {
let config = LogRotationConfig {
retention_days: 7,
..Default::default()
};
let now = Utc::now();
let files = vec![
LogFileInfo {
path: PathBuf::from("/logs/recent.log"),
created_at: now - Duration::days(2),
size_bytes: 1024,
},
LogFileInfo {
path: PathBuf::from("/logs/old.log"),
created_at: now - Duration::days(10),
size_bytes: 2048,
},
LogFileInfo {
path: PathBuf::from("/logs/very_old.log"),
created_at: now - Duration::days(30),
size_bytes: 4096,
},
];
let result = config.files_to_delete(&files, now);
assert_eq!(result.len(), 2);
assert!(result.contains(&PathBuf::from("/logs/old.log")));
assert!(result.contains(&PathBuf::from("/logs/very_old.log")));
}
#[test]
fn test_files_to_delete_boundary_exactly_at_retention() {
let config = LogRotationConfig {
retention_days: 7,
..Default::default()
};
let now = Utc::now();
let files = vec![LogFileInfo {
path: PathBuf::from("/logs/boundary.log"),
created_at: now - Duration::days(7),
size_bytes: 1024,
}];
let result = config.files_to_delete(&files, now);
assert!(result.is_empty(), "File exactly at retention boundary should not be deleted");
}
#[test]
fn test_files_to_delete_just_past_retention() {
let config = LogRotationConfig {
retention_days: 7,
..Default::default()
};
let now = Utc::now();
let files = vec![LogFileInfo {
path: PathBuf::from("/logs/expired.log"),
created_at: now - Duration::days(7) - Duration::seconds(1),
size_bytes: 1024,
}];
let result = config.files_to_delete(&files, now);
assert_eq!(result.len(), 1);
assert_eq!(result[0], PathBuf::from("/logs/expired.log"));
}
#[test]
fn test_files_to_delete_custom_retention() {
let config = LogRotationConfig {
retention_days: 30,
..Default::default()
};
let now = Utc::now();
let files = vec![
LogFileInfo {
path: PathBuf::from("/logs/recent.log"),
created_at: now - Duration::days(10),
size_bytes: 1024,
},
LogFileInfo {
path: PathBuf::from("/logs/old.log"),
created_at: now - Duration::days(31),
size_bytes: 2048,
},
];
let result = config.files_to_delete(&files, now);
assert_eq!(result.len(), 1);
assert_eq!(result[0], PathBuf::from("/logs/old.log"));
}
#[test]
fn test_files_to_delete_zero_retention_deletes_all_past() {
let config = LogRotationConfig {
retention_days: 0,
..Default::default()
};
let now = Utc::now();
let files = vec![
LogFileInfo {
path: PathBuf::from("/logs/today.log"),
created_at: now,
size_bytes: 1024,
},
LogFileInfo {
path: PathBuf::from("/logs/yesterday.log"),
created_at: now - Duration::days(1),
size_bytes: 2048,
},
];
let result = config.files_to_delete(&files, now);
assert_eq!(result.len(), 1);
assert_eq!(result[0], PathBuf::from("/logs/yesterday.log"));
}
#[test]
fn test_parse_log_date_from_filename() {
let result = LogRetentionManager::parse_log_date_from_filename("adk-gateway.log.2026-05-12");
assert!(result.is_some());
let dt = result.unwrap();
assert_eq!(dt.format("%Y-%m-%d").to_string(), "2026-05-12");
}
#[test]
fn test_parse_log_date_invalid_filename() {
let result = LogRetentionManager::parse_log_date_from_filename("random-file.txt");
assert!(result.is_none());
}
#[test]
fn test_log_rotation_config_default() {
let config = LogRotationConfig::default();
assert_eq!(config.retention_days, 7);
assert_eq!(config.max_file_size_mb, 100);
assert_eq!(config.rotation, RotationPolicy::Daily);
assert!(config.format.is_none());
}
#[test]
fn test_log_format_env_override() {
let setup = TelemetrySetup {
json_format: false,
pretty_format: true,
otel_endpoint: None,
log_dir: None,
rotation_config: LogRotationConfig::default(),
};
assert!(!setup.json_format);
assert!(setup.pretty_format);
}
}