use std::sync::{Mutex, OnceLock};
use tempfile::tempdir;
use crate::constants::defaults::REDACTED;
use crate::debuglog::{
enable as enable_debug_log, reset_for_tests as reset_debug_log, test_lock as debug_lock,
};
use super::{RedactedLogger, is_path_like_env_key, looks_sensitive_env_key, redact_text};
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
#[test]
fn looks_sensitive_env_key_matches_expected_values() {
let cases = [
("API_KEY", true),
("password", true),
("auth-token", true),
("TOKEN1", true),
(" secret ", true),
("PATH", false),
("HOME", false),
("SHELL", false),
("MONKEY", false),
("PRIVATEKEY", true),
("APIKEY", true),
];
for (key, expected) in cases {
assert_eq!(looks_sensitive_env_key(key), expected, "key={key}");
}
}
#[test]
fn is_path_like_env_key_matches_expected_values() {
let cases = [
("PATH", true),
("HOME", true),
("TMPDIR", true),
(" pwd ", true),
("SHELL", false),
("PATH_INFO", false),
];
for (key, expected) in cases {
assert_eq!(is_path_like_env_key(key), expected, "key={key}");
}
}
#[test]
fn redact_text_masks_key_value_pairs() {
let input = "API_KEY=abc12345 token:xyz98765 password = hunter2";
let output = redact_text(input);
assert!(!output.contains("abc12345"));
assert!(!output.contains("xyz98765"));
assert!(!output.contains("hunter2"));
assert!(output.contains("API_KEY=[REDACTED]"));
assert!(output.contains("token:[REDACTED]"));
assert!(output.contains("password = [REDACTED]"));
}
#[test]
fn redact_text_masks_bearer_tokens() {
let input = "Authorization: Bearer abcdef123456";
let output = redact_text(input);
assert!(!output.contains("abcdef123456"));
assert!(output.contains("Bearer [REDACTED]"));
}
#[test]
fn redact_text_handles_non_ascii() {
let input = "Read AGENTS.md — voila âêîö ä½ å¥½";
let output = redact_text(input);
assert_eq!(output, input);
}
#[test]
fn redact_text_masks_sensitive_env_values() {
let _guard = env_lock().lock().expect("env lock");
unsafe { std::env::set_var("API_TOKEN", "supersecretvalue") };
let input = "token is supersecretvalue";
let output = redact_text(input);
unsafe { std::env::remove_var("API_TOKEN") };
assert!(!output.contains("supersecretvalue"));
assert!(output.contains(REDACTED));
}
#[test]
fn redact_text_leaves_non_sensitive_env_values() {
let _guard = env_lock().lock().expect("env lock");
let key = "RALPH_NON_SENSITIVE_ENV";
let value = "visible_plain_value";
unsafe { std::env::set_var(key, value) };
let input = "value is visible_plain_value";
let output = redact_text(input);
unsafe { std::env::remove_var(key) };
assert!(output.contains(value));
}
#[test]
fn redact_text_masks_privatekey_env_value() {
let _guard = env_lock().lock().expect("env lock");
unsafe { std::env::set_var("PRIVATEKEY", "supersecretkeyvalue") };
let input = "key is supersecretkeyvalue";
let output = redact_text(input);
unsafe { std::env::remove_var("PRIVATEKEY") };
assert!(!output.contains("supersecretkeyvalue"));
assert!(output.contains(REDACTED));
}
#[test]
fn redact_text_reads_latest_sensitive_env_values_without_manual_cache_clear() {
let _guard = env_lock().lock().expect("env lock");
unsafe { std::env::set_var("API_TOKEN", "initialsecretvalue") };
let first = redact_text("token is initialsecretvalue");
unsafe { std::env::set_var("API_TOKEN", "updatedsecretvalue") };
let second = redact_text("token is updatedsecretvalue");
unsafe { std::env::remove_var("API_TOKEN") };
assert!(!first.contains("initialsecretvalue"));
assert!(!second.contains("updatedsecretvalue"));
assert!(first.contains(REDACTED));
assert!(second.contains(REDACTED));
}
struct MockLogger {
last_msg: std::sync::Arc<std::sync::Mutex<String>>,
}
impl log::Log for MockLogger {
fn enabled(&self, _: &log::Metadata) -> bool {
true
}
fn log(&self, record: &log::Record) {
let mut lock = self.last_msg.lock().unwrap();
*lock = format!("{}", record.args());
}
fn flush(&self) {}
}
#[test]
fn redacted_logger_masks_output() {
let last_msg = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
let mock = Box::new(MockLogger {
last_msg: last_msg.clone(),
});
let wrapper = RedactedLogger::new(mock);
let record = log::Record::builder()
.args(format_args!("Connecting with API_KEY=secret123"))
.level(log::Level::Info)
.build();
use log::Log;
wrapper.log(&record);
let msg = last_msg.lock().unwrap();
assert!(!msg.contains("secret123"));
assert!(msg.contains("API_KEY=[REDACTED]"));
}
#[test]
fn redacted_logger_writes_raw_log_to_debug_log() {
let _guard = debug_lock().lock().expect("debug log lock");
reset_debug_log();
let dir = tempdir().expect("tempdir");
enable_debug_log(dir.path()).expect("enable debug log");
let last_msg = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
let mock = Box::new(MockLogger {
last_msg: last_msg.clone(),
});
let wrapper = RedactedLogger::new(mock);
let record = log::Record::builder()
.args(format_args!("Connecting with API_KEY=secret123"))
.level(log::Level::Info)
.build();
use log::Log;
wrapper.log(&record);
let debug_log = dir.path().join(".ralph/logs/debug.log");
let contents = std::fs::read_to_string(&debug_log).expect("read log");
assert!(contents.contains("API_KEY=secret123"), "log: {contents}");
reset_debug_log();
}