Skip to main content

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