Skip to main content

sanitize_engine/
strategy.rs

1//! Pluggable replacement strategies.
2//!
3//! This module provides the [`Strategy`] trait and five 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 `(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 `(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, 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::thread_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
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(original, &entropy)
187    }
188}
189
190// ===========================================================================
191// Built-in strategies
192// ===========================================================================
193
194// ---------------------------------------------------------------------------
195// 1. RandomString
196// ---------------------------------------------------------------------------
197
198/// Generates an alphanumeric string from entropy bytes.
199///
200/// The output length defaults to 16 characters but can be configured.
201/// Characters are drawn from `[a-zA-Z0-9]`.
202pub struct RandomString {
203    /// Desired output length (capped at 64).
204    len: usize,
205}
206
207impl RandomString {
208    /// Create with default length (16).
209    #[must_use]
210    pub fn new() -> Self {
211        Self { len: 16 }
212    }
213
214    /// Create with a specific output length (clamped to 1..=64).
215    #[must_use]
216    pub fn with_length(len: usize) -> Self {
217        Self {
218            len: len.clamp(1, 64),
219        }
220    }
221}
222
223impl Default for RandomString {
224    fn default() -> Self {
225        Self::new()
226    }
227}
228
229impl Strategy for RandomString {
230    fn name(&self) -> &'static str {
231        "random_string"
232    }
233
234    fn replace(&self, _original: &str, entropy: &[u8; 32]) -> String {
235        const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz\
236                                  ABCDEFGHIJKLMNOPQRSTUVWXYZ\
237                                  0123456789";
238        // Expand entropy with a simple deterministic PRNG seeded from the
239        // 32 entropy bytes (xorshift64 on 8-byte chunks).
240        let mut chars = String::with_capacity(self.len);
241        // Seed from all 32 entropy bytes via wrapping addition of 4 u64 chunks.
242        let mut state = 0u64;
243        for chunk in entropy.chunks_exact(8) {
244            // chunks_exact(8) on a [u8; 32] always yields exactly 8-byte slices.
245            let arr: [u8; 8] = chunk.try_into().expect("chunks_exact(8) yields 8-byte slices");
246            state = state.wrapping_add(u64::from_le_bytes(arr));
247        }
248        if state == 0 {
249            state = 0xDEAD_BEEF_CAFE_BABE; // avoid degenerate zero state
250        }
251
252        for _ in 0..self.len {
253            // xorshift64
254            state ^= state << 13;
255            state ^= state >> 7;
256            state ^= state << 17;
257            #[allow(clippy::cast_possible_truncation)]
258            // truncation is intentional for index mapping
259            let idx = (state as usize) % CHARSET.len();
260            chars.push(CHARSET[idx] as char);
261        }
262        chars
263    }
264}
265
266// ---------------------------------------------------------------------------
267// 2. RandomUuid
268// ---------------------------------------------------------------------------
269
270/// Generates a UUID v4–formatted string from entropy bytes.
271///
272/// The output looks like `xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx` where
273/// `x` is a hex digit derived from entropy and `y ∈ {8,9,a,b}` per RFC 4122.
274/// When backed by deterministic entropy, the UUID is stable.
275pub struct RandomUuid;
276
277impl RandomUuid {
278    #[must_use]
279    pub fn new() -> Self {
280        Self
281    }
282}
283
284impl Default for RandomUuid {
285    fn default() -> Self {
286        Self::new()
287    }
288}
289
290impl Strategy for RandomUuid {
291    fn name(&self) -> &'static str {
292        "random_uuid"
293    }
294
295    fn replace(&self, _original: &str, entropy: &[u8; 32]) -> String {
296        // Take the first 16 bytes to form a UUID.
297        let mut bytes = [0u8; 16];
298        bytes.copy_from_slice(&entropy[..16]);
299
300        // Set version = 4 (bits 4-7 of byte 6).
301        bytes[6] = (bytes[6] & 0x0F) | 0x40;
302        // Set variant = RFC 4122 (bits 6-7 of byte 8).
303        bytes[8] = (bytes[8] & 0x3F) | 0x80;
304
305        format!(
306            "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
307            bytes[0], bytes[1], bytes[2], bytes[3],
308            bytes[4], bytes[5],
309            bytes[6], bytes[7],
310            bytes[8], bytes[9],
311            bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
312        )
313    }
314}
315
316// ---------------------------------------------------------------------------
317// 3. FakeIp
318// ---------------------------------------------------------------------------
319
320/// Generates a fake IPv4 address in the `10.0.0.0/8` reserved range.
321///
322/// Avoids `.0` in the last octet to prevent confusion with network addresses.
323pub struct FakeIp;
324
325impl FakeIp {
326    #[must_use]
327    pub fn new() -> Self {
328        Self
329    }
330}
331
332impl Default for FakeIp {
333    fn default() -> Self {
334        Self::new()
335    }
336}
337
338impl Strategy for FakeIp {
339    fn name(&self) -> &'static str {
340        "fake_ip"
341    }
342
343    fn replace(&self, _original: &str, entropy: &[u8; 32]) -> String {
344        let a = entropy[0];
345        let b = entropy[1];
346        // Ensure last octet is ≥ 1 (avoid x.x.x.0).
347        let c = entropy[2].max(1);
348        format!("10.{}.{}.{}", a, b, c)
349    }
350}
351
352// ---------------------------------------------------------------------------
353// 4. PreserveLength
354// ---------------------------------------------------------------------------
355
356/// Generates a replacement with the **same byte length** as the original.
357///
358/// Useful when column widths, fixed-length fields, or alignment must be
359/// maintained. Uses lowercase hex characters derived from entropy.
360pub struct PreserveLength;
361
362impl PreserveLength {
363    #[must_use]
364    pub fn new() -> Self {
365        Self
366    }
367}
368
369impl Default for PreserveLength {
370    fn default() -> Self {
371        Self::new()
372    }
373}
374
375impl Strategy for PreserveLength {
376    fn name(&self) -> &'static str {
377        "preserve_length"
378    }
379
380    fn replace(&self, original: &str, entropy: &[u8; 32]) -> String {
381        const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
382
383        let target_len = original.len();
384        if target_len == 0 {
385            return String::new();
386        }
387
388        // Seed from all 32 entropy bytes via wrapping addition of 4 u64 chunks.
389        let mut state = 0u64;
390        for chunk in entropy.chunks_exact(8) {
391            // chunks_exact(8) on a [u8; 32] always yields exactly 8-byte slices.
392            let arr: [u8; 8] = chunk.try_into().expect("chunks_exact(8) yields 8-byte slices");
393            state = state.wrapping_add(u64::from_le_bytes(arr));
394        }
395        if state == 0 {
396            state = 0xCAFE_BABE_DEAD_BEEFu64;
397        }
398
399        let mut result = String::with_capacity(target_len);
400        for _ in 0..target_len {
401            state ^= state << 13;
402            state ^= state >> 7;
403            state ^= state << 17;
404            #[allow(clippy::cast_possible_truncation)]
405            // truncation is intentional for index mapping
406            let idx = (state as usize) % CHARSET.len();
407            result.push(CHARSET[idx] as char);
408        }
409        result
410    }
411}
412
413// ---------------------------------------------------------------------------
414// 5. HmacHash
415// ---------------------------------------------------------------------------
416
417/// HMAC-SHA256 hash strategy — deterministic by construction.
418///
419/// Unlike the other strategies, `HmacHash` carries its own 32-byte key and
420/// computes `HMAC-SHA256(key, original)` directly. The caller-provided
421/// entropy is **ignored**. This makes the output deterministic regardless
422/// of the [`EntropyMode`] used by [`StrategyGenerator`].
423///
424/// The output is a lowercase hex string, optionally truncated to
425/// `output_len` characters (default: 32).
426pub struct HmacHash {
427    key: [u8; 32],
428    /// Number of hex characters to emit (max 64).
429    output_len: usize,
430}
431
432impl HmacHash {
433    /// Create with both a key and a default output length (32 hex chars).
434    #[must_use]
435    pub fn new(key: [u8; 32]) -> Self {
436        Self {
437            key,
438            output_len: 32,
439        }
440    }
441
442    /// Create with a custom output length (clamped to 1..=64).
443    #[must_use]
444    pub fn with_output_len(key: [u8; 32], output_len: usize) -> Self {
445        Self {
446            key,
447            output_len: output_len.clamp(1, 64),
448        }
449    }
450}
451
452impl Strategy for HmacHash {
453    fn name(&self) -> &'static str {
454        "hmac_hash"
455    }
456
457    fn replace(&self, original: &str, _entropy: &[u8; 32]) -> String {
458        use std::fmt::Write;
459
460        type HmacSha256 = Hmac<Sha256>;
461        let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
462        mac.update(original.as_bytes());
463        let result = mac.finalize();
464        let hash_bytes: [u8; 32] = {
465            let mut buf = [0u8; 32];
466            buf.copy_from_slice(&result.into_bytes());
467            buf
468        };
469        let mut hex = String::with_capacity(64);
470        for b in &hash_bytes {
471            let _ = write!(hex, "{:02x}", b);
472        }
473        hex[..self.output_len].to_string()
474    }
475}
476
477// ===========================================================================
478// Tests
479// ===========================================================================
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use crate::category::Category;
485    use std::sync::Arc;
486
487    /// Helper: fixed deterministic entropy for testing.
488    fn test_entropy() -> [u8; 32] {
489        let mut e = [0u8; 32];
490        for (i, b) in e.iter_mut().enumerate() {
491            #[allow(clippy::cast_possible_truncation)] // i is always < 32, fits in u8
492            {
493                *b = (i as u8).wrapping_mul(37).wrapping_add(7);
494            }
495        }
496        e
497    }
498
499    // ---- Strategy trait: purity / determinism ----
500
501    #[test]
502    fn strategies_are_deterministic() {
503        let entropy = test_entropy();
504        let strategies: Vec<Box<dyn Strategy>> = vec![
505            Box::new(RandomString::new()),
506            Box::new(RandomUuid::new()),
507            Box::new(FakeIp::new()),
508            Box::new(PreserveLength::new()),
509            Box::new(HmacHash::new([42u8; 32])),
510        ];
511        for s in &strategies {
512            let a = s.replace("hello world", &entropy);
513            let b = s.replace("hello world", &entropy);
514            assert_eq!(a, b, "strategy '{}' must be deterministic", s.name());
515        }
516    }
517
518    #[test]
519    fn different_entropy_different_output() {
520        let e1 = [1u8; 32];
521        let e2 = [2u8; 32];
522        let strategies: Vec<Box<dyn Strategy>> = vec![
523            Box::new(RandomString::new()),
524            Box::new(RandomUuid::new()),
525            Box::new(FakeIp::new()),
526            Box::new(PreserveLength::new()),
527        ];
528        for s in &strategies {
529            let a = s.replace("test", &e1);
530            let b = s.replace("test", &e2);
531            assert_ne!(
532                a,
533                b,
534                "strategy '{}' should differ with different entropy",
535                s.name()
536            );
537        }
538    }
539
540    // ---- RandomString ----
541
542    #[test]
543    fn random_string_default_length() {
544        let s = RandomString::new();
545        let out = s.replace("anything", &test_entropy());
546        assert_eq!(out.len(), 16);
547        assert!(
548            out.chars().all(|c| c.is_ascii_alphanumeric()),
549            "output must be alphanumeric: {}",
550            out,
551        );
552    }
553
554    #[test]
555    fn random_string_custom_length() {
556        let s = RandomString::with_length(8);
557        let out = s.replace("anything", &test_entropy());
558        assert_eq!(out.len(), 8);
559    }
560
561    #[test]
562    fn random_string_clamped_length() {
563        let s = RandomString::with_length(999);
564        assert_eq!(s.len, 64);
565        let s = RandomString::with_length(0);
566        assert_eq!(s.len, 1);
567    }
568
569    // ---- RandomUuid ----
570
571    #[test]
572    fn random_uuid_format() {
573        let s = RandomUuid::new();
574        let out = s.replace("anything", &test_entropy());
575        // 8-4-4-4-12 = 36 chars
576        assert_eq!(out.len(), 36, "UUID must be 36 chars: {}", out);
577        let parts: Vec<&str> = out.split('-').collect();
578        assert_eq!(parts.len(), 5);
579        assert_eq!(parts[0].len(), 8);
580        assert_eq!(parts[1].len(), 4);
581        assert_eq!(parts[2].len(), 4);
582        assert_eq!(parts[3].len(), 4);
583        assert_eq!(parts[4].len(), 12);
584        // Version nibble = 4
585        assert_eq!(&parts[2][0..1], "4", "version must be 4");
586        // Variant nibble ∈ {8,9,a,b}
587        let variant = &parts[3][0..1];
588        assert!(
589            ["8", "9", "a", "b"].contains(&variant),
590            "variant nibble must be 8/9/a/b, got {}",
591            variant,
592        );
593    }
594
595    // ---- FakeIp ----
596
597    #[test]
598    fn fake_ip_format() {
599        let s = FakeIp::new();
600        let out = s.replace("192.168.1.1", &test_entropy());
601        assert!(out.starts_with("10."), "must be in 10.0.0.0/8: {}", out);
602        let octets: Vec<&str> = out.split('.').collect();
603        assert_eq!(octets.len(), 4);
604        for octet in &octets {
605            let _n: u8 = octet.parse().expect("octet must be a valid u8");
606        }
607        // Last octet ≥ 1.
608        let last: u8 = octets[3].parse().unwrap();
609        assert!(last >= 1, "last octet must be ≥ 1");
610    }
611
612    // ---- PreserveLength ----
613
614    #[test]
615    fn preserve_length_matches() {
616        let s = PreserveLength::new();
617        for input in &["a", "hello", "this is a fairly long string indeed", ""] {
618            let out = s.replace(input, &test_entropy());
619            assert_eq!(
620                out.len(),
621                input.len(),
622                "length mismatch for input '{}'",
623                input,
624            );
625        }
626    }
627
628    #[test]
629    fn preserve_length_characters() {
630        let s = PreserveLength::new();
631        let out = s.replace("hello!", &test_entropy());
632        assert!(
633            out.chars().all(|c| c.is_ascii_alphanumeric()),
634            "output must be alphanumeric: {}",
635            out,
636        );
637    }
638
639    // ---- HmacHash ----
640
641    #[test]
642    fn hmac_hash_deterministic_with_key() {
643        let s = HmacHash::new([42u8; 32]);
644        let a = s.replace("secret", &[0u8; 32]);
645        let b = s.replace("secret", &[0xFF; 32]);
646        // Entropy is ignored — result depends only on key + original.
647        assert_eq!(a, b, "HmacHash must ignore entropy");
648    }
649
650    #[test]
651    fn hmac_hash_default_length() {
652        let s = HmacHash::new([0u8; 32]);
653        let out = s.replace("test", &[0u8; 32]);
654        assert_eq!(out.len(), 32, "default output is 32 hex chars");
655        assert!(
656            out.chars().all(|c| c.is_ascii_hexdigit()),
657            "output must be hex: {}",
658            out,
659        );
660    }
661
662    #[test]
663    fn hmac_hash_custom_length() {
664        let s = HmacHash::with_output_len([0u8; 32], 12);
665        let out = s.replace("test", &[0u8; 32]);
666        assert_eq!(out.len(), 12);
667    }
668
669    #[test]
670    fn hmac_hash_different_keys() {
671        let s1 = HmacHash::new([1u8; 32]);
672        let s2 = HmacHash::new([2u8; 32]);
673        let a = s1.replace("test", &[0u8; 32]);
674        let b = s2.replace("test", &[0u8; 32]);
675        assert_ne!(a, b, "different keys must produce different output");
676    }
677
678    #[test]
679    fn hmac_hash_different_inputs() {
680        let s = HmacHash::new([42u8; 32]);
681        let a = s.replace("alice", &[0u8; 32]);
682        let b = s.replace("bob", &[0u8; 32]);
683        assert_ne!(a, b);
684    }
685
686    // ---- StrategyGenerator integration ----
687
688    #[test]
689    fn strategy_generator_deterministic() {
690        let strat = Box::new(RandomString::new());
691        let gen = StrategyGenerator::new(strat, EntropyMode::Deterministic { key: [42u8; 32] });
692        let a = gen.generate(&Category::Email, "alice@corp.com");
693        let b = gen.generate(&Category::Email, "alice@corp.com");
694        assert_eq!(a, b, "deterministic mode must be repeatable");
695    }
696
697    #[test]
698    fn strategy_generator_different_categories() {
699        let strat = Box::new(RandomString::new());
700        let gen = StrategyGenerator::new(strat, EntropyMode::Deterministic { key: [42u8; 32] });
701        let a = gen.generate(&Category::Email, "test");
702        let b = gen.generate(&Category::Name, "test");
703        assert_ne!(a, b, "different categories must produce different entropy");
704    }
705
706    #[test]
707    fn strategy_generator_with_store() {
708        let strat = Box::new(RandomUuid::new());
709        let gen = Arc::new(StrategyGenerator::new(
710            strat,
711            EntropyMode::Deterministic { key: [99u8; 32] },
712        ));
713        let store = crate::store::MappingStore::new(gen, None);
714
715        let s1 = store
716            .get_or_insert(&Category::Email, "alice@corp.com")
717            .unwrap();
718        let s2 = store
719            .get_or_insert(&Category::Email, "alice@corp.com")
720            .unwrap();
721        assert_eq!(s1, s2, "store must cache strategy output");
722        assert_eq!(s1.len(), 36, "output must be UUID-formatted");
723    }
724
725    #[test]
726    fn strategy_generator_random_cached_in_store() {
727        let strat = Box::new(FakeIp::new());
728        let gen = Arc::new(StrategyGenerator::new(strat, EntropyMode::Random));
729        let store = crate::store::MappingStore::new(gen, None);
730
731        let s1 = store.get_or_insert(&Category::IpV4, "192.168.1.1").unwrap();
732        let s2 = store.get_or_insert(&Category::IpV4, "192.168.1.1").unwrap();
733        // Random entropy, but store caches first result.
734        assert_eq!(s1, s2);
735        assert!(s1.starts_with("10."));
736    }
737
738    #[test]
739    fn all_strategies_implement_send_sync() {
740        fn assert_send_sync<T: Send + Sync>() {}
741        assert_send_sync::<RandomString>();
742        assert_send_sync::<RandomUuid>();
743        assert_send_sync::<FakeIp>();
744        assert_send_sync::<PreserveLength>();
745        assert_send_sync::<HmacHash>();
746        assert_send_sync::<StrategyGenerator>();
747    }
748
749    #[test]
750    fn strategy_names_unique() {
751        let strategies: Vec<Box<dyn Strategy>> = vec![
752            Box::new(RandomString::new()),
753            Box::new(RandomUuid::new()),
754            Box::new(FakeIp::new()),
755            Box::new(PreserveLength::new()),
756            Box::new(HmacHash::new([0u8; 32])),
757        ];
758        let mut names: Vec<&str> = strategies.iter().map(|s| s.name()).collect();
759        let len_before = names.len();
760        names.sort_unstable();
761        names.dedup();
762        assert_eq!(names.len(), len_before, "strategy names must be unique");
763    }
764
765    // ---- Concurrent use via StrategyGenerator + MappingStore ----
766
767    #[test]
768    fn concurrent_strategy_generator() {
769        use std::thread;
770
771        let strat = Box::new(PreserveLength::new());
772        let gen = Arc::new(StrategyGenerator::new(
773            strat,
774            EntropyMode::Deterministic { key: [7u8; 32] },
775        ));
776        let store = Arc::new(crate::store::MappingStore::new(gen, None));
777
778        let mut handles = vec![];
779        for t in 0..4 {
780            let store = Arc::clone(&store);
781            handles.push(thread::spawn(move || {
782                for i in 0..500 {
783                    let val = format!("thread{}-val{}", t, i);
784                    let result = store.get_or_insert(&Category::Name, &val).unwrap();
785                    assert_eq!(
786                        result.len(),
787                        val.len(),
788                        "PreserveLength must match input length",
789                    );
790                }
791            }));
792        }
793        for h in handles {
794            h.join().unwrap();
795        }
796        assert_eq!(store.len(), 2000);
797    }
798}