use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use parking_lot::RwLock;
pub const MIN_SECRET_LEN: usize = 8;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum SecretCategory {
ApiKey,
Token,
Password,
Certificate,
Webhook,
Generic,
}
impl SecretCategory {
#[must_use]
pub fn from_key_name(key: &str) -> Self {
let lower = key.to_ascii_lowercase();
if lower.contains("api_key") || lower.contains("apikey") {
Self::ApiKey
} else if lower.contains("token") {
Self::Token
} else if lower.contains("password") || lower.contains("passwd") {
Self::Password
} else if lower.contains("cert") {
Self::Certificate
} else if lower.contains("webhook") {
Self::Webhook
} else {
Self::Generic
}
}
fn as_str(self) -> &'static str {
match self {
Self::ApiKey => "api_key",
Self::Token => "token",
Self::Password => "password",
Self::Certificate => "certificate",
Self::Webhook => "webhook",
Self::Generic => "generic",
}
}
}
#[derive(Default)]
pub struct SecretMaskRegistry {
forward: RwLock<HashMap<String, String>>,
reverse: RwLock<HashMap<String, String>>,
sorted_pairs: RwLock<Vec<(String, String)>>,
nonce: String,
counter: AtomicUsize,
}
impl std::fmt::Debug for SecretMaskRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SecretMaskRegistry")
.field("nonce", &self.nonce)
.field("entries", &self.forward.read().len())
.finish_non_exhaustive()
}
}
impl SecretMaskRegistry {
#[must_use]
pub fn new() -> Self {
let nonce: u64 = rand::random();
let nonce = format!("{nonce:016x}");
Self {
forward: RwLock::new(HashMap::new()),
reverse: RwLock::new(HashMap::new()),
sorted_pairs: RwLock::new(Vec::new()),
nonce,
counter: AtomicUsize::new(0),
}
}
pub fn register(&self, _key_name: &str, secret_value: &str, category: SecretCategory) {
if secret_value.len() < MIN_SECRET_LEN {
return;
}
let mut forward = self.forward.write();
if forward.contains_key(secret_value) {
return;
}
let index = self.counter.fetch_add(1, Ordering::Relaxed);
let placeholder = format!("<SECRET:{}:{}:{}>", category.as_str(), self.nonce, index);
let mut pairs = self.sorted_pairs.write();
forward.insert(secret_value.to_owned(), placeholder.clone());
self.reverse
.write()
.insert(placeholder.clone(), secret_value.to_owned());
pairs.push((secret_value.to_owned(), placeholder));
pairs.sort_unstable_by_key(|(s, _)| std::cmp::Reverse(s.len()));
}
#[must_use]
pub fn mask(&self, text: &str) -> String {
let pairs = self.sorted_pairs.read();
if pairs.is_empty() {
return text.to_owned();
}
let mut result = text.to_owned();
for (secret, placeholder) in pairs.iter() {
result = result.replace(secret.as_str(), placeholder.as_str());
}
result
}
#[must_use]
pub fn would_mask(&self, text: &str) -> bool {
let pairs = self.sorted_pairs.read();
pairs.iter().any(|(secret, _)| text.contains(secret))
}
#[must_use]
pub fn unmask(&self, text: &str) -> String {
let reverse = self.reverse.read();
if reverse.is_empty() {
return text.to_owned();
}
let mut result = text.to_owned();
for (placeholder, secret) in reverse.iter() {
result = result.replace(placeholder.as_str(), secret.as_str());
}
result
}
#[must_use]
pub fn len(&self) -> usize {
self.forward.read().len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.forward.read().is_empty()
}
#[must_use]
pub fn nonce(&self) -> &str {
&self.nonce
}
}
impl zeph_llm::masking::OutboundMasker for SecretMaskRegistry {
fn mask(&self, text: &str) -> Option<String> {
if self.would_mask(text) {
Some(self.mask(text))
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn registry_with(secrets: &[(&str, &str, SecretCategory)]) -> SecretMaskRegistry {
let r = SecretMaskRegistry::new();
for (key, val, cat) in secrets {
r.register(key, val, *cat);
}
r
}
#[test]
fn category_from_key_name_api_key() {
assert_eq!(
SecretCategory::from_key_name("ZEPH_OPENAI_API_KEY"),
SecretCategory::ApiKey
);
assert_eq!(
SecretCategory::from_key_name("APIKEY_STRIPE"),
SecretCategory::ApiKey
);
}
#[test]
fn category_from_key_name_token() {
assert_eq!(
SecretCategory::from_key_name("TELEGRAM_BOT_TOKEN"),
SecretCategory::Token
);
}
#[test]
fn category_from_key_name_password() {
assert_eq!(
SecretCategory::from_key_name("DB_PASSWORD"),
SecretCategory::Password
);
assert_eq!(
SecretCategory::from_key_name("REDIS_PASSWD"),
SecretCategory::Password
);
}
#[test]
fn category_from_key_name_generic() {
assert_eq!(
SecretCategory::from_key_name("SOMETHING_RANDOM"),
SecretCategory::Generic
);
}
#[test]
fn short_secret_below_min_len_not_registered() {
let r = SecretMaskRegistry::new();
r.register("K", "short", SecretCategory::Generic); assert!(
r.is_empty(),
"secret shorter than MIN_SECRET_LEN must be ignored"
);
}
#[test]
fn secret_exactly_at_min_len_is_registered() {
let r = SecretMaskRegistry::new();
r.register("K", "12345678", SecretCategory::Generic); assert_eq!(r.len(), 1);
}
#[test]
fn mask_unmask_roundtrip() {
let r = registry_with(&[("KEY", "mysecretvalue!!", SecretCategory::ApiKey)]);
let original = "Authorization: Bearer mysecretvalue!!";
let masked = r.mask(original);
assert!(
!masked.contains("mysecretvalue!!"),
"secret must not appear in masked output"
);
assert!(
masked.contains("<SECRET:api_key:"),
"placeholder prefix must appear"
);
let restored = r.unmask(&masked);
assert_eq!(restored, original, "unmask must restore original text");
}
#[test]
fn mask_text_without_secret_unchanged() {
let r = registry_with(&[("KEY", "mysecretvalue!!", SecretCategory::ApiKey)]);
let text = "no secrets here at all";
assert_eq!(r.mask(text), text);
}
#[test]
fn unmask_text_without_placeholder_unchanged() {
let r = registry_with(&[("KEY", "mysecretvalue!!", SecretCategory::ApiKey)]);
let text = "no placeholders here";
assert_eq!(r.unmask(text), text);
}
#[test]
fn nonce_unique_across_registries() {
let nonces: Vec<String> = (0..20)
.map(|_| SecretMaskRegistry::new().nonce().to_owned())
.collect();
for n in &nonces {
assert_eq!(n.len(), 16, "nonce must be 16 hex chars");
assert!(
n.chars().all(|c| c.is_ascii_hexdigit()),
"nonce must be hex"
);
}
let unique: std::collections::HashSet<&str> = nonces.iter().map(String::as_str).collect();
assert_eq!(
unique.len(),
nonces.len(),
"all registry nonces must be distinct"
);
}
#[test]
fn placeholder_contains_nonce() {
let r = SecretMaskRegistry::new();
r.register("KEY", "secretpassword1", SecretCategory::Password);
let masked = r.mask("secretpassword1");
assert!(
masked.contains(r.nonce()),
"placeholder must embed the session nonce"
);
}
#[test]
fn longer_secret_replaced_before_shorter_prefix() {
let r = SecretMaskRegistry::new();
r.register("SHORT", "abcdefgh", SecretCategory::Generic);
r.register("LONG", "abcdefghijklmnop", SecretCategory::Generic);
let text = "value: abcdefghijklmnop extra";
let masked = r.mask(text);
assert!(
!masked.contains("abcdefgh"),
"no secret fragment must remain after mask"
);
}
#[test]
fn duplicate_secret_value_reuses_placeholder() {
let r = SecretMaskRegistry::new();
r.register("KEY1", "shared-secret-abc", SecretCategory::Token);
r.register("KEY2", "shared-secret-abc", SecretCategory::Token); assert_eq!(
r.len(),
1,
"duplicate secret value must not create a second entry"
);
}
#[test]
fn empty_registry_mask_is_identity() {
let r = SecretMaskRegistry::new();
assert_eq!(r.mask("any text"), "any text");
assert_eq!(r.unmask("any text"), "any text");
}
#[test]
fn would_mask_true_when_secret_present() {
let r = registry_with(&[("KEY", "supersecretvalue1", SecretCategory::ApiKey)]);
assert!(r.would_mask("prefix supersecretvalue1 suffix"));
}
#[test]
fn would_mask_false_when_no_secret_present() {
let r = registry_with(&[("KEY", "supersecretvalue1", SecretCategory::ApiKey)]);
assert!(!r.would_mask("nothing sensitive in this text"));
}
#[test]
fn would_mask_false_on_empty_registry() {
let r = SecretMaskRegistry::new();
assert!(!r.would_mask("supersecretvalue1"));
}
#[test]
fn would_mask_matches_mask_outcome() {
let r = registry_with(&[("KEY", "supersecretvalue1", SecretCategory::ApiKey)]);
for text in ["supersecretvalue1 here", "nothing here", ""] {
let predicted = r.would_mask(text);
let actual_changed = r.mask(text) != text;
assert_eq!(predicted, actual_changed, "mismatch for text: {text:?}");
}
}
#[test]
fn concurrent_register_and_mask_never_expose_raw_secret() {
use std::sync::{Arc, Barrier};
use std::thread;
let registry = Arc::new(SecretMaskRegistry::new());
let secret = "super-secret-value-9999";
let text = format!("token={secret} end");
registry.register("KEY", secret, SecretCategory::ApiKey);
let barrier = Arc::new(Barrier::new(2));
let iterations = 2_000;
let reg_clone = Arc::clone(®istry);
let barrier_clone = Arc::clone(&barrier);
let register_thread = thread::spawn(move || {
barrier_clone.wait();
for _ in 0..iterations {
reg_clone.register("KEY", secret, SecretCategory::ApiKey);
}
});
barrier.wait();
for _ in 0..iterations {
let masked = registry.mask(&text);
assert!(
!masked.contains(secret),
"raw secret must not appear in masked output: {masked}"
);
}
register_thread.join().expect("register thread panicked");
}
#[test]
fn cross_registry_unmask_isolation() {
let r1 = SecretMaskRegistry::new();
r1.register("K1", "secret-alpha-xyz1", SecretCategory::ApiKey);
let r2 = SecretMaskRegistry::new();
r2.register("K2", "secret-beta-abc99", SecretCategory::Token);
let masked_by_r2 = r2.mask("value secret-beta-abc99 end");
assert!(!masked_by_r2.contains("secret-beta-abc99"));
let result = r1.unmask(&masked_by_r2);
assert_eq!(
result, masked_by_r2,
"r1 must not unmask a placeholder it never registered (nonce isolation)"
);
let restored = r2.unmask(&masked_by_r2);
assert!(
restored.contains("secret-beta-abc99"),
"r2 must unmask its own placeholder"
);
}
}