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(
524            category,
525            entropy,
526            original,
527            crate::generator::LengthPolicy::Preserve,
528        )
529    }
530}
531
532// ===========================================================================
533// Tests
534// ===========================================================================
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539    use crate::category::Category;
540    use std::sync::Arc;
541
542    /// Helper: fixed deterministic entropy for testing.
543    fn test_entropy() -> [u8; 32] {
544        let mut e = [0u8; 32];
545        for (i, b) in e.iter_mut().enumerate() {
546            #[allow(clippy::cast_possible_truncation)] // i is always < 32, fits in u8
547            {
548                *b = (i as u8).wrapping_mul(37).wrapping_add(7);
549            }
550        }
551        e
552    }
553
554    // ---- Strategy trait: purity / determinism ----
555
556    #[test]
557    fn strategies_are_deterministic() {
558        let entropy = test_entropy();
559        let strategies: Vec<Box<dyn Strategy>> = vec![
560            Box::new(RandomString::new()),
561            Box::new(RandomUuid::new()),
562            Box::new(FakeIp::new()),
563            Box::new(PreserveLength::new()),
564            Box::new(HmacHash::new([42u8; 32])),
565            Box::new(CategoryAwareStrategy::new()),
566        ];
567        for s in &strategies {
568            let a = s.replace(&Category::AuthToken, "hello world", &entropy);
569            let b = s.replace(&Category::AuthToken, "hello world", &entropy);
570            assert_eq!(a, b, "strategy '{}' must be deterministic", s.name());
571        }
572    }
573
574    #[test]
575    fn different_entropy_different_output() {
576        let e1 = [1u8; 32];
577        let e2 = [2u8; 32];
578        let strategies: Vec<Box<dyn Strategy>> = vec![
579            Box::new(RandomString::new()),
580            Box::new(RandomUuid::new()),
581            Box::new(FakeIp::new()),
582            Box::new(PreserveLength::new()),
583            Box::new(CategoryAwareStrategy::new()),
584        ];
585        for s in &strategies {
586            let a = s.replace(&Category::AuthToken, "test", &e1);
587            let b = s.replace(&Category::AuthToken, "test", &e2);
588            assert_ne!(
589                a,
590                b,
591                "strategy '{}' should differ with different entropy",
592                s.name()
593            );
594        }
595    }
596
597    // ---- RandomString ----
598
599    #[test]
600    fn random_string_default_length() {
601        let s = RandomString::new();
602        let out = s.replace(&Category::AuthToken, "anything", &test_entropy());
603        assert_eq!(out.len(), 16);
604        assert!(
605            out.chars().all(|c| c.is_ascii_alphanumeric()),
606            "output must be alphanumeric: {}",
607            out,
608        );
609    }
610
611    #[test]
612    fn random_string_custom_length() {
613        let s = RandomString::with_length(8);
614        let out = s.replace(&Category::AuthToken, "anything", &test_entropy());
615        assert_eq!(out.len(), 8);
616    }
617
618    #[test]
619    fn random_string_clamped_length() {
620        let s = RandomString::with_length(999);
621        assert_eq!(s.len, 64);
622        let s = RandomString::with_length(0);
623        assert_eq!(s.len, 1);
624    }
625
626    // ---- RandomUuid ----
627
628    #[test]
629    fn random_uuid_format() {
630        let s = RandomUuid::new();
631        let out = s.replace(&Category::AuthToken, "anything", &test_entropy());
632        // 8-4-4-4-12 = 36 chars
633        assert_eq!(out.len(), 36, "UUID must be 36 chars: {}", out);
634        let parts: Vec<&str> = out.split('-').collect();
635        assert_eq!(parts.len(), 5);
636        assert_eq!(parts[0].len(), 8);
637        assert_eq!(parts[1].len(), 4);
638        assert_eq!(parts[2].len(), 4);
639        assert_eq!(parts[3].len(), 4);
640        assert_eq!(parts[4].len(), 12);
641        // Version nibble = 4
642        assert_eq!(&parts[2][0..1], "4", "version must be 4");
643        // Variant nibble ∈ {8,9,a,b}
644        let variant = &parts[3][0..1];
645        assert!(
646            ["8", "9", "a", "b"].contains(&variant),
647            "variant nibble must be 8/9/a/b, got {}",
648            variant,
649        );
650    }
651
652    // ---- FakeIp ----
653
654    #[test]
655    fn fake_ip_format() {
656        let s = FakeIp::new();
657        let input = "192.168.1.1";
658        let out = s.replace(&Category::IpV4, input, &test_entropy());
659        // Length preserved.
660        assert_eq!(
661            out.len(),
662            input.len(),
663            "FakeIp must preserve length: {}",
664            out
665        );
666        // Dot positions preserved.
667        let in_dots: Vec<usize> = input
668            .char_indices()
669            .filter(|&(_, c)| c == '.')
670            .map(|(i, _)| i)
671            .collect();
672        let out_dots: Vec<usize> = out
673            .char_indices()
674            .filter(|&(_, c)| c == '.')
675            .map(|(i, _)| i)
676            .collect();
677        assert_eq!(out_dots, in_dots, "FakeIp must preserve dot positions");
678        // Non-dot characters must be ASCII digits.
679        assert!(
680            out.chars().all(|c| c == '.' || c.is_ascii_digit()),
681            "FakeIp output must contain only digits and dots: {}",
682            out
683        );
684        // Must differ from input.
685        assert_ne!(out, input, "FakeIp must change the IP");
686    }
687
688    // ---- PreserveLength ----
689
690    #[test]
691    fn preserve_length_matches() {
692        let s = PreserveLength::new();
693        for input in &["a", "hello", "this is a fairly long string indeed", ""] {
694            let out = s.replace(&Category::AuthToken, input, &test_entropy());
695            assert_eq!(
696                out.len(),
697                input.len(),
698                "length mismatch for input '{}'",
699                input,
700            );
701        }
702    }
703
704    #[test]
705    fn preserve_length_characters() {
706        let s = PreserveLength::new();
707        let out = s.replace(&Category::AuthToken, "hello!", &test_entropy());
708        assert!(
709            out.chars().all(|c| c.is_ascii_alphanumeric()),
710            "output must be alphanumeric: {}",
711            out,
712        );
713    }
714
715    // ---- HmacHash ----
716
717    #[test]
718    fn hmac_hash_deterministic_with_key() {
719        let s = HmacHash::new([42u8; 32]);
720        let a = s.replace(&Category::AuthToken, "secret", &[0u8; 32]);
721        let b = s.replace(&Category::AuthToken, "secret", &[0xFF; 32]);
722        // Entropy is ignored — result depends only on key + original.
723        assert_eq!(a, b, "HmacHash must ignore entropy");
724    }
725
726    #[test]
727    fn hmac_hash_default_length() {
728        let s = HmacHash::new([0u8; 32]);
729        let out = s.replace(&Category::AuthToken, "test", &[0u8; 32]);
730        assert_eq!(out.len(), 32, "default output is 32 hex chars");
731        assert!(
732            out.chars().all(|c| c.is_ascii_hexdigit()),
733            "output must be hex: {}",
734            out,
735        );
736    }
737
738    #[test]
739    fn hmac_hash_custom_length() {
740        let s = HmacHash::with_output_len([0u8; 32], 12);
741        let out = s.replace(&Category::AuthToken, "test", &[0u8; 32]);
742        assert_eq!(out.len(), 12);
743    }
744
745    #[test]
746    fn hmac_hash_different_keys() {
747        let s1 = HmacHash::new([1u8; 32]);
748        let s2 = HmacHash::new([2u8; 32]);
749        let a = s1.replace(&Category::AuthToken, "test", &[0u8; 32]);
750        let b = s2.replace(&Category::AuthToken, "test", &[0u8; 32]);
751        assert_ne!(a, b, "different keys must produce different output");
752    }
753
754    #[test]
755    fn hmac_hash_different_inputs() {
756        let s = HmacHash::new([42u8; 32]);
757        let a = s.replace(&Category::AuthToken, "alice", &[0u8; 32]);
758        let b = s.replace(&Category::AuthToken, "bob", &[0u8; 32]);
759        assert_ne!(a, b);
760    }
761
762    // ---- StrategyGenerator integration ----
763
764    #[test]
765    fn strategy_generator_deterministic() {
766        let strat = Box::new(RandomString::new());
767        let gen = StrategyGenerator::new(strat, EntropyMode::Deterministic { key: [42u8; 32] });
768        let a = gen.generate(&Category::Email, "alice@corp.com");
769        let b = gen.generate(&Category::Email, "alice@corp.com");
770        assert_eq!(a, b, "deterministic mode must be repeatable");
771    }
772
773    #[test]
774    fn strategy_generator_different_categories() {
775        let strat = Box::new(RandomString::new());
776        let gen = StrategyGenerator::new(strat, EntropyMode::Deterministic { key: [42u8; 32] });
777        let a = gen.generate(&Category::Email, "test");
778        let b = gen.generate(&Category::Name, "test");
779        assert_ne!(a, b, "different categories must produce different entropy");
780    }
781
782    #[test]
783    fn strategy_generator_with_store() {
784        let strat = Box::new(RandomUuid::new());
785        let gen = Arc::new(StrategyGenerator::new(
786            strat,
787            EntropyMode::Deterministic { key: [99u8; 32] },
788        ));
789        let store = crate::store::MappingStore::new(gen, None);
790
791        let s1 = store
792            .get_or_insert(&Category::Email, "alice@corp.com")
793            .unwrap();
794        let s2 = store
795            .get_or_insert(&Category::Email, "alice@corp.com")
796            .unwrap();
797        assert_eq!(s1, s2, "store must cache strategy output");
798        assert_eq!(s1.len(), 36, "output must be UUID-formatted");
799    }
800
801    #[test]
802    fn strategy_generator_random_cached_in_store() {
803        let strat = Box::new(FakeIp::new());
804        let gen = Arc::new(StrategyGenerator::new(strat, EntropyMode::Random));
805        let store = crate::store::MappingStore::new(gen, None);
806
807        let s1 = store.get_or_insert(&Category::IpV4, "192.168.1.1").unwrap();
808        let s2 = store.get_or_insert(&Category::IpV4, "192.168.1.1").unwrap();
809        // Random entropy, but store caches first result.
810        assert_eq!(s1, s2);
811        assert_eq!(
812            s1.len(),
813            "192.168.1.1".len(),
814            "FakeIp must preserve input length"
815        );
816    }
817
818    #[test]
819    fn all_strategies_implement_send_sync() {
820        fn assert_send_sync<T: Send + Sync>() {}
821        assert_send_sync::<RandomString>();
822        assert_send_sync::<RandomUuid>();
823        assert_send_sync::<FakeIp>();
824        assert_send_sync::<PreserveLength>();
825        assert_send_sync::<HmacHash>();
826        assert_send_sync::<CategoryAwareStrategy>();
827        assert_send_sync::<StrategyGenerator>();
828    }
829
830    #[test]
831    fn strategy_names_unique() {
832        let strategies: Vec<Box<dyn Strategy>> = vec![
833            Box::new(RandomString::new()),
834            Box::new(RandomUuid::new()),
835            Box::new(FakeIp::new()),
836            Box::new(PreserveLength::new()),
837            Box::new(HmacHash::new([0u8; 32])),
838            Box::new(CategoryAwareStrategy::new()),
839        ];
840        let mut names: Vec<&str> = strategies.iter().map(|s| s.name()).collect();
841        let len_before = names.len();
842        names.sort_unstable();
843        names.dedup();
844        assert_eq!(names.len(), len_before, "strategy names must be unique");
845    }
846
847    // ---- Concurrent use via StrategyGenerator + MappingStore ----
848
849    #[test]
850    fn concurrent_strategy_generator() {
851        use std::thread;
852
853        let strat = Box::new(PreserveLength::new());
854        let gen = Arc::new(StrategyGenerator::new(
855            strat,
856            EntropyMode::Deterministic { key: [7u8; 32] },
857        ));
858        let store = Arc::new(crate::store::MappingStore::new(gen, None));
859
860        let mut handles = vec![];
861        for t in 0..4 {
862            let store = Arc::clone(&store);
863            handles.push(thread::spawn(move || {
864                for i in 0..500 {
865                    let val = format!("thread{}-val{}", t, i);
866                    let result = store.get_or_insert(&Category::Name, &val).unwrap();
867                    assert_eq!(
868                        result.len(),
869                        val.len(),
870                        "PreserveLength must match input length",
871                    );
872                }
873            }));
874        }
875        for h in handles {
876            h.join().unwrap();
877        }
878        assert_eq!(store.len(), 2000);
879    }
880
881    // ---- CategoryAwareStrategy ----
882
883    #[test]
884    fn category_aware_email_shaped() {
885        let s = CategoryAwareStrategy::new();
886        let input = "alice@corp.com";
887        let out = s.replace(&Category::Email, input, &test_entropy());
888        assert_eq!(out.len(), input.len(), "length must be preserved");
889        assert!(out.contains('@'), "output must be email-shaped");
890        assert!(out.ends_with("@corp.com"), "domain must be preserved");
891    }
892
893    #[test]
894    fn category_aware_length_preserved_across_categories() {
895        let s = CategoryAwareStrategy::new();
896        let cases = [
897            (Category::Email, "alice@corp.com"),
898            (Category::IpV4, "192.168.1.1"),
899            (Category::AuthToken, "ghp_abc123secrettoken"),
900            (Category::Hostname, "db-prod.internal"),
901        ];
902        for (cat, input) in &cases {
903            let out = s.replace(cat, input, &test_entropy());
904            assert_eq!(out.len(), input.len(), "length mismatch for {:?}", cat);
905            assert_ne!(out, *input, "output must differ from input for {:?}", cat);
906        }
907    }
908
909    #[test]
910    fn category_aware_random_mode_consistent_within_run() {
911        // EntropyMode::Random produces fresh entropy each call, but the
912        // MappingStore dedup cache must still guarantee per-run consistency.
913        let gen = Arc::new(StrategyGenerator::new(
914            Box::new(CategoryAwareStrategy::new()),
915            EntropyMode::Random,
916        ));
917        let store = crate::store::MappingStore::new(gen, None);
918        let r1 = store
919            .get_or_insert(&Category::Email, "alice@corp.com")
920            .unwrap();
921        let r2 = store
922            .get_or_insert(&Category::Email, "alice@corp.com")
923            .unwrap();
924        assert_eq!(
925            r1, r2,
926            "store cache must return same replacement within run"
927        );
928        assert!(r1.contains('@'), "replacement must be email-shaped");
929        assert_eq!(r1.len(), "alice@corp.com".len(), "length must be preserved");
930    }
931
932    #[test]
933    fn category_aware_deterministic() {
934        let s = CategoryAwareStrategy::new();
935        let entropy = test_entropy();
936        let a = s.replace(&Category::Email, "alice@corp.com", &entropy);
937        let b = s.replace(&Category::Email, "alice@corp.com", &entropy);
938        assert_eq!(a, b, "category_aware must be deterministic");
939    }
940
941    // ---- Property tests: structural invariants on generated values ----
942
943    mod property {
944        use super::*;
945        use crate::store::MappingStore;
946        use proptest::prelude::*;
947
948        fn hmac_store() -> MappingStore {
949            let gen = Arc::new(crate::generator::HmacGenerator::new([77u8; 32]));
950            MappingStore::new(gen, None)
951        }
952
953        fn category_aware_store() -> MappingStore {
954            let gen = Arc::new(StrategyGenerator::new(
955                Box::new(CategoryAwareStrategy::new()),
956                EntropyMode::Deterministic { key: [77u8; 32] },
957            ));
958            MappingStore::new(gen, None)
959        }
960
961        proptest! {
962            #[test]
963            fn category_aware_length_preserved(s in "[a-z0-9]{1,64}") {
964                let store = category_aware_store();
965                let out = store.get_or_insert(&Category::AuthToken, &s).unwrap();
966                prop_assert_eq!(out.len(), s.len());
967            }
968
969            #[test]
970            fn category_aware_email_shaped(
971                local in "[a-z]{3,8}",
972                domain in "[a-z]{3,8}",
973                tld in "[a-z]{2,4}",
974            ) {
975                let input = format!("{local}@{domain}.{tld}");
976                let store = category_aware_store();
977                let out = store.get_or_insert(&Category::Email, &input).unwrap();
978                prop_assert_eq!(out.len(), input.len());
979                prop_assert!(out.contains('@'));
980                let after_at = out.split('@').nth(1).unwrap_or("");
981                prop_assert!(after_at.contains('.'));
982            }
983
984            #[test]
985            fn email_output_is_email_shaped(
986                local in "[a-z]{3,8}",
987                domain in "[a-z]{3,8}",
988                tld in "[a-z]{2,4}",
989            ) {
990                let input = format!("{local}@{domain}.{tld}");
991                let store = hmac_store();
992                let out = store.get_or_insert(&Category::Email, &input).unwrap();
993                prop_assert_eq!(out.chars().filter(|&c| c == '@').count(), 1);
994                let after = out.split('@').nth(1).unwrap_or("");
995                prop_assert!(after.contains('.'), "no dot in domain part: {out}");
996                prop_assert_eq!(out.len(), input.len());
997            }
998
999            #[test]
1000            fn ipv4_output_preserves_dot_structure(
1001                a in 0u8..=255u8,
1002                b in 0u8..=255u8,
1003                c in 0u8..=255u8,
1004                d in 0u8..=255u8,
1005            ) {
1006                let input = format!("{a}.{b}.{c}.{d}");
1007                let store = hmac_store();
1008                let out = store.get_or_insert(&Category::IpV4, &input).unwrap();
1009                // The strategy preserves dot positions and digit counts but does
1010                // not clamp octet values to 0-255 (e.g. 114 → 987 is valid output).
1011                // Invariant: 4 dot-separated groups, each containing only digits,
1012                // each with the same digit count as the original octet.
1013                let in_parts: Vec<&str> = input.split('.').collect();
1014                let out_parts: Vec<&str> = out.split('.').collect();
1015                prop_assert_eq!(out_parts.len(), 4);
1016                for (inp, outp) in in_parts.iter().zip(out_parts.iter()) {
1017                    prop_assert_eq!(inp.len(), outp.len());
1018                    prop_assert!(outp.chars().all(|c| c.is_ascii_digit()));
1019                }
1020            }
1021
1022            #[test]
1023            fn same_input_always_same_output(s in "[a-z0-9]{4,12}@[a-z]{4,8}\\.com") {
1024                let store = hmac_store();
1025                let out1 = store.get_or_insert(&Category::Email, &s).unwrap();
1026                let out2 = store.get_or_insert(&Category::Email, &s).unwrap();
1027                prop_assert_eq!(out1, out2);
1028            }
1029
1030            #[test]
1031            fn different_categories_produce_different_outputs(s in "[a-z]{6,10}") {
1032                let store = hmac_store();
1033                let as_email = store.get_or_insert(&Category::Email, &format!("{s}@corp.com")).unwrap();
1034                let as_name  = store.get_or_insert(&Category::Name,  &format!("{s}@corp.com")).unwrap();
1035                prop_assert_ne!(as_email, as_name);
1036            }
1037        }
1038    }
1039}