use crate::category::Category;
use crate::generator::ReplacementGenerator;
use hmac::{Hmac, Mac};
use rand::Rng;
use sha2::Sha256;
use zeroize::Zeroize;
pub trait Strategy: Send + Sync {
fn name(&self) -> &'static str;
fn replace(&self, original: &str, entropy: &[u8; 32]) -> String;
}
#[derive(Debug)]
pub enum EntropyMode {
Deterministic {
key: [u8; 32],
},
Random,
}
impl Drop for EntropyMode {
fn drop(&mut self) {
if let EntropyMode::Deterministic { ref mut key } = self {
key.zeroize();
}
}
}
pub struct StrategyGenerator {
strategy: Box<dyn Strategy>,
mode: EntropyMode,
}
impl StrategyGenerator {
#[must_use]
pub fn new(strategy: Box<dyn Strategy>, mode: EntropyMode) -> Self {
Self { strategy, mode }
}
fn entropy(&self, category: &Category, original: &str) -> [u8; 32] {
match &self.mode {
EntropyMode::Deterministic { key } => {
type HmacSha256 = Hmac<Sha256>;
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
let tag = category.domain_tag_hmac();
mac.update(tag.as_bytes());
mac.update(b"\x00");
mac.update(original.as_bytes());
let result = mac.finalize();
let mut out = [0u8; 32];
out.copy_from_slice(&result.into_bytes());
out
}
EntropyMode::Random => {
let mut buf = [0u8; 32];
rand::thread_rng().fill(&mut buf);
buf
}
}
}
#[must_use]
pub fn strategy(&self) -> &dyn Strategy {
&*self.strategy
}
}
impl ReplacementGenerator for StrategyGenerator {
fn generate(&self, category: &Category, original: &str) -> String {
let entropy = self.entropy(category, original);
self.strategy.replace(original, &entropy)
}
}
pub struct RandomString {
len: usize,
}
impl RandomString {
#[must_use]
pub fn new() -> Self {
Self { len: 16 }
}
#[must_use]
pub fn with_length(len: usize) -> Self {
Self {
len: len.clamp(1, 64),
}
}
}
impl Default for RandomString {
fn default() -> Self {
Self::new()
}
}
impl Strategy for RandomString {
fn name(&self) -> &'static str {
"random_string"
}
fn replace(&self, _original: &str, entropy: &[u8; 32]) -> String {
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz\
ABCDEFGHIJKLMNOPQRSTUVWXYZ\
0123456789";
let mut chars = String::with_capacity(self.len);
let mut state = 0u64;
for chunk in entropy.chunks_exact(8) {
let arr: [u8; 8] = chunk.try_into().expect("chunks_exact(8) yields 8-byte slices");
state = state.wrapping_add(u64::from_le_bytes(arr));
}
if state == 0 {
state = 0xDEAD_BEEF_CAFE_BABE; }
for _ in 0..self.len {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
#[allow(clippy::cast_possible_truncation)]
let idx = (state as usize) % CHARSET.len();
chars.push(CHARSET[idx] as char);
}
chars
}
}
pub struct RandomUuid;
impl RandomUuid {
#[must_use]
pub fn new() -> Self {
Self
}
}
impl Default for RandomUuid {
fn default() -> Self {
Self::new()
}
}
impl Strategy for RandomUuid {
fn name(&self) -> &'static str {
"random_uuid"
}
fn replace(&self, _original: &str, entropy: &[u8; 32]) -> String {
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&entropy[..16]);
bytes[6] = (bytes[6] & 0x0F) | 0x40;
bytes[8] = (bytes[8] & 0x3F) | 0x80;
format!(
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
bytes[0], bytes[1], bytes[2], bytes[3],
bytes[4], bytes[5],
bytes[6], bytes[7],
bytes[8], bytes[9],
bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
)
}
}
pub struct FakeIp;
impl FakeIp {
#[must_use]
pub fn new() -> Self {
Self
}
}
impl Default for FakeIp {
fn default() -> Self {
Self::new()
}
}
impl Strategy for FakeIp {
fn name(&self) -> &'static str {
"fake_ip"
}
fn replace(&self, _original: &str, entropy: &[u8; 32]) -> String {
let a = entropy[0];
let b = entropy[1];
let c = entropy[2].max(1);
format!("10.{}.{}.{}", a, b, c)
}
}
pub struct PreserveLength;
impl PreserveLength {
#[must_use]
pub fn new() -> Self {
Self
}
}
impl Default for PreserveLength {
fn default() -> Self {
Self::new()
}
}
impl Strategy for PreserveLength {
fn name(&self) -> &'static str {
"preserve_length"
}
fn replace(&self, original: &str, entropy: &[u8; 32]) -> String {
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
let target_len = original.len();
if target_len == 0 {
return String::new();
}
let mut state = 0u64;
for chunk in entropy.chunks_exact(8) {
let arr: [u8; 8] = chunk.try_into().expect("chunks_exact(8) yields 8-byte slices");
state = state.wrapping_add(u64::from_le_bytes(arr));
}
if state == 0 {
state = 0xCAFE_BABE_DEAD_BEEFu64;
}
let mut result = String::with_capacity(target_len);
for _ in 0..target_len {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
#[allow(clippy::cast_possible_truncation)]
let idx = (state as usize) % CHARSET.len();
result.push(CHARSET[idx] as char);
}
result
}
}
pub struct HmacHash {
key: [u8; 32],
output_len: usize,
}
impl HmacHash {
#[must_use]
pub fn new(key: [u8; 32]) -> Self {
Self {
key,
output_len: 32,
}
}
#[must_use]
pub fn with_output_len(key: [u8; 32], output_len: usize) -> Self {
Self {
key,
output_len: output_len.clamp(1, 64),
}
}
}
impl Strategy for HmacHash {
fn name(&self) -> &'static str {
"hmac_hash"
}
fn replace(&self, original: &str, _entropy: &[u8; 32]) -> String {
use std::fmt::Write;
type HmacSha256 = Hmac<Sha256>;
let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
mac.update(original.as_bytes());
let result = mac.finalize();
let hash_bytes: [u8; 32] = {
let mut buf = [0u8; 32];
buf.copy_from_slice(&result.into_bytes());
buf
};
let mut hex = String::with_capacity(64);
for b in &hash_bytes {
let _ = write!(hex, "{:02x}", b);
}
hex[..self.output_len].to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::category::Category;
use std::sync::Arc;
fn test_entropy() -> [u8; 32] {
let mut e = [0u8; 32];
for (i, b) in e.iter_mut().enumerate() {
#[allow(clippy::cast_possible_truncation)] {
*b = (i as u8).wrapping_mul(37).wrapping_add(7);
}
}
e
}
#[test]
fn strategies_are_deterministic() {
let entropy = test_entropy();
let strategies: Vec<Box<dyn Strategy>> = vec![
Box::new(RandomString::new()),
Box::new(RandomUuid::new()),
Box::new(FakeIp::new()),
Box::new(PreserveLength::new()),
Box::new(HmacHash::new([42u8; 32])),
];
for s in &strategies {
let a = s.replace("hello world", &entropy);
let b = s.replace("hello world", &entropy);
assert_eq!(a, b, "strategy '{}' must be deterministic", s.name());
}
}
#[test]
fn different_entropy_different_output() {
let e1 = [1u8; 32];
let e2 = [2u8; 32];
let strategies: Vec<Box<dyn Strategy>> = vec![
Box::new(RandomString::new()),
Box::new(RandomUuid::new()),
Box::new(FakeIp::new()),
Box::new(PreserveLength::new()),
];
for s in &strategies {
let a = s.replace("test", &e1);
let b = s.replace("test", &e2);
assert_ne!(
a,
b,
"strategy '{}' should differ with different entropy",
s.name()
);
}
}
#[test]
fn random_string_default_length() {
let s = RandomString::new();
let out = s.replace("anything", &test_entropy());
assert_eq!(out.len(), 16);
assert!(
out.chars().all(|c| c.is_ascii_alphanumeric()),
"output must be alphanumeric: {}",
out,
);
}
#[test]
fn random_string_custom_length() {
let s = RandomString::with_length(8);
let out = s.replace("anything", &test_entropy());
assert_eq!(out.len(), 8);
}
#[test]
fn random_string_clamped_length() {
let s = RandomString::with_length(999);
assert_eq!(s.len, 64);
let s = RandomString::with_length(0);
assert_eq!(s.len, 1);
}
#[test]
fn random_uuid_format() {
let s = RandomUuid::new();
let out = s.replace("anything", &test_entropy());
assert_eq!(out.len(), 36, "UUID must be 36 chars: {}", out);
let parts: Vec<&str> = out.split('-').collect();
assert_eq!(parts.len(), 5);
assert_eq!(parts[0].len(), 8);
assert_eq!(parts[1].len(), 4);
assert_eq!(parts[2].len(), 4);
assert_eq!(parts[3].len(), 4);
assert_eq!(parts[4].len(), 12);
assert_eq!(&parts[2][0..1], "4", "version must be 4");
let variant = &parts[3][0..1];
assert!(
["8", "9", "a", "b"].contains(&variant),
"variant nibble must be 8/9/a/b, got {}",
variant,
);
}
#[test]
fn fake_ip_format() {
let s = FakeIp::new();
let out = s.replace("192.168.1.1", &test_entropy());
assert!(out.starts_with("10."), "must be in 10.0.0.0/8: {}", out);
let octets: Vec<&str> = out.split('.').collect();
assert_eq!(octets.len(), 4);
for octet in &octets {
let _n: u8 = octet.parse().expect("octet must be a valid u8");
}
let last: u8 = octets[3].parse().unwrap();
assert!(last >= 1, "last octet must be ≥ 1");
}
#[test]
fn preserve_length_matches() {
let s = PreserveLength::new();
for input in &["a", "hello", "this is a fairly long string indeed", ""] {
let out = s.replace(input, &test_entropy());
assert_eq!(
out.len(),
input.len(),
"length mismatch for input '{}'",
input,
);
}
}
#[test]
fn preserve_length_characters() {
let s = PreserveLength::new();
let out = s.replace("hello!", &test_entropy());
assert!(
out.chars().all(|c| c.is_ascii_alphanumeric()),
"output must be alphanumeric: {}",
out,
);
}
#[test]
fn hmac_hash_deterministic_with_key() {
let s = HmacHash::new([42u8; 32]);
let a = s.replace("secret", &[0u8; 32]);
let b = s.replace("secret", &[0xFF; 32]);
assert_eq!(a, b, "HmacHash must ignore entropy");
}
#[test]
fn hmac_hash_default_length() {
let s = HmacHash::new([0u8; 32]);
let out = s.replace("test", &[0u8; 32]);
assert_eq!(out.len(), 32, "default output is 32 hex chars");
assert!(
out.chars().all(|c| c.is_ascii_hexdigit()),
"output must be hex: {}",
out,
);
}
#[test]
fn hmac_hash_custom_length() {
let s = HmacHash::with_output_len([0u8; 32], 12);
let out = s.replace("test", &[0u8; 32]);
assert_eq!(out.len(), 12);
}
#[test]
fn hmac_hash_different_keys() {
let s1 = HmacHash::new([1u8; 32]);
let s2 = HmacHash::new([2u8; 32]);
let a = s1.replace("test", &[0u8; 32]);
let b = s2.replace("test", &[0u8; 32]);
assert_ne!(a, b, "different keys must produce different output");
}
#[test]
fn hmac_hash_different_inputs() {
let s = HmacHash::new([42u8; 32]);
let a = s.replace("alice", &[0u8; 32]);
let b = s.replace("bob", &[0u8; 32]);
assert_ne!(a, b);
}
#[test]
fn strategy_generator_deterministic() {
let strat = Box::new(RandomString::new());
let gen = StrategyGenerator::new(strat, EntropyMode::Deterministic { key: [42u8; 32] });
let a = gen.generate(&Category::Email, "alice@corp.com");
let b = gen.generate(&Category::Email, "alice@corp.com");
assert_eq!(a, b, "deterministic mode must be repeatable");
}
#[test]
fn strategy_generator_different_categories() {
let strat = Box::new(RandomString::new());
let gen = StrategyGenerator::new(strat, EntropyMode::Deterministic { key: [42u8; 32] });
let a = gen.generate(&Category::Email, "test");
let b = gen.generate(&Category::Name, "test");
assert_ne!(a, b, "different categories must produce different entropy");
}
#[test]
fn strategy_generator_with_store() {
let strat = Box::new(RandomUuid::new());
let gen = Arc::new(StrategyGenerator::new(
strat,
EntropyMode::Deterministic { key: [99u8; 32] },
));
let store = crate::store::MappingStore::new(gen, None);
let s1 = store
.get_or_insert(&Category::Email, "alice@corp.com")
.unwrap();
let s2 = store
.get_or_insert(&Category::Email, "alice@corp.com")
.unwrap();
assert_eq!(s1, s2, "store must cache strategy output");
assert_eq!(s1.len(), 36, "output must be UUID-formatted");
}
#[test]
fn strategy_generator_random_cached_in_store() {
let strat = Box::new(FakeIp::new());
let gen = Arc::new(StrategyGenerator::new(strat, EntropyMode::Random));
let store = crate::store::MappingStore::new(gen, None);
let s1 = store.get_or_insert(&Category::IpV4, "192.168.1.1").unwrap();
let s2 = store.get_or_insert(&Category::IpV4, "192.168.1.1").unwrap();
assert_eq!(s1, s2);
assert!(s1.starts_with("10."));
}
#[test]
fn all_strategies_implement_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<RandomString>();
assert_send_sync::<RandomUuid>();
assert_send_sync::<FakeIp>();
assert_send_sync::<PreserveLength>();
assert_send_sync::<HmacHash>();
assert_send_sync::<StrategyGenerator>();
}
#[test]
fn strategy_names_unique() {
let strategies: Vec<Box<dyn Strategy>> = vec![
Box::new(RandomString::new()),
Box::new(RandomUuid::new()),
Box::new(FakeIp::new()),
Box::new(PreserveLength::new()),
Box::new(HmacHash::new([0u8; 32])),
];
let mut names: Vec<&str> = strategies.iter().map(|s| s.name()).collect();
let len_before = names.len();
names.sort_unstable();
names.dedup();
assert_eq!(names.len(), len_before, "strategy names must be unique");
}
#[test]
fn concurrent_strategy_generator() {
use std::thread;
let strat = Box::new(PreserveLength::new());
let gen = Arc::new(StrategyGenerator::new(
strat,
EntropyMode::Deterministic { key: [7u8; 32] },
));
let store = Arc::new(crate::store::MappingStore::new(gen, None));
let mut handles = vec![];
for t in 0..4 {
let store = Arc::clone(&store);
handles.push(thread::spawn(move || {
for i in 0..500 {
let val = format!("thread{}-val{}", t, i);
let result = store.get_or_insert(&Category::Name, &val).unwrap();
assert_eq!(
result.len(),
val.len(),
"PreserveLength must match input length",
);
}
}));
}
for h in handles {
h.join().unwrap();
}
assert_eq!(store.len(), 2000);
}
}