use crate::types::redaction::{PiiCategory, RedactionStrategy};
use ahash::AHashMap;
use sha2::{Digest, Sha256};
#[derive(Debug, Default, Clone)]
pub struct TokenCounter {
counts: AHashMap<String, u32>,
cache: AHashMap<(String, String), String>,
}
impl TokenCounter {
pub fn new() -> Self {
Self::default()
}
#[cfg_attr(alef, alef(skip))]
pub fn next_token(&mut self, category: &PiiCategory, original: &str) -> String {
let cat_label = category_label(category);
let key = (cat_label.clone(), original.to_string());
if let Some(existing) = self.cache.get(&key) {
return existing.clone();
}
let count = self.counts.entry(cat_label.clone()).or_insert(0);
*count += 1;
let token = format!("[{}_{}]", cat_label.to_ascii_uppercase(), count);
self.cache.insert(key, token.clone());
token
}
#[cfg(feature = "redaction-rehydrate")]
#[cfg_attr(alef, alef(skip))]
pub fn rehydration_map(&self) -> super::rehydration::RehydrationMap {
self.cache
.iter()
.map(|((_category, original), token)| (token.clone(), original.clone()))
.collect()
}
}
#[cfg_attr(alef, alef(skip))]
pub fn apply_strategy(
strategy: RedactionStrategy,
original: &str,
category: &PiiCategory,
counter: &mut TokenCounter,
) -> String {
match strategy {
RedactionStrategy::Mask => "[REDACTED]".to_string(),
RedactionStrategy::Hash => {
let mut hasher = Sha256::new();
hasher.update(original.as_bytes());
let digest = hasher.finalize();
let hex = hex::encode(digest);
format!("[HASH:{}]", &hex[..16])
}
RedactionStrategy::TokenReplace => counter.next_token(category, original),
RedactionStrategy::Drop => String::new(),
}
}
fn category_label(category: &PiiCategory) -> String {
match category {
PiiCategory::Email => "email".into(),
PiiCategory::Phone => "phone".into(),
PiiCategory::Ssn => "ssn".into(),
PiiCategory::CreditCard => "credit_card".into(),
PiiCategory::PostalCode => "postal_code".into(),
PiiCategory::IpAddress => "ip_address".into(),
PiiCategory::Iban => "iban".into(),
PiiCategory::SwiftBic => "swift_bic".into(),
PiiCategory::DateOfBirth => "date_of_birth".into(),
PiiCategory::Person => "person".into(),
PiiCategory::Organization => "organization".into(),
PiiCategory::Location => "location".into(),
PiiCategory::Custom(label) => label.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mask_strategy() {
let mut counter = TokenCounter::new();
let token = apply_strategy(
RedactionStrategy::Mask,
"alice@example.com",
&PiiCategory::Email,
&mut counter,
);
assert_eq!(token, "[REDACTED]");
}
#[test]
fn test_hash_strategy_is_deterministic() {
let mut counter = TokenCounter::new();
let a = apply_strategy(
RedactionStrategy::Hash,
"alice@example.com",
&PiiCategory::Email,
&mut counter,
);
let b = apply_strategy(
RedactionStrategy::Hash,
"alice@example.com",
&PiiCategory::Email,
&mut counter,
);
assert_eq!(a, b);
assert!(a.starts_with("[HASH:"));
assert_eq!(a.len(), "[HASH:0123456789abcdef]".len());
}
#[test]
fn test_token_replace_reuses_for_same_value() {
let mut counter = TokenCounter::new();
let t1 = apply_strategy(
RedactionStrategy::TokenReplace,
"alice@example.com",
&PiiCategory::Email,
&mut counter,
);
let t2 = apply_strategy(
RedactionStrategy::TokenReplace,
"alice@example.com",
&PiiCategory::Email,
&mut counter,
);
let t3 = apply_strategy(
RedactionStrategy::TokenReplace,
"bob@example.com",
&PiiCategory::Email,
&mut counter,
);
assert_eq!(t1, "[EMAIL_1]");
assert_eq!(t2, "[EMAIL_1]");
assert_eq!(t3, "[EMAIL_2]");
}
#[test]
fn test_drop_strategy() {
let mut counter = TokenCounter::new();
assert!(apply_strategy(RedactionStrategy::Drop, "x", &PiiCategory::Phone, &mut counter).is_empty());
}
}