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