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//! # Architecture
9//!
10//! ```text
11//! ┌──────────────────────┐
12//! │    MappingStore       │  ← owns Arc<dyn ReplacementGenerator>
13//! └──────────┬───────────┘
14//!            │ calls generate(category, original)
15//!            ▼
16//! ┌──────────────────────┐
17//! │  StrategyGenerator   │  ← adapter: produces entropy, delegates to Strategy
18//! │  (ReplacementGenerator)│
19//! └──────────┬───────────┘
20//!            │ calls replace(original, &entropy)
21//!            ▼
22//! ┌──────────────────────┐
23//! │   dyn Strategy       │  ← pure function of (original, entropy) → String
24//! │                      │
25//! │  RandomString        │
26//! │  RandomUuid          │
27//! │  FakeIp              │
28//! │  PreserveLength      │
29//! │  HmacHash            │
30//! └──────────────────────┘
31//! ```
32//!
33//! # Deterministic Mode
34//!
35//! Strategies are pure functions of `(original, entropy)`. Determinism is
36//! controlled by the **entropy source** inside [`StrategyGenerator`]:
37//!
38//! - **Deterministic** (`EntropyMode::Deterministic`): entropy is derived
39//!   via HMAC-SHA256 keyed with a fixed seed — same seed + same input →
40//!   same replacement across runs.
41//! - **Random** (`EntropyMode::Random`): entropy comes from OS CSPRNG —
42//!   each call produces a fresh value (but the `MappingStore` still caches
43//!   the first result per unique input for per-run consistency).
44//!
45//! The [`HmacHash`] strategy is an exception: it carries its own HMAC key
46//! and is deterministic by construction regardless of the entropy mode.
47//!
48//! # Extensibility
49//!
50//! To add a new replacement strategy:
51//!
52//! 1. Create a struct implementing [`Strategy`].
53//! 2. Return a unique name from [`Strategy::name`].
54//! 3. Implement [`Strategy::replace`] as a pure function of `(original, entropy)`.
55//! 4. Wrap it in a [`StrategyGenerator`] to use with `MappingStore`.
56//!
57//! Third-party crates can implement `Strategy` without modifying this crate,
58//! since the trait is public and object-safe.
59
60use crate::category::Category;
61use crate::generator::ReplacementGenerator;
62use hmac::{Hmac, Mac};
63use rand::Rng;
64use sha2::Sha256;
65use zeroize::Zeroize;
66
67// ---------------------------------------------------------------------------
68// Strategy trait
69// ---------------------------------------------------------------------------
70
71/// A pluggable replacement strategy.
72///
73/// Strategies transform an original sensitive value into a sanitized
74/// replacement using 32 bytes of caller-provided entropy. They MUST be
75/// **pure functions** of their inputs: the same `(original, entropy)` pair
76/// always produces the same output.
77///
78/// Strategies are agnostic to how entropy is produced (HMAC-deterministic
79/// or CSPRNG-random). That concern is handled by [`StrategyGenerator`].
80pub trait Strategy: Send + Sync {
81    /// Human-readable, unique name for this strategy (e.g. `"random_string"`).
82    fn name(&self) -> &'static str;
83
84    /// Produce a sanitized replacement for `original` using `entropy`.
85    ///
86    /// # Contract
87    ///
88    /// - Must be deterministic: same `(original, entropy)` → same output.
89    /// - Must not perform I/O or access external mutable state.
90    /// - Returned value should be clearly synthetic / non-sensitive.
91    fn replace(&self, original: &str, entropy: &[u8; 32]) -> String;
92}
93
94// ---------------------------------------------------------------------------
95// Entropy mode (used by StrategyGenerator)
96// ---------------------------------------------------------------------------
97
98/// How entropy is produced for strategies.
99#[derive(Debug)]
100pub enum EntropyMode {
101    /// Deterministic: `entropy = HMAC-SHA256(key, category || '\0' || original)`.
102    Deterministic {
103        /// 32-byte HMAC key (seed).
104        key: [u8; 32],
105    },
106    /// Random: entropy is drawn from OS CSPRNG on every call.
107    Random,
108}
109
110impl Drop for EntropyMode {
111    fn drop(&mut self) {
112        if let EntropyMode::Deterministic { ref mut key } = self {
113            key.zeroize();
114        }
115    }
116}
117
118// ---------------------------------------------------------------------------
119// StrategyGenerator — adapter from Strategy → ReplacementGenerator
120// ---------------------------------------------------------------------------
121
122/// Adapter that bridges a [`Strategy`] into the [`ReplacementGenerator`]
123/// interface consumed by [`MappingStore`](crate::store::MappingStore).
124///
125/// It produces entropy according to the configured [`EntropyMode`] and
126/// delegates replacement formatting to the wrapped strategy.
127pub struct StrategyGenerator {
128    strategy: Box<dyn Strategy>,
129    mode: EntropyMode,
130}
131
132impl StrategyGenerator {
133    /// Create a new adapter.
134    ///
135    /// # Arguments
136    ///
137    /// - `strategy` — the replacement strategy to use.
138    /// - `mode` — how to produce entropy (deterministic seed or random).
139    #[must_use]
140    pub fn new(strategy: Box<dyn Strategy>, mode: EntropyMode) -> Self {
141        Self { strategy, mode }
142    }
143
144    /// Produce 32 bytes of entropy for `(category, original)`.
145    fn entropy(&self, category: &Category, original: &str) -> [u8; 32] {
146        match &self.mode {
147            EntropyMode::Deterministic { key } => {
148                type HmacSha256 = Hmac<Sha256>;
149                let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
150                let tag = category.domain_tag_hmac();
151                mac.update(tag.as_bytes());
152                mac.update(b"\x00");
153                mac.update(original.as_bytes());
154                let result = mac.finalize();
155                let mut out = [0u8; 32];
156                out.copy_from_slice(&result.into_bytes());
157                out
158            }
159            EntropyMode::Random => {
160                let mut buf = [0u8; 32];
161                rand::thread_rng().fill(&mut buf);
162                buf
163            }
164        }
165    }
166
167    /// Access the underlying strategy.
168    #[must_use]
169    pub fn strategy(&self) -> &dyn Strategy {
170        &*self.strategy
171    }
172}
173
174impl ReplacementGenerator for StrategyGenerator {
175    fn generate(&self, category: &Category, original: &str) -> String {
176        let entropy = self.entropy(category, original);
177        self.strategy.replace(original, &entropy)
178    }
179}
180
181// ===========================================================================
182// Built-in strategies
183// ===========================================================================
184
185// ---------------------------------------------------------------------------
186// 1. RandomString
187// ---------------------------------------------------------------------------
188
189/// Generates an alphanumeric string from entropy bytes.
190///
191/// The output length defaults to 16 characters but can be configured.
192/// Characters are drawn from `[a-zA-Z0-9]`.
193pub struct RandomString {
194    /// Desired output length (capped at 64).
195    len: usize,
196}
197
198impl RandomString {
199    /// Create with default length (16).
200    #[must_use]
201    pub fn new() -> Self {
202        Self { len: 16 }
203    }
204
205    /// Create with a specific output length (clamped to 1..=64).
206    #[must_use]
207    pub fn with_length(len: usize) -> Self {
208        Self {
209            len: len.clamp(1, 64),
210        }
211    }
212}
213
214impl Default for RandomString {
215    fn default() -> Self {
216        Self::new()
217    }
218}
219
220impl Strategy for RandomString {
221    fn name(&self) -> &'static str {
222        "random_string"
223    }
224
225    fn replace(&self, _original: &str, entropy: &[u8; 32]) -> String {
226        const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz\
227                                  ABCDEFGHIJKLMNOPQRSTUVWXYZ\
228                                  0123456789";
229        // Expand entropy with a simple deterministic PRNG seeded from the
230        // 32 entropy bytes (xorshift64 on 8-byte chunks).
231        let mut chars = String::with_capacity(self.len);
232        // Seed from all 32 entropy bytes via wrapping addition of 4 u64 chunks.
233        let mut state = 0u64;
234        for chunk in entropy.chunks_exact(8) {
235            state = state.wrapping_add(u64::from_le_bytes(chunk.try_into().unwrap()));
236        }
237        if state == 0 {
238            state = 0xDEAD_BEEF_CAFE_BABE; // avoid degenerate zero state
239        }
240
241        for _ in 0..self.len {
242            // xorshift64
243            state ^= state << 13;
244            state ^= state >> 7;
245            state ^= state << 17;
246            #[allow(clippy::cast_possible_truncation)]
247            // truncation is intentional for index mapping
248            let idx = (state as usize) % CHARSET.len();
249            chars.push(CHARSET[idx] as char);
250        }
251        chars
252    }
253}
254
255// ---------------------------------------------------------------------------
256// 2. RandomUuid
257// ---------------------------------------------------------------------------
258
259/// Generates a UUID v4–formatted string from entropy bytes.
260///
261/// The output looks like `xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx` where
262/// `x` is a hex digit derived from entropy and `y ∈ {8,9,a,b}` per RFC 4122.
263/// When backed by deterministic entropy, the UUID is stable.
264pub struct RandomUuid;
265
266impl RandomUuid {
267    #[must_use]
268    pub fn new() -> Self {
269        Self
270    }
271}
272
273impl Default for RandomUuid {
274    fn default() -> Self {
275        Self::new()
276    }
277}
278
279impl Strategy for RandomUuid {
280    fn name(&self) -> &'static str {
281        "random_uuid"
282    }
283
284    fn replace(&self, _original: &str, entropy: &[u8; 32]) -> String {
285        // Take the first 16 bytes to form a UUID.
286        let mut bytes = [0u8; 16];
287        bytes.copy_from_slice(&entropy[..16]);
288
289        // Set version = 4 (bits 4-7 of byte 6).
290        bytes[6] = (bytes[6] & 0x0F) | 0x40;
291        // Set variant = RFC 4122 (bits 6-7 of byte 8).
292        bytes[8] = (bytes[8] & 0x3F) | 0x80;
293
294        format!(
295            "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
296            bytes[0], bytes[1], bytes[2], bytes[3],
297            bytes[4], bytes[5],
298            bytes[6], bytes[7],
299            bytes[8], bytes[9],
300            bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
301        )
302    }
303}
304
305// ---------------------------------------------------------------------------
306// 3. FakeIp
307// ---------------------------------------------------------------------------
308
309/// Generates a fake IPv4 address in the `10.0.0.0/8` reserved range.
310///
311/// Avoids `.0` in the last octet to prevent confusion with network addresses.
312pub struct FakeIp;
313
314impl FakeIp {
315    #[must_use]
316    pub fn new() -> Self {
317        Self
318    }
319}
320
321impl Default for FakeIp {
322    fn default() -> Self {
323        Self::new()
324    }
325}
326
327impl Strategy for FakeIp {
328    fn name(&self) -> &'static str {
329        "fake_ip"
330    }
331
332    fn replace(&self, _original: &str, entropy: &[u8; 32]) -> String {
333        let a = entropy[0];
334        let b = entropy[1];
335        // Ensure last octet is ≥ 1 (avoid x.x.x.0).
336        let c = entropy[2].max(1);
337        format!("10.{}.{}.{}", a, b, c)
338    }
339}
340
341// ---------------------------------------------------------------------------
342// 4. PreserveLength
343// ---------------------------------------------------------------------------
344
345/// Generates a replacement with the **same byte length** as the original.
346///
347/// Useful when column widths, fixed-length fields, or alignment must be
348/// maintained. Uses lowercase hex characters derived from entropy.
349pub struct PreserveLength;
350
351impl PreserveLength {
352    #[must_use]
353    pub fn new() -> Self {
354        Self
355    }
356}
357
358impl Default for PreserveLength {
359    fn default() -> Self {
360        Self::new()
361    }
362}
363
364impl Strategy for PreserveLength {
365    fn name(&self) -> &'static str {
366        "preserve_length"
367    }
368
369    fn replace(&self, original: &str, entropy: &[u8; 32]) -> String {
370        const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
371
372        let target_len = original.len();
373        if target_len == 0 {
374            return String::new();
375        }
376
377        // Seed from all 32 entropy bytes via wrapping addition of 4 u64 chunks.
378        let mut state = 0u64;
379        for chunk in entropy.chunks_exact(8) {
380            state = state.wrapping_add(u64::from_le_bytes(chunk.try_into().unwrap()));
381        }
382        if state == 0 {
383            state = 0xCAFE_BABE_DEAD_BEEFu64;
384        }
385
386        let mut result = String::with_capacity(target_len);
387        for _ in 0..target_len {
388            state ^= state << 13;
389            state ^= state >> 7;
390            state ^= state << 17;
391            #[allow(clippy::cast_possible_truncation)]
392            // truncation is intentional for index mapping
393            let idx = (state as usize) % CHARSET.len();
394            result.push(CHARSET[idx] as char);
395        }
396        result
397    }
398}
399
400// ---------------------------------------------------------------------------
401// 5. HmacHash
402// ---------------------------------------------------------------------------
403
404/// HMAC-SHA256 hash strategy — deterministic by construction.
405///
406/// Unlike the other strategies, `HmacHash` carries its own 32-byte key and
407/// computes `HMAC-SHA256(key, original)` directly. The caller-provided
408/// entropy is **ignored**. This makes the output deterministic regardless
409/// of the [`EntropyMode`] used by [`StrategyGenerator`].
410///
411/// The output is a lowercase hex string, optionally truncated to
412/// `output_len` characters (default: 32).
413pub struct HmacHash {
414    key: [u8; 32],
415    /// Number of hex characters to emit (max 64).
416    output_len: usize,
417}
418
419impl HmacHash {
420    /// Create with both a key and a default output length (32 hex chars).
421    #[must_use]
422    pub fn new(key: [u8; 32]) -> Self {
423        Self {
424            key,
425            output_len: 32,
426        }
427    }
428
429    /// Create with a custom output length (clamped to 1..=64).
430    #[must_use]
431    pub fn with_output_len(key: [u8; 32], output_len: usize) -> Self {
432        Self {
433            key,
434            output_len: output_len.clamp(1, 64),
435        }
436    }
437}
438
439impl Strategy for HmacHash {
440    fn name(&self) -> &'static str {
441        "hmac_hash"
442    }
443
444    fn replace(&self, original: &str, _entropy: &[u8; 32]) -> String {
445        use std::fmt::Write;
446
447        type HmacSha256 = Hmac<Sha256>;
448        let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
449        mac.update(original.as_bytes());
450        let result = mac.finalize();
451        let hash_bytes: [u8; 32] = {
452            let mut buf = [0u8; 32];
453            buf.copy_from_slice(&result.into_bytes());
454            buf
455        };
456        let mut hex = String::with_capacity(64);
457        for b in &hash_bytes {
458            let _ = write!(hex, "{:02x}", b);
459        }
460        hex[..self.output_len].to_string()
461    }
462}
463
464// ===========================================================================
465// Tests
466// ===========================================================================
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471    use crate::category::Category;
472    use std::sync::Arc;
473
474    /// Helper: fixed deterministic entropy for testing.
475    fn test_entropy() -> [u8; 32] {
476        let mut e = [0u8; 32];
477        for (i, b) in e.iter_mut().enumerate() {
478            #[allow(clippy::cast_possible_truncation)] // i is always < 32, fits in u8
479            {
480                *b = (i as u8).wrapping_mul(37).wrapping_add(7);
481            }
482        }
483        e
484    }
485
486    // ---- Strategy trait: purity / determinism ----
487
488    #[test]
489    fn strategies_are_deterministic() {
490        let entropy = test_entropy();
491        let strategies: Vec<Box<dyn Strategy>> = vec![
492            Box::new(RandomString::new()),
493            Box::new(RandomUuid::new()),
494            Box::new(FakeIp::new()),
495            Box::new(PreserveLength::new()),
496            Box::new(HmacHash::new([42u8; 32])),
497        ];
498        for s in &strategies {
499            let a = s.replace("hello world", &entropy);
500            let b = s.replace("hello world", &entropy);
501            assert_eq!(a, b, "strategy '{}' must be deterministic", s.name());
502        }
503    }
504
505    #[test]
506    fn different_entropy_different_output() {
507        let e1 = [1u8; 32];
508        let e2 = [2u8; 32];
509        let strategies: Vec<Box<dyn Strategy>> = vec![
510            Box::new(RandomString::new()),
511            Box::new(RandomUuid::new()),
512            Box::new(FakeIp::new()),
513            Box::new(PreserveLength::new()),
514        ];
515        for s in &strategies {
516            let a = s.replace("test", &e1);
517            let b = s.replace("test", &e2);
518            assert_ne!(
519                a,
520                b,
521                "strategy '{}' should differ with different entropy",
522                s.name()
523            );
524        }
525    }
526
527    // ---- RandomString ----
528
529    #[test]
530    fn random_string_default_length() {
531        let s = RandomString::new();
532        let out = s.replace("anything", &test_entropy());
533        assert_eq!(out.len(), 16);
534        assert!(
535            out.chars().all(|c| c.is_ascii_alphanumeric()),
536            "output must be alphanumeric: {}",
537            out,
538        );
539    }
540
541    #[test]
542    fn random_string_custom_length() {
543        let s = RandomString::with_length(8);
544        let out = s.replace("anything", &test_entropy());
545        assert_eq!(out.len(), 8);
546    }
547
548    #[test]
549    fn random_string_clamped_length() {
550        let s = RandomString::with_length(999);
551        assert_eq!(s.len, 64);
552        let s = RandomString::with_length(0);
553        assert_eq!(s.len, 1);
554    }
555
556    // ---- RandomUuid ----
557
558    #[test]
559    fn random_uuid_format() {
560        let s = RandomUuid::new();
561        let out = s.replace("anything", &test_entropy());
562        // 8-4-4-4-12 = 36 chars
563        assert_eq!(out.len(), 36, "UUID must be 36 chars: {}", out);
564        let parts: Vec<&str> = out.split('-').collect();
565        assert_eq!(parts.len(), 5);
566        assert_eq!(parts[0].len(), 8);
567        assert_eq!(parts[1].len(), 4);
568        assert_eq!(parts[2].len(), 4);
569        assert_eq!(parts[3].len(), 4);
570        assert_eq!(parts[4].len(), 12);
571        // Version nibble = 4
572        assert_eq!(&parts[2][0..1], "4", "version must be 4");
573        // Variant nibble ∈ {8,9,a,b}
574        let variant = &parts[3][0..1];
575        assert!(
576            ["8", "9", "a", "b"].contains(&variant),
577            "variant nibble must be 8/9/a/b, got {}",
578            variant,
579        );
580    }
581
582    // ---- FakeIp ----
583
584    #[test]
585    fn fake_ip_format() {
586        let s = FakeIp::new();
587        let out = s.replace("192.168.1.1", &test_entropy());
588        assert!(out.starts_with("10."), "must be in 10.0.0.0/8: {}", out);
589        let octets: Vec<&str> = out.split('.').collect();
590        assert_eq!(octets.len(), 4);
591        for octet in &octets {
592            let _n: u8 = octet.parse().expect("octet must be a valid u8");
593        }
594        // Last octet ≥ 1.
595        let last: u8 = octets[3].parse().unwrap();
596        assert!(last >= 1, "last octet must be ≥ 1");
597    }
598
599    // ---- PreserveLength ----
600
601    #[test]
602    fn preserve_length_matches() {
603        let s = PreserveLength::new();
604        for input in &["a", "hello", "this is a fairly long string indeed", ""] {
605            let out = s.replace(input, &test_entropy());
606            assert_eq!(
607                out.len(),
608                input.len(),
609                "length mismatch for input '{}'",
610                input,
611            );
612        }
613    }
614
615    #[test]
616    fn preserve_length_characters() {
617        let s = PreserveLength::new();
618        let out = s.replace("hello!", &test_entropy());
619        assert!(
620            out.chars().all(|c| c.is_ascii_alphanumeric()),
621            "output must be alphanumeric: {}",
622            out,
623        );
624    }
625
626    // ---- HmacHash ----
627
628    #[test]
629    fn hmac_hash_deterministic_with_key() {
630        let s = HmacHash::new([42u8; 32]);
631        let a = s.replace("secret", &[0u8; 32]);
632        let b = s.replace("secret", &[0xFF; 32]);
633        // Entropy is ignored — result depends only on key + original.
634        assert_eq!(a, b, "HmacHash must ignore entropy");
635    }
636
637    #[test]
638    fn hmac_hash_default_length() {
639        let s = HmacHash::new([0u8; 32]);
640        let out = s.replace("test", &[0u8; 32]);
641        assert_eq!(out.len(), 32, "default output is 32 hex chars");
642        assert!(
643            out.chars().all(|c| c.is_ascii_hexdigit()),
644            "output must be hex: {}",
645            out,
646        );
647    }
648
649    #[test]
650    fn hmac_hash_custom_length() {
651        let s = HmacHash::with_output_len([0u8; 32], 12);
652        let out = s.replace("test", &[0u8; 32]);
653        assert_eq!(out.len(), 12);
654    }
655
656    #[test]
657    fn hmac_hash_different_keys() {
658        let s1 = HmacHash::new([1u8; 32]);
659        let s2 = HmacHash::new([2u8; 32]);
660        let a = s1.replace("test", &[0u8; 32]);
661        let b = s2.replace("test", &[0u8; 32]);
662        assert_ne!(a, b, "different keys must produce different output");
663    }
664
665    #[test]
666    fn hmac_hash_different_inputs() {
667        let s = HmacHash::new([42u8; 32]);
668        let a = s.replace("alice", &[0u8; 32]);
669        let b = s.replace("bob", &[0u8; 32]);
670        assert_ne!(a, b);
671    }
672
673    // ---- StrategyGenerator integration ----
674
675    #[test]
676    fn strategy_generator_deterministic() {
677        let strat = Box::new(RandomString::new());
678        let gen = StrategyGenerator::new(strat, EntropyMode::Deterministic { key: [42u8; 32] });
679        let a = gen.generate(&Category::Email, "alice@corp.com");
680        let b = gen.generate(&Category::Email, "alice@corp.com");
681        assert_eq!(a, b, "deterministic mode must be repeatable");
682    }
683
684    #[test]
685    fn strategy_generator_different_categories() {
686        let strat = Box::new(RandomString::new());
687        let gen = StrategyGenerator::new(strat, EntropyMode::Deterministic { key: [42u8; 32] });
688        let a = gen.generate(&Category::Email, "test");
689        let b = gen.generate(&Category::Name, "test");
690        assert_ne!(a, b, "different categories must produce different entropy");
691    }
692
693    #[test]
694    fn strategy_generator_with_store() {
695        let strat = Box::new(RandomUuid::new());
696        let gen = Arc::new(StrategyGenerator::new(
697            strat,
698            EntropyMode::Deterministic { key: [99u8; 32] },
699        ));
700        let store = crate::store::MappingStore::new(gen, None);
701
702        let s1 = store
703            .get_or_insert(&Category::Email, "alice@corp.com")
704            .unwrap();
705        let s2 = store
706            .get_or_insert(&Category::Email, "alice@corp.com")
707            .unwrap();
708        assert_eq!(s1, s2, "store must cache strategy output");
709        assert_eq!(s1.len(), 36, "output must be UUID-formatted");
710    }
711
712    #[test]
713    fn strategy_generator_random_cached_in_store() {
714        let strat = Box::new(FakeIp::new());
715        let gen = Arc::new(StrategyGenerator::new(strat, EntropyMode::Random));
716        let store = crate::store::MappingStore::new(gen, None);
717
718        let s1 = store.get_or_insert(&Category::IpV4, "192.168.1.1").unwrap();
719        let s2 = store.get_or_insert(&Category::IpV4, "192.168.1.1").unwrap();
720        // Random entropy, but store caches first result.
721        assert_eq!(s1, s2);
722        assert!(s1.starts_with("10."));
723    }
724
725    #[test]
726    fn all_strategies_implement_send_sync() {
727        fn assert_send_sync<T: Send + Sync>() {}
728        assert_send_sync::<RandomString>();
729        assert_send_sync::<RandomUuid>();
730        assert_send_sync::<FakeIp>();
731        assert_send_sync::<PreserveLength>();
732        assert_send_sync::<HmacHash>();
733        assert_send_sync::<StrategyGenerator>();
734    }
735
736    #[test]
737    fn strategy_names_unique() {
738        let strategies: Vec<Box<dyn Strategy>> = vec![
739            Box::new(RandomString::new()),
740            Box::new(RandomUuid::new()),
741            Box::new(FakeIp::new()),
742            Box::new(PreserveLength::new()),
743            Box::new(HmacHash::new([0u8; 32])),
744        ];
745        let mut names: Vec<&str> = strategies.iter().map(|s| s.name()).collect();
746        let len_before = names.len();
747        names.sort_unstable();
748        names.dedup();
749        assert_eq!(names.len(), len_before, "strategy names must be unique");
750    }
751
752    // ---- Concurrent use via StrategyGenerator + MappingStore ----
753
754    #[test]
755    fn concurrent_strategy_generator() {
756        use std::thread;
757
758        let strat = Box::new(PreserveLength::new());
759        let gen = Arc::new(StrategyGenerator::new(
760            strat,
761            EntropyMode::Deterministic { key: [7u8; 32] },
762        ));
763        let store = Arc::new(crate::store::MappingStore::new(gen, None));
764
765        let mut handles = vec![];
766        for t in 0..4 {
767            let store = Arc::clone(&store);
768            handles.push(thread::spawn(move || {
769                for i in 0..500 {
770                    let val = format!("thread{}-val{}", t, i);
771                    let result = store.get_or_insert(&Category::Name, &val).unwrap();
772                    assert_eq!(
773                        result.len(),
774                        val.len(),
775                        "PreserveLength must match input length",
776                    );
777                }
778            }));
779        }
780        for h in handles {
781            h.join().unwrap();
782        }
783        assert_eq!(store.len(), 2000);
784    }
785}