use anyhow::{Context, Result};
use chrono::{Local, Utc};
use std::{
borrow::Cow,
fs::OpenOptions,
io::{BufWriter, Write},
sync::{
Arc, Mutex, RwLock,
atomic::{AtomicUsize, Ordering},
},
};
pub const REDACTED: &str = "***";
const MIN_SCRUBBED_SECRET_LEN: usize = 6;
#[derive(Clone, Copy, Debug)]
pub enum LogKind {
GmailImap,
Jmap,
}
impl LogKind {
fn slug(self) -> &'static str {
match self {
Self::GmailImap => "gmail",
Self::Jmap => "jmap",
}
}
fn title(self) -> &'static str {
match self {
Self::GmailImap => "Gmail IMAP",
Self::Jmap => "JMAP",
}
}
fn slot(self) -> &'static Mutex<Option<Arc<DebugLog>>> {
static GMAIL: Mutex<Option<Arc<DebugLog>>> = Mutex::new(None);
static JMAP: Mutex<Option<Arc<DebugLog>>> = Mutex::new(None);
match self {
Self::GmailImap => &GMAIL,
Self::Jmap => &JMAP,
}
}
}
#[derive(Debug)]
pub struct DebugLog {
file: Mutex<BufWriter<std::fs::File>>,
next_id: AtomicUsize,
secrets: RwLock<Vec<String>>,
}
impl DebugLog {
fn init(kind: LogKind) -> Result<Self> {
let now = Local::now();
let stamp = now.format("%Y-%m-%d-%H%M").to_string();
let pid = std::process::id();
let filename = format!("{}-log-{stamp}-{pid}.log", kind.slug());
let path = std::env::current_dir()
.context("determining current directory for debug log file")?
.join(filename);
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("opening debug log file at {}", path.display()))?;
let mut writer = BufWriter::new(file);
let header_time = now.format("%Y-%m-%d %H:%M:%S");
writeln!(
writer,
"# {} debug log started {header_time} local, pid {pid}",
kind.title()
)
.ok();
Ok(Self {
file: Mutex::new(writer),
next_id: AtomicUsize::new(1),
secrets: RwLock::new(Vec::new()),
})
}
pub fn global(kind: LogKind) -> Result<Arc<Self>> {
let mut slot = kind.slot().lock().unwrap_or_else(|err| err.into_inner());
if let Some(existing) = slot.as_ref() {
return Ok(Arc::clone(existing));
}
let logger = Arc::new(Self::init(kind)?);
*slot = Some(Arc::clone(&logger));
Ok(logger)
}
pub fn allocate_connection_id(&self) -> usize {
self.next_id.fetch_add(1, Ordering::AcqRel)
}
pub fn register_secret(&self, secret: &str) {
if secret.len() < MIN_SCRUBBED_SECRET_LEN {
return;
}
let mut secrets = self.secrets.write().unwrap_or_else(|err| err.into_inner());
if !secrets.iter().any(|known| known == secret) {
secrets.push(secret.to_owned());
}
}
pub fn scrub<'a>(&self, text: &'a str) -> Cow<'a, str> {
let secrets = self.secrets.read().unwrap_or_else(|err| err.into_inner());
let mut result = Cow::Borrowed(text);
for secret in secrets.iter() {
if result.contains(secret.as_str()) {
result = Cow::Owned(result.replace(secret.as_str(), REDACTED));
}
}
result
}
pub fn log_event(&self, scope: &str, label: &str, payload: &str) {
let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%S%.6fZ");
let payload = self.scrub(payload);
if let Ok(mut writer) = self.file.lock() {
let _ = writeln!(writer, "{timestamp} {scope} {label}: {payload}");
let _ = writer.flush();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn log() -> DebugLog {
DebugLog {
file: Mutex::new(BufWriter::new(tempfile::tempfile().unwrap())),
next_id: AtomicUsize::new(1),
secrets: RwLock::new(Vec::new()),
}
}
#[test]
fn scrubs_registered_secret_anywhere_in_the_payload() {
let log = log();
log.register_secret("hunter2hunter2");
let scrubbed = log.scrub(r#"A001 LOGIN "user" "hunter2hunter2""#);
assert_eq!(scrubbed, r#"A001 LOGIN "user" "***""#);
}
#[test]
fn scrubs_every_occurrence_of_every_secret() {
let log = log();
log.register_secret("app-password");
log.register_secret("bearer-token-value");
let scrubbed = log.scrub("app-password bearer-token-value app-password");
assert_eq!(scrubbed, "*** *** ***");
}
#[test]
fn leaves_payloads_without_secrets_untouched() {
let log = log();
log.register_secret("app-password");
assert!(matches!(
log.scrub("* OK Gimap ready for requests"),
Cow::Borrowed(_)
));
}
#[test]
fn ignores_secrets_too_short_to_search_for() {
let log = log();
log.register_secret("abc");
assert_eq!(log.scrub("FETCH abcdef"), "FETCH abcdef");
}
}