Skip to main content

rust_sanitize/
strategy.rs

1//! Pluggable replacement strategies.
2//!
3//! This module provides the [`Strategy`] trait and six built-in
4//! implementations that can be composed with the mapping engine via
5//! [`StrategyGenerator`], an adapter that implements
6//! [`ReplacementGenerator`].
7//!
8//! # Design Note
9//!
10//! This is the **extensibility layer** for library consumers who need custom
11//! replacement logic. The CLI binary uses [`crate::generator::HmacGenerator`]
12//! and [`crate::generator::RandomGenerator`] directly with category-aware
13//! formatters for performance and simplicity. Both paths share the same
14//! [`ReplacementGenerator`] interface. See `ARCHITECTURE.md` section 2 for
15//! details on the dual-path design.
16//!
17//! # Architecture
18//!
19//! ```text
20//! ┌──────────────────────┐
21//! │    MappingStore       │  ← owns Arc<dyn ReplacementGenerator>
22//! └──────────┬───────────┘
23//!            │ calls generate(category, original)
24//!            ▼
25//! ┌──────────────────────┐
26//! │  StrategyGenerator   │  ← adapter: produces entropy, delegates to Strategy
27//! │  (ReplacementGenerator)│
28//! └──────────┬───────────┘
29//!            │ calls replace(original, &entropy)
30//!            ▼
31//! ┌──────────────────────┐
32//! │   dyn Strategy       │  ← pure function of (original, entropy) → String
33//! │                      │
34//! │  RandomString        │
35//! │  RandomUuid          │
36//! │  FakeIp              │
37//! │  PreserveLength      │
38//! │  HmacHash            │
39//! └──────────────────────┘
40//! ```
41//!
42//! # Deterministic Mode
43//!
44//! Strategies are pure functions of `(original, entropy)`. Determinism is
45//! controlled by the **entropy source** inside [`StrategyGenerator`]:
46//!
47//! - **Deterministic** (`EntropyMode::Deterministic`): entropy is derived
48//!   via HMAC-SHA256 keyed with a fixed seed — same seed + same input →
49//!   same replacement across runs.
50//! - **Random** (`EntropyMode::Random`): entropy comes from OS CSPRNG —
51//!   each call produces a fresh value (but the `MappingStore` still caches
52//!   the first result per unique input for per-run consistency).
53//!
54//! The [`HmacHash`] strategy is an exception: it carries its own HMAC key
55//! and is deterministic by construction regardless of the entropy mode.
56//!
57//! # Extensibility
58//!
59//! To add a new replacement strategy:
60//!
61//! 1. Create a struct implementing [`Strategy`].
62//! 2. Return a unique name from [`Strategy::name`].
63//! 3. Implement [`Strategy::replace`] as a pure function of `(category, original, entropy)`.
64//! 4. Wrap it in a [`StrategyGenerator`] to use with `MappingStore`.
65//!
66//! Third-party crates can implement `Strategy` without modifying this crate,
67//! since the trait is public and object-safe.
68
69use crate::category::Category;
70use crate::generator::ReplacementGenerator;
71use hmac::{Hmac, Mac};
72use rand::Rng;
73use sha2::Sha256;
74use zeroize::Zeroize;
75
76// ---------------------------------------------------------------------------
77// Strategy trait
78// ---------------------------------------------------------------------------
79
80/// A pluggable replacement strategy.
81///
82/// Strategies transform an original sensitive value into a sanitized
83/// replacement using 32 bytes of caller-provided entropy. They MUST be
84/// **pure functions** of their inputs: the same `(original, entropy)` pair
85/// always produces the same output.
86///
87/// Strategies are agnostic to how entropy is produced (HMAC-deterministic
88/// or CSPRNG-random). That concern is handled by [`StrategyGenerator`].
89pub trait Strategy: Send + Sync {
90    /// Human-readable, unique name for this strategy (e.g. `"random_string"`).
91    fn name(&self) -> &'static str;
92
93    /// Produce a sanitized replacement for `original` using `entropy`.
94    ///
95    /// # Contract
96    ///
97    /// - Must be deterministic: same `(category, original, entropy)` → same output.
98    /// - Must not perform I/O or access external mutable state.
99    /// - Returned value should be clearly synthetic / non-sensitive.
100    fn replace(&self, category: &Category, original: &str, entropy: &[u8; 32]) -> String;
101}
102
103// ---------------------------------------------------------------------------
104// Entropy mode (used by StrategyGenerator)
105// ---------------------------------------------------------------------------
106
107/// How entropy is produced for strategies.
108#[derive(Debug)]
109pub enum EntropyMode {
110    /// Deterministic: `entropy = HMAC-SHA256(key, category || '\0' || original)`.
111    Deterministic {
112        /// 32-byte HMAC key (seed).
113        key: [u8; 32],
114    },
115    /// Random: entropy is drawn from OS CSPRNG on every call.
116    Random,
117}
118
119impl Drop for EntropyMode {
120    fn drop(&mut self) {
121        if let EntropyMode::Deterministic { ref mut key } = self {
122            key.zeroize();
123        }
124    }
125}
126
127// ---------------------------------------------------------------------------
128// StrategyGenerator — adapter from Strategy → ReplacementGenerator
129// ---------------------------------------------------------------------------
130
131/// Adapter that bridges a [`Strategy`] into the [`ReplacementGenerator`]
132/// interface consumed by [`MappingStore`](crate::store::MappingStore).
133///
134/// It produces entropy according to the configured [`EntropyMode`] and
135/// delegates replacement formatting to the wrapped strategy.
136pub struct StrategyGenerator {
137    strategy: Box<dyn Strategy>,
138    mode: EntropyMode,
139}
140
141impl StrategyGenerator {
142    /// Create a new adapter.
143    ///
144    /// # Arguments
145    ///
146    /// - `strategy` — the replacement strategy to use.
147    /// - `mode` — how to produce entropy (deterministic seed or random).
148    #[must_use]
149    pub fn new(strategy: Box<dyn Strategy>, mode: EntropyMode) -> Self {
150        Self { strategy, mode }
151    }
152
153    /// Produce 32 bytes of entropy for `(category, original)`.
154    fn entropy(&self, category: &Category, original: &str) -> [u8; 32] {
155        match &self.mode {
156            EntropyMode::Deterministic { key } => {
157                type HmacSha256 = Hmac<Sha256>;
158                let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
159                let tag = category.domain_tag_hmac();
160                mac.update(tag.as_bytes());
161                mac.update(b"\x00");
162                mac.update(original.as_bytes());
163                let result = mac.finalize();
164                let mut out = [0u8; 32];
165                out.copy_from_slice(&result.into_bytes());
166                out
167            }
168            EntropyMode::Random => {
169                let mut buf = [0u8; 32];
170                rand::rng().fill(&mut buf);
171                buf
172            }
173        }
174    }
175
176    /// Access the underlying strategy.
177    #[must_use]
178    pub fn strategy(&self) -> &dyn Strategy {
179        self.strategy.as_ref()
180    }
181}
182
183impl ReplacementGenerator for StrategyGenerator {
184    fn generate(&self, category: &Category, original: &str) -> String {
185        let entropy = self.entropy(category, original);
186        self.strategy.replace(category, original, &entropy)
187    }
188}
189
190// ===========================================================================
191// Built-in strategies
192// ===========================================================================
193
194/// Seed a 64-bit xorshift PRNG from a 32-byte entropy buffer.
195///
196/// Folds the four 8-byte little-endian chunks via wrapping addition so that
197/// all 256 bits of entropy influence the initial state. Guards against the
198/// degenerate all-zero state that would cause xorshift64 to produce only zeros.
199#[inline]
200fn xorshift64_seed(entropy: &[u8; 32]) -> u64 {
201    let mut state = 0u64;
202    for chunk in entropy.chunks_exact(8) {
203        let arr: [u8; 8] = chunk
204            .try_into()
205            .expect("chunks_exact(8) yields 8-byte slices");
206        state = state.wrapping_add(u64::from_le_bytes(arr));
207    }
208    if state == 0 {
209        state = 0xDEAD_BEEF_CAFE_BABE;
210    }
211    state
212}
213
214/// Advance a xorshift64 PRNG state by one step.
215#[inline]
216fn xorshift64_step(state: &mut u64) {
217    *state ^= *state << 13;
218    *state ^= *state >> 7;
219    *state ^= *state << 17;
220}
221
222// ---------------------------------------------------------------------------
223// 1. RandomString
224// ---------------------------------------------------------------------------
225
226/// Generates an alphanumeric string from entropy bytes.
227///
228/// The output length defaults to 16 characters but can be configured.
229/// Characters are drawn from `[a-zA-Z0-9]`.
230pub struct RandomString {
231    /// Desired output length (capped at 64).
232    len: usize,
233}
234
235impl RandomString {
236    /// Create with default length (16).
237    #[must_use]
238    pub fn new() -> Self {
239        Self { len: 16 }
240    }
241
242    /// Create with a specific output length (clamped to 1..=64).
243    #[must_use]
244    pub fn with_length(len: usize) -> Self {
245        Self {
246            len: len.clamp(1, 64),
247        }
248    }
249}
250
251impl Default for RandomString {
252    fn default() -> Self {
253        Self::new()
254    }
255}
256
257impl Strategy for RandomString {
258    fn name(&self) -> &'static str {
259        "random_string"
260    }
261
262    fn replace(&self, _category: &Category, _original: &str, entropy: &[u8; 32]) -> String {
263        const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz\
264                                  ABCDEFGHIJKLMNOPQRSTUVWXYZ\
265                                  0123456789";
266        let mut chars = String::with_capacity(self.len);
267        let mut state = xorshift64_seed(entropy);
268
269        for _ in 0..self.len {
270            xorshift64_step(&mut state);
271            #[allow(clippy::cast_possible_truncation)]
272            // truncation is intentional for index mapping
273            let idx = (state as usize) % CHARSET.len();
274            chars.push(CHARSET[idx] as char);
275        }
276        chars
277    }
278}
279
280// ---------------------------------------------------------------------------
281// 2. RandomUuid
282// ---------------------------------------------------------------------------
283
284/// Generates a UUID v4–formatted string from entropy bytes.
285///
286/// The output looks like `xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx` where
287/// `x` is a hex digit derived from entropy and `y ∈ {8,9,a,b}` per RFC 4122.
288/// When backed by deterministic entropy, the UUID is stable.
289pub struct RandomUuid;
290
291impl RandomUuid {
292    #[must_use]
293    pub fn new() -> Self {
294        Self
295    }
296}
297
298impl Default for RandomUuid {
299    fn default() -> Self {
300        Self::new()
301    }
302}
303
304impl Strategy for RandomUuid {
305    fn name(&self) -> &'static str {
306        "random_uuid"
307    }
308
309    fn replace(&self, _category: &Category, _original: &str, entropy: &[u8; 32]) -> String {
310        // Take the first 16 bytes to form a UUID.
311        let mut bytes = [0u8; 16];
312        bytes.copy_from_slice(&entropy[..16]);
313
314        // Set version = 4 (bits 4-7 of byte 6).
315        bytes[6] = (bytes[6] & 0x0F) | 0x40;
316        // Set variant = RFC 4122 (bits 6-7 of byte 8).
317        bytes[8] = (bytes[8] & 0x3F) | 0x80;
318
319        format!(
320            "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
321            bytes[0], bytes[1], bytes[2], bytes[3],
322            bytes[4], bytes[5],
323            bytes[6], bytes[7],
324            bytes[8], bytes[9],
325            bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
326        )
327    }
328}
329
330// ---------------------------------------------------------------------------
331// 3. FakeIp
332// ---------------------------------------------------------------------------
333
334/// Generates a length-preserving fake IP address.
335///
336/// Dots (`.`) are preserved in their original positions; every other
337/// character is replaced with a deterministic decimal digit derived from
338/// `entropy`. The output is always the same byte length as `original`,
339/// preserving column widths and log formatting.
340pub struct FakeIp;
341
342impl FakeIp {
343    #[must_use]
344    pub fn new() -> Self {
345        Self
346    }
347}
348
349impl Default for FakeIp {
350    fn default() -> Self {
351        Self::new()
352    }
353}
354
355impl Strategy for FakeIp {
356    fn name(&self) -> &'static str {
357        "fake_ip"
358    }
359
360    fn replace(&self, _category: &Category, original: &str, entropy: &[u8; 32]) -> String {
361        // Preserve dots; replace every other character with a deterministic
362        // digit so the output has the same byte length as the original.
363        let mut buf = String::with_capacity(original.len());
364        let mut hi = 0usize;
365        for ch in original.chars() {
366            if ch == '.' {
367                buf.push('.');
368            } else {
369                buf.push((b'0' + entropy[hi % 32] % 10) as char);
370                hi += 1;
371            }
372        }
373        buf
374    }
375}
376
377// ---------------------------------------------------------------------------
378// 4. PreserveLength
379// ---------------------------------------------------------------------------
380
381/// Generates a replacement with the **same byte length** as the original.
382///
383/// Useful when column widths, fixed-length fields, or alignment must be
384/// maintained. Uses lowercase hex characters derived from entropy.
385pub struct PreserveLength;
386
387impl PreserveLength {
388    #[must_use]
389    pub fn new() -> Self {
390        Self
391    }
392}
393
394impl Default for PreserveLength {
395    fn default() -> Self {
396        Self::new()
397    }
398}
399
400impl Strategy for PreserveLength {
401    fn name(&self) -> &'static str {
402        "preserve_length"
403    }
404
405    fn replace(&self, _category: &Category, original: &str, entropy: &[u8; 32]) -> String {
406        const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
407
408        let target_len = original.len();
409        if target_len == 0 {
410            return String::new();
411        }
412
413        let mut state = xorshift64_seed(entropy);
414        let mut result = String::with_capacity(target_len);
415        for _ in 0..target_len {
416            xorshift64_step(&mut state);
417            #[allow(clippy::cast_possible_truncation)]
418            // truncation is intentional for index mapping
419            let idx = (state as usize) % CHARSET.len();
420            result.push(CHARSET[idx] as char);
421        }
422        result
423    }
424}
425
426// ---------------------------------------------------------------------------
427// 5. HmacHash
428// ---------------------------------------------------------------------------
429
430/// HMAC-SHA256 hash strategy — deterministic by construction.
431///
432/// Unlike the other strategies, `HmacHash` carries its own 32-byte key and
433/// computes `HMAC-SHA256(key, original)` directly. The caller-provided
434/// entropy is **ignored**. This makes the output deterministic regardless
435/// of the [`EntropyMode`] used by [`StrategyGenerator`].
436///
437/// The output is a lowercase hex string, optionally truncated to
438/// `output_len` characters (default: 32).
439pub struct HmacHash {
440    key: [u8; 32],
441    /// Number of hex characters to emit (max 64).
442    output_len: usize,
443}
444
445impl HmacHash {
446    /// Create with both a key and a default output length (32 hex chars).
447    #[must_use]
448    pub fn new(key: [u8; 32]) -> Self {
449        Self {
450            key,
451            output_len: 32,
452        }
453    }
454
455    /// Create with a custom output length (clamped to 1..=64).
456    #[must_use]
457    pub fn with_output_len(key: [u8; 32], output_len: usize) -> Self {
458        Self {
459            key,
460            output_len: output_len.clamp(1, 64),
461        }
462    }
463}
464
465impl Strategy for HmacHash {
466    fn name(&self) -> &'static str {
467        "hmac_hash"
468    }
469
470    fn replace(&self, _category: &Category, original: &str, _entropy: &[u8; 32]) -> String {
471        use std::fmt::Write;
472
473        type HmacSha256 = Hmac<Sha256>;
474        let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
475        mac.update(original.as_bytes());
476        let result = mac.finalize();
477        let hash_bytes: [u8; 32] = {
478            let mut buf = [0u8; 32];
479            buf.copy_from_slice(&result.into_bytes());
480            buf
481        };
482        let mut hex = String::with_capacity(64);
483        for b in &hash_bytes {
484            let _ = write!(hex, "{:02x}", b);
485        }
486        hex[..self.output_len].to_string()
487    }
488}
489
490// ---------------------------------------------------------------------------
491// 6. CategoryAwareStrategy
492// ---------------------------------------------------------------------------
493
494/// Built-in strategy that delegates to the same category-aware formatters
495/// used by the CLI.
496///
497/// Replacements are shaped to match their category: email-shaped for emails,
498/// IP-shaped for IPs, JWT-shaped for JWTs, and so on — identical output
499/// quality to what [`HmacGenerator`](crate::generator::HmacGenerator)
500/// produces. Use this strategy when you want full structured replacement
501/// behaviour through the [`Strategy`] / [`StrategyGenerator`] path.
502pub struct CategoryAwareStrategy;
503
504impl CategoryAwareStrategy {
505    #[must_use]
506    pub fn new() -> Self {
507        Self
508    }
509}
510
511impl Default for CategoryAwareStrategy {
512    fn default() -> Self {
513        Self::new()
514    }
515}
516
517impl Strategy for CategoryAwareStrategy {
518    fn name(&self) -> &'static str {
519        "category_aware"
520    }
521
522    fn replace(&self, category: &Category, original: &str, entropy: &[u8; 32]) -> String {
523        crate::generator::format_replacement(category, entropy, original)
524    }
525}
526
527// ===========================================================================
528// Tests
529// ===========================================================================
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534    use crate::category::Category;
535    use std::sync::Arc;
536
537    /// Helper: fixed deterministic entropy for testing.
538    fn test_entropy() -> [u8; 32] {
539        let mut e = [0u8; 32];
540        for (i, b) in e.iter_mut().enumerate() {
541            #[allow(clippy::cast_possible_truncation)] // i is always < 32, fits in u8
542            {
543                *b = (i as u8).wrapping_mul(37).wrapping_add(7);
544            }
545        }
546        e
547    }
548
549    // ---- Strategy trait: purity / determinism ----
550
551    #[test]
552    fn strategies_are_deterministic() {
553        let entropy = test_entropy();
554        let strategies: Vec<Box<dyn Strategy>> = vec![
555            Box::new(RandomString::new()),
556            Box::new(RandomUuid::new()),
557            Box::new(FakeIp::new()),
558            Box::new(PreserveLength::new()),
559            Box::new(HmacHash::new([42u8; 32])),
560            Box::new(CategoryAwareStrategy::new()),
561        ];
562        for s in &strategies {
563            let a = s.replace(&Category::AuthToken, "hello world", &entropy);
564            let b = s.replace(&Category::AuthToken, "hello world", &entropy);
565            assert_eq!(a, b, "strategy '{}' must be deterministic", s.name());
566        }
567    }
568
569    #[test]
570    fn different_entropy_different_output() {
571        let e1 = [1u8; 32];
572        let e2 = [2u8; 32];
573        let strategies: Vec<Box<dyn Strategy>> = vec![
574            Box::new(RandomString::new()),
575            Box::new(RandomUuid::new()),
576            Box::new(FakeIp::new()),
577            Box::new(PreserveLength::new()),
578            Box::new(CategoryAwareStrategy::new()),
579        ];
580        for s in &strategies {
581            let a = s.replace(&Category::AuthToken, "test", &e1);
582            let b = s.replace(&Category::AuthToken, "test", &e2);
583            assert_ne!(
584                a,
585                b,
586                "strategy '{}' should differ with different entropy",
587                s.name()
588            );
589        }
590    }
591
592    // ---- RandomString ----
593
594    #[test]
595    fn random_string_default_length() {
596        let s = RandomString::new();
597        let out = s.replace(&Category::AuthToken, "anything", &test_entropy());
598        assert_eq!(out.len(), 16);
599        assert!(
600            out.chars().all(|c| c.is_ascii_alphanumeric()),
601            "output must be alphanumeric: {}",
602            out,
603        );
604    }
605
606    #[test]
607    fn random_string_custom_length() {
608        let s = RandomString::with_length(8);
609        let out = s.replace(&Category::AuthToken, "anything", &test_entropy());
610        assert_eq!(out.len(), 8);
611    }
612
613    #[test]
614    fn random_string_clamped_length() {
615        let s = RandomString::with_length(999);
616        assert_eq!(s.len, 64);
617        let s = RandomString::with_length(0);
618        assert_eq!(s.len, 1);
619    }
620
621    // ---- RandomUuid ----
622
623    #[test]
624    fn random_uuid_format() {
625        let s = RandomUuid::new();
626        let out = s.replace(&Category::AuthToken, "anything", &test_entropy());
627        // 8-4-4-4-12 = 36 chars
628        assert_eq!(out.len(), 36, "UUID must be 36 chars: {}", out);
629        let parts: Vec<&str> = out.split('-').collect();
630        assert_eq!(parts.len(), 5);
631        assert_eq!(parts[0].len(), 8);
632        assert_eq!(parts[1].len(), 4);
633        assert_eq!(parts[2].len(), 4);
634        assert_eq!(parts[3].len(), 4);
635        assert_eq!(parts[4].len(), 12);
636        // Version nibble = 4
637        assert_eq!(&parts[2][0..1], "4", "version must be 4");
638        // Variant nibble ∈ {8,9,a,b}
639        let variant = &parts[3][0..1];
640        assert!(
641            ["8", "9", "a", "b"].contains(&variant),
642            "variant nibble must be 8/9/a/b, got {}",
643            variant,
644        );
645    }
646
647    // ---- FakeIp ----
648
649    #[test]
650    fn fake_ip_format() {
651        let s = FakeIp::new();
652        let input = "192.168.1.1";
653        let out = s.replace(&Category::IpV4, input, &test_entropy());
654        // Length preserved.
655        assert_eq!(
656            out.len(),
657            input.len(),
658            "FakeIp must preserve length: {}",
659            out
660        );
661        // Dot positions preserved.
662        let in_dots: Vec<usize> = input
663            .char_indices()
664            .filter(|&(_, c)| c == '.')
665            .map(|(i, _)| i)
666            .collect();
667        let out_dots: Vec<usize> = out
668            .char_indices()
669            .filter(|&(_, c)| c == '.')
670            .map(|(i, _)| i)
671            .collect();
672        assert_eq!(out_dots, in_dots, "FakeIp must preserve dot positions");
673        // Non-dot characters must be ASCII digits.
674        assert!(
675            out.chars().all(|c| c == '.' || c.is_ascii_digit()),
676            "FakeIp output must contain only digits and dots: {}",
677            out
678        );
679        // Must differ from input.
680        assert_ne!(out, input, "FakeIp must change the IP");
681    }
682
683    // ---- PreserveLength ----
684
685    #[test]
686    fn preserve_length_matches() {
687        let s = PreserveLength::new();
688        for input in &["a", "hello", "this is a fairly long string indeed", ""] {
689            let out = s.replace(&Category::AuthToken, input, &test_entropy());
690            assert_eq!(
691                out.len(),
692                input.len(),
693                "length mismatch for input '{}'",
694                input,
695            );
696        }
697    }
698
699    #[test]
700    fn preserve_length_characters() {
701        let s = PreserveLength::new();
702        let out = s.replace(&Category::AuthToken, "hello!", &test_entropy());
703        assert!(
704            out.chars().all(|c| c.is_ascii_alphanumeric()),
705            "output must be alphanumeric: {}",
706            out,
707        );
708    }
709
710    // ---- HmacHash ----
711
712    #[test]
713    fn hmac_hash_deterministic_with_key() {
714        let s = HmacHash::new([42u8; 32]);
715        let a = s.replace(&Category::AuthToken, "secret", &[0u8; 32]);
716        let b = s.replace(&Category::AuthToken, "secret", &[0xFF; 32]);
717        // Entropy is ignored — result depends only on key + original.
718        assert_eq!(a, b, "HmacHash must ignore entropy");
719    }
720
721    #[test]
722    fn hmac_hash_default_length() {
723        let s = HmacHash::new([0u8; 32]);
724        let out = s.replace(&Category::AuthToken, "test", &[0u8; 32]);
725        assert_eq!(out.len(), 32, "default output is 32 hex chars");
726        assert!(
727            out.chars().all(|c| c.is_ascii_hexdigit()),
728            "output must be hex: {}",
729            out,
730        );
731    }
732
733    #[test]
734    fn hmac_hash_custom_length() {
735        let s = HmacHash::with_output_len([0u8; 32], 12);
736        let out = s.replace(&Category::AuthToken, "test", &[0u8; 32]);
737        assert_eq!(out.len(), 12);
738    }
739
740    #[test]
741    fn hmac_hash_different_keys() {
742        let s1 = HmacHash::new([1u8; 32]);
743        let s2 = HmacHash::new([2u8; 32]);
744        let a = s1.replace(&Category::AuthToken, "test", &[0u8; 32]);
745        let b = s2.replace(&Category::AuthToken, "test", &[0u8; 32]);
746        assert_ne!(a, b, "different keys must produce different output");
747    }
748
749    #[test]
750    fn hmac_hash_different_inputs() {
751        let s = HmacHash::new([42u8; 32]);
752        let a = s.replace(&Category::AuthToken, "alice", &[0u8; 32]);
753        let b = s.replace(&Category::AuthToken, "bob", &[0u8; 32]);
754        assert_ne!(a, b);
755    }
756
757    // ---- StrategyGenerator integration ----
758
759    #[test]
760    fn strategy_generator_deterministic() {
761        let strat = Box::new(RandomString::new());
762        let gen = StrategyGenerator::new(strat, EntropyMode::Deterministic { key: [42u8; 32] });
763        let a = gen.generate(&Category::Email, "alice@corp.com");
764        let b = gen.generate(&Category::Email, "alice@corp.com");
765        assert_eq!(a, b, "deterministic mode must be repeatable");
766    }
767
768    #[test]
769    fn strategy_generator_different_categories() {
770        let strat = Box::new(RandomString::new());
771        let gen = StrategyGenerator::new(strat, EntropyMode::Deterministic { key: [42u8; 32] });
772        let a = gen.generate(&Category::Email, "test");
773        let b = gen.generate(&Category::Name, "test");
774        assert_ne!(a, b, "different categories must produce different entropy");
775    }
776
777    #[test]
778    fn strategy_generator_with_store() {
779        let strat = Box::new(RandomUuid::new());
780        let gen = Arc::new(StrategyGenerator::new(
781            strat,
782            EntropyMode::Deterministic { key: [99u8; 32] },
783        ));
784        let store = crate::store::MappingStore::new(gen, None);
785
786        let s1 = store
787            .get_or_insert(&Category::Email, "alice@corp.com")
788            .unwrap();
789        let s2 = store
790            .get_or_insert(&Category::Email, "alice@corp.com")
791            .unwrap();
792        assert_eq!(s1, s2, "store must cache strategy output");
793        assert_eq!(s1.len(), 36, "output must be UUID-formatted");
794    }
795
796    #[test]
797    fn strategy_generator_random_cached_in_store() {
798        let strat = Box::new(FakeIp::new());
799        let gen = Arc::new(StrategyGenerator::new(strat, EntropyMode::Random));
800        let store = crate::store::MappingStore::new(gen, None);
801
802        let s1 = store.get_or_insert(&Category::IpV4, "192.168.1.1").unwrap();
803        let s2 = store.get_or_insert(&Category::IpV4, "192.168.1.1").unwrap();
804        // Random entropy, but store caches first result.
805        assert_eq!(s1, s2);
806        assert_eq!(
807            s1.len(),
808            "192.168.1.1".len(),
809            "FakeIp must preserve input length"
810        );
811    }
812
813    #[test]
814    fn all_strategies_implement_send_sync() {
815        fn assert_send_sync<T: Send + Sync>() {}
816        assert_send_sync::<RandomString>();
817        assert_send_sync::<RandomUuid>();
818        assert_send_sync::<FakeIp>();
819        assert_send_sync::<PreserveLength>();
820        assert_send_sync::<HmacHash>();
821        assert_send_sync::<CategoryAwareStrategy>();
822        assert_send_sync::<StrategyGenerator>();
823    }
824
825    #[test]
826    fn strategy_names_unique() {
827        let strategies: Vec<Box<dyn Strategy>> = vec![
828            Box::new(RandomString::new()),
829            Box::new(RandomUuid::new()),
830            Box::new(FakeIp::new()),
831            Box::new(PreserveLength::new()),
832            Box::new(HmacHash::new([0u8; 32])),
833            Box::new(CategoryAwareStrategy::new()),
834        ];
835        let mut names: Vec<&str> = strategies.iter().map(|s| s.name()).collect();
836        let len_before = names.len();
837        names.sort_unstable();
838        names.dedup();
839        assert_eq!(names.len(), len_before, "strategy names must be unique");
840    }
841
842    // ---- Concurrent use via StrategyGenerator + MappingStore ----
843
844    #[test]
845    fn concurrent_strategy_generator() {
846        use std::thread;
847
848        let strat = Box::new(PreserveLength::new());
849        let gen = Arc::new(StrategyGenerator::new(
850            strat,
851            EntropyMode::Deterministic { key: [7u8; 32] },
852        ));
853        let store = Arc::new(crate::store::MappingStore::new(gen, None));
854
855        let mut handles = vec![];
856        for t in 0..4 {
857            let store = Arc::clone(&store);
858            handles.push(thread::spawn(move || {
859                for i in 0..500 {
860                    let val = format!("thread{}-val{}", t, i);
861                    let result = store.get_or_insert(&Category::Name, &val).unwrap();
862                    assert_eq!(
863                        result.len(),
864                        val.len(),
865                        "PreserveLength must match input length",
866                    );
867                }
868            }));
869        }
870        for h in handles {
871            h.join().unwrap();
872        }
873        assert_eq!(store.len(), 2000);
874    }
875
876    // ---- CategoryAwareStrategy ----
877
878    #[test]
879    fn category_aware_email_shaped() {
880        let s = CategoryAwareStrategy::new();
881        let input = "alice@corp.com";
882        let out = s.replace(&Category::Email, input, &test_entropy());
883        assert_eq!(out.len(), input.len(), "length must be preserved");
884        assert!(out.contains('@'), "output must be email-shaped");
885        assert!(out.ends_with("@corp.com"), "domain must be preserved");
886    }
887
888    #[test]
889    fn category_aware_length_preserved_across_categories() {
890        let s = CategoryAwareStrategy::new();
891        let cases = [
892            (Category::Email, "alice@corp.com"),
893            (Category::IpV4, "192.168.1.1"),
894            (Category::AuthToken, "ghp_abc123secrettoken"),
895            (Category::Hostname, "db-prod.internal"),
896        ];
897        for (cat, input) in &cases {
898            let out = s.replace(cat, input, &test_entropy());
899            assert_eq!(out.len(), input.len(), "length mismatch for {:?}", cat);
900            assert_ne!(out, *input, "output must differ from input for {:?}", cat);
901        }
902    }
903
904    #[test]
905    fn category_aware_random_mode_consistent_within_run() {
906        // EntropyMode::Random produces fresh entropy each call, but the
907        // MappingStore dedup cache must still guarantee per-run consistency.
908        let gen = Arc::new(StrategyGenerator::new(
909            Box::new(CategoryAwareStrategy::new()),
910            EntropyMode::Random,
911        ));
912        let store = crate::store::MappingStore::new(gen, None);
913        let r1 = store
914            .get_or_insert(&Category::Email, "alice@corp.com")
915            .unwrap();
916        let r2 = store
917            .get_or_insert(&Category::Email, "alice@corp.com")
918            .unwrap();
919        assert_eq!(
920            r1, r2,
921            "store cache must return same replacement within run"
922        );
923        assert!(r1.contains('@'), "replacement must be email-shaped");
924        assert_eq!(r1.len(), "alice@corp.com".len(), "length must be preserved");
925    }
926
927    #[test]
928    fn category_aware_deterministic() {
929        let s = CategoryAwareStrategy::new();
930        let entropy = test_entropy();
931        let a = s.replace(&Category::Email, "alice@corp.com", &entropy);
932        let b = s.replace(&Category::Email, "alice@corp.com", &entropy);
933        assert_eq!(a, b, "category_aware must be deterministic");
934    }
935
936    // ---- Property tests: structural invariants on generated values ----
937
938    mod property {
939        use super::*;
940        use crate::store::MappingStore;
941        use proptest::prelude::*;
942
943        fn hmac_store() -> MappingStore {
944            let gen = Arc::new(crate::generator::HmacGenerator::new([77u8; 32]));
945            MappingStore::new(gen, None)
946        }
947
948        fn category_aware_store() -> MappingStore {
949            let gen = Arc::new(StrategyGenerator::new(
950                Box::new(CategoryAwareStrategy::new()),
951                EntropyMode::Deterministic { key: [77u8; 32] },
952            ));
953            MappingStore::new(gen, None)
954        }
955
956        proptest! {
957            #[test]
958            fn category_aware_length_preserved(s in "[a-z0-9]{1,64}") {
959                let store = category_aware_store();
960                let out = store.get_or_insert(&Category::AuthToken, &s).unwrap();
961                prop_assert_eq!(out.len(), s.len());
962            }
963
964            #[test]
965            fn category_aware_email_shaped(
966                local in "[a-z]{3,8}",
967                domain in "[a-z]{3,8}",
968                tld in "[a-z]{2,4}",
969            ) {
970                let input = format!("{local}@{domain}.{tld}");
971                let store = category_aware_store();
972                let out = store.get_or_insert(&Category::Email, &input).unwrap();
973                prop_assert_eq!(out.len(), input.len());
974                prop_assert!(out.contains('@'));
975                let after_at = out.split('@').nth(1).unwrap_or("");
976                prop_assert!(after_at.contains('.'));
977            }
978
979            #[test]
980            fn email_output_is_email_shaped(
981                local in "[a-z]{3,8}",
982                domain in "[a-z]{3,8}",
983                tld in "[a-z]{2,4}",
984            ) {
985                let input = format!("{local}@{domain}.{tld}");
986                let store = hmac_store();
987                let out = store.get_or_insert(&Category::Email, &input).unwrap();
988                prop_assert_eq!(out.chars().filter(|&c| c == '@').count(), 1);
989                let after = out.split('@').nth(1).unwrap_or("");
990                prop_assert!(after.contains('.'), "no dot in domain part: {out}");
991                prop_assert_eq!(out.len(), input.len());
992            }
993
994            #[test]
995            fn ipv4_output_preserves_dot_structure(
996                a in 0u8..=255u8,
997                b in 0u8..=255u8,
998                c in 0u8..=255u8,
999                d in 0u8..=255u8,
1000            ) {
1001                let input = format!("{a}.{b}.{c}.{d}");
1002                let store = hmac_store();
1003                let out = store.get_or_insert(&Category::IpV4, &input).unwrap();
1004                // The strategy preserves dot positions and digit counts but does
1005                // not clamp octet values to 0-255 (e.g. 114 → 987 is valid output).
1006                // Invariant: 4 dot-separated groups, each containing only digits,
1007                // each with the same digit count as the original octet.
1008                let in_parts: Vec<&str> = input.split('.').collect();
1009                let out_parts: Vec<&str> = out.split('.').collect();
1010                prop_assert_eq!(out_parts.len(), 4);
1011                for (inp, outp) in in_parts.iter().zip(out_parts.iter()) {
1012                    prop_assert_eq!(inp.len(), outp.len());
1013                    prop_assert!(outp.chars().all(|c| c.is_ascii_digit()));
1014                }
1015            }
1016
1017            #[test]
1018            fn same_input_always_same_output(s in "[a-z0-9]{4,12}@[a-z]{4,8}\\.com") {
1019                let store = hmac_store();
1020                let out1 = store.get_or_insert(&Category::Email, &s).unwrap();
1021                let out2 = store.get_or_insert(&Category::Email, &s).unwrap();
1022                prop_assert_eq!(out1, out2);
1023            }
1024
1025            #[test]
1026            fn different_categories_produce_different_outputs(s in "[a-z]{6,10}") {
1027                let store = hmac_store();
1028                let as_email = store.get_or_insert(&Category::Email, &format!("{s}@corp.com")).unwrap();
1029                let as_name  = store.get_or_insert(&Category::Name,  &format!("{s}@corp.com")).unwrap();
1030                prop_assert_ne!(as_email, as_name);
1031            }
1032        }
1033    }
1034}