Skip to main content

rust_sanitize/
store.rs

1//! Thread-safe, concurrent one-way replacement store.
2//!
3//! # Concurrency Model
4//!
5//! The store uses [`dashmap::DashMap`] — a concurrent hash map with shard-level
6//! locking (default 64 shards). This gives us:
7//!
8//! - **Lock-free reads** for lookups of already-mapped values.
9//! - **Shard-level write locks** that are held only while inserting a new entry.
10//!   With 64 shards and 8–16 threads, the probability of two threads contending
11//!   on the same shard is very low.
12//! - **Atomic get-or-insert** via the `entry()` API, which prevents TOCTOU races
13//!   and guarantees first-writer-wins semantics.
14//!
15//! # Structure
16//!
17//! The forward map is two-level: `Category → original → sanitized`.
18//!
19//! ```text
20//! DashMap<Category, Arc<DashMap<ZeroizingString, (CompactString, usize)>>>
21//!    outer (~20 entries, always hot in cache)
22//!               └── inner (one per category, holds the actual values)
23//! ```
24//!
25//! This lets the fast-path read call `inner.get(original: &str)` without
26//! constructing a temporary `String`, because `ZeroizingString: Borrow<str>`.
27//! For files where the same value appears thousands of times, this eliminates
28//! thousands of `malloc`/`free` cycles on the hot path.
29//!
30//! Replacements are **one-way only** — there is no reverse map, no mapping
31//! file, and no restore capability.
32//!
33//! # Memory Characteristics
34//!
35//! At 10M unique values with average key length 20 bytes and average value
36//! length 30 bytes:
37//! - Forward map: 10M × (20 + 30 + ~120 DashMap overhead) ≈ 1.7 GB
38//! - **Total: ~1.7 GB** — acceptable for server workloads.
39//!
40//! An optional `capacity_limit` can be set to prevent unbounded growth.
41
42use crate::allowlist::AllowlistMatcher;
43use crate::category::Category;
44use crate::error::{Result, SanitizeError};
45use crate::generator::ReplacementGenerator;
46use compact_str::CompactString;
47use dashmap::DashMap;
48use std::borrow::Borrow;
49use std::sync::atomic::{AtomicUsize, Ordering};
50use std::sync::Arc;
51use zeroize::Zeroize;
52
53// ---------------------------------------------------------------------------
54// ZeroizingString — map key for the inner (per-category) DashMap
55// ---------------------------------------------------------------------------
56
57/// A `String` that zeroizes its heap buffer on drop.
58///
59/// `Zeroizing<String>` from the `zeroize` crate does not implement `Hash`,
60/// so it cannot be used as a `HashMap` key. This newtype adds `Hash` while
61/// keeping the zeroize-on-drop guarantee via an explicit `Drop` impl.
62///
63/// Implementing `Borrow<str>` allows `DashMap<ZeroizingString, _>::get(s: &str)`
64/// to work without constructing a temporary `ZeroizingString` — the key insight
65/// that makes the fast-path read allocation-free.
66#[derive(Debug, Clone, PartialEq, Eq)]
67struct ZeroizingString(String);
68
69impl std::hash::Hash for ZeroizingString {
70    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
71        self.0.hash(state);
72    }
73}
74
75impl Drop for ZeroizingString {
76    fn drop(&mut self) {
77        self.0.zeroize();
78    }
79}
80
81/// Enables `DashMap<ZeroizingString, _>::get(s: &str)` — zero allocation on
82/// cache hits. Correct because `ZeroizingString` delegates `Hash` and `Eq`
83/// to its inner `String`, which is consistent with `str`'s `Hash` and `Eq`.
84impl Borrow<str> for ZeroizingString {
85    fn borrow(&self) -> &str {
86        &self.0
87    }
88}
89
90// ---------------------------------------------------------------------------
91// Convenience type alias for the inner map
92// ---------------------------------------------------------------------------
93
94type InnerMap = DashMap<ZeroizingString, (CompactString, usize)>;
95
96// ---------------------------------------------------------------------------
97// MappingStore
98// ---------------------------------------------------------------------------
99
100/// Thread-safe concurrent one-way replacement store.
101///
102/// Caches forward mappings for per-run consistency (same input always
103/// produces the same output within a run). There is no reverse map,
104/// no journal, and no persistence — replacements are one-way only.
105///
106/// See the [module-level documentation](self) for concurrency and memory details.
107pub struct MappingStore {
108    /// `category → original → (sanitized, insertion_index)`
109    ///
110    /// Two-level map: outer is keyed by `Category` (tiny, always in cache),
111    /// inner is keyed by `ZeroizingString` (actual values). The inner map is
112    /// behind an `Arc` so it can be obtained without holding the outer shard
113    /// lock during inner map operations.
114    forward: DashMap<Category, Arc<InnerMap>>,
115    /// Replacement generator (HMAC deterministic or CSPRNG random).
116    generator: Arc<dyn ReplacementGenerator>,
117    /// Current number of mappings (atomic for lock-free reads).
118    len: AtomicUsize,
119    /// Optional upper bound on the number of mappings.
120    capacity_limit: Option<usize>,
121    /// Optional allowlist — matched values pass through unchanged and are
122    /// not recorded in the forward map.
123    allowlist: Option<Arc<AllowlistMatcher>>,
124}
125
126impl MappingStore {
127    // ---------------- Construction ----------------
128
129    /// Create a new, empty mapping store.
130    ///
131    /// # Arguments
132    ///
133    /// - `generator` — replacement strategy (HMAC or random).
134    /// - `capacity_limit` — optional max number of unique mappings.
135    #[must_use]
136    pub fn new(generator: Arc<dyn ReplacementGenerator>, capacity_limit: Option<usize>) -> Self {
137        Self {
138            forward: DashMap::with_capacity(32),
139            generator,
140            len: AtomicUsize::new(0),
141            capacity_limit,
142            allowlist: None,
143        }
144    }
145
146    /// Create a new store with an allowlist. Values matching the allowlist
147    /// are returned unchanged and never recorded in the forward map.
148    #[must_use]
149    pub fn new_with_allowlist(
150        generator: Arc<dyn ReplacementGenerator>,
151        capacity_limit: Option<usize>,
152        allowlist: Arc<AllowlistMatcher>,
153    ) -> Self {
154        Self {
155            forward: DashMap::with_capacity(32),
156            generator,
157            len: AtomicUsize::new(0),
158            capacity_limit,
159            allowlist: Some(allowlist),
160        }
161    }
162
163    /// Return the allowlist attached to this store, if any.
164    pub fn allowlist(&self) -> Option<&AllowlistMatcher> {
165        self.allowlist.as_deref()
166    }
167
168    // ---------------- Core API ----------------
169
170    /// Get or create the sanitized replacement for `(category, original)`.
171    ///
172    /// This is the primary API for one-way sanitization.
173    ///
174    /// **Hot-path allocation:** When the value is already cached, this method
175    /// is allocation-free. The inner `DashMap::get` accepts `&str` directly via
176    /// `ZeroizingString: Borrow<str>`, so no temporary `String` is constructed.
177    ///
178    /// **Thread-safety:** Uses `DashMap::entry()` which holds a shard-level
179    /// lock only for the duration of the insert closure. The generator is
180    /// called inside the lock, but generation is fast (one HMAC or one RNG
181    /// call). Capacity enforcement uses `compare_exchange` to prevent
182    /// TOCTOU over-insertion.
183    ///
184    /// **Per-run consistency:** Once a value is mapped, all subsequent
185    /// lookups return the same sanitized value (first-writer-wins).
186    ///
187    /// # Errors
188    ///
189    /// Returns [`SanitizeError::CapacityExceeded`] if the store has
190    /// reached its configured capacity limit.
191    pub fn get_or_insert(&self, category: &Category, original: &str) -> Result<CompactString> {
192        // Allowlist check: return the original value unchanged without recording it.
193        if let Some(al) = &self.allowlist {
194            if al.is_allowed(original) {
195                return Ok(CompactString::new(original));
196            }
197        }
198
199        // Fast path: already mapped — zero allocation.
200        // `inner.get(original)` accepts `&str` via `ZeroizingString: Borrow<str>`.
201        // Clone the Arc while we already hold the outer shard reference so the
202        // slow path below never needs to acquire the outer shard a second time.
203        let inner: Arc<InnerMap> = match self.forward.get(category) {
204            Some(outer) => {
205                if let Some(existing) = outer.value().get(original) {
206                    return Ok(existing.value().0.clone());
207                }
208                outer.value().clone()
209            }
210            None => self
211                .forward
212                .entry(category.clone())
213                .or_insert_with(|| Arc::new(DashMap::new()))
214                .value()
215                .clone(),
216        };
217
218        if let Some(limit) = self.capacity_limit {
219            // Atomically reserve a capacity slot *before* generating the value.
220            // This eliminates the TOCTOU race where multiple threads pass the
221            // capacity check and all insert.
222            //
223            // insertion_index is set to `current` (the pre-increment value) from
224            // the successful CAS — not from a separate load after the loop, which
225            // could observe a higher count from a concurrent inserter and assign
226            // the wrong monotonic position to this entry.
227            let insertion_index;
228            loop {
229                let current = self.len.load(Ordering::Acquire);
230                if current >= limit {
231                    // One more chance: key may have been inserted by another thread.
232                    if let Some(existing) = inner.get(original) {
233                        return Ok(existing.value().0.clone());
234                    }
235                    return Err(SanitizeError::CapacityExceeded { current, limit });
236                }
237                if self
238                    .len
239                    .compare_exchange_weak(
240                        current,
241                        current + 1,
242                        Ordering::AcqRel,
243                        Ordering::Acquire,
244                    )
245                    .is_ok()
246                {
247                    insertion_index = current;
248                    break;
249                }
250                // CAS failed → another thread incremented; retry.
251            }
252
253            // Slot reserved — generate and insert (first-writer-wins).
254            let mut was_inserted = false;
255            let result = inner
256                .entry(ZeroizingString(original.to_owned()))
257                .or_insert_with(|| {
258                    was_inserted = true;
259                    let val = self.generator.generate(category, original);
260                    (CompactString::new(val), insertion_index)
261                })
262                .value()
263                .0
264                .clone();
265
266            if !was_inserted {
267                // Another thread inserted first — release our reserved slot.
268                self.len.fetch_sub(1, Ordering::Release);
269            }
270
271            Ok(result)
272        } else {
273            // No capacity limit — generate inside the entry lock so only the
274            // first writer calls the generator (first-writer-wins semantics).
275            let result = inner
276                .entry(ZeroizingString(original.to_owned()))
277                .or_insert_with(|| {
278                    let insertion_index = self.len.fetch_add(1, Ordering::AcqRel);
279                    let val = self.generator.generate(category, original);
280                    (CompactString::new(val), insertion_index)
281                })
282                .value()
283                .0
284                .clone();
285
286            Ok(result)
287        }
288    }
289
290    /// Look up an existing forward mapping without creating one.
291    #[must_use]
292    pub fn forward_lookup(&self, category: &Category, original: &str) -> Option<CompactString> {
293        let inner = self.forward.get(category)?;
294        inner.value().get(original).map(|r| r.value().0.clone())
295    }
296
297    // ---------------- Metrics ----------------
298
299    /// Number of unique mappings in the store.
300    #[must_use]
301    pub fn len(&self) -> usize {
302        self.len.load(Ordering::Relaxed)
303    }
304
305    /// Whether the store is empty.
306    #[must_use]
307    pub fn is_empty(&self) -> bool {
308        self.len() == 0
309    }
310
311    /// Remove all mappings, zeroizing the original plaintexts.
312    ///
313    /// This is useful for resetting the store between runs without
314    /// dropping and recreating it.
315    pub fn clear(&mut self) {
316        // Dropping the map entries triggers ZeroizingString::drop on each inner key.
317        // If any cloned Arcs are still live (e.g., in a concurrent thread's stack),
318        // those inner maps survive until the last Arc drops — but `clear` is only
319        // called after all workers have finished, so this is safe in practice.
320        drop(std::mem::take(&mut self.forward));
321        self.len.store(0, Ordering::Release);
322    }
323
324    // ---------------- Snapshot / diff (for format-preserving pass) ----------------
325
326    /// Snapshot the current insertion count.
327    ///
328    /// Returns an opaque `usize` that can be passed to [`Self::iter_since`] to
329    /// iterate only the entries added *after* this point — useful for
330    /// finding which mappings a structured processor pass discovered without
331    /// building a full `HashSet` of all existing keys.
332    ///
333    /// O(1), no allocation.
334    #[must_use]
335    pub fn snapshot(&self) -> usize {
336        self.len.load(Ordering::Acquire)
337    }
338
339    /// Iterate over entries added at or after the given snapshot.
340    ///
341    /// `snapshot` is the value returned by a previous call to [`Self::snapshot`].
342    /// Entries whose insertion index is ≥ `snapshot` are yielded; older
343    /// entries are skipped. Still O(n) in total store size, but avoids
344    /// allocating a `HashSet` of all prior keys.
345    ///
346    /// Implementation note: the inner `.collect::<Vec<_>>()` inside the
347    /// `flat_map` is required to release the DashMap shard lock before
348    /// yielding items — it allocates one `Vec` per category shard visited.
349    pub fn iter_since(
350        &self,
351        snapshot: usize,
352    ) -> impl Iterator<Item = (Category, CompactString, CompactString)> + '_ {
353        self.forward.iter().flat_map(move |outer| {
354            let cat = outer.key().clone();
355            outer
356                .value()
357                .iter()
358                .filter_map(move |inner| {
359                    let (sanitized, idx) = inner.value();
360                    if *idx >= snapshot {
361                        Some((
362                            cat.clone(),
363                            CompactString::new(inner.key().0.as_str()),
364                            sanitized.clone(),
365                        ))
366                    } else {
367                        None
368                    }
369                })
370                .collect::<Vec<_>>()
371        })
372    }
373
374    // ---------------- Iteration (for external use) ----------------
375
376    /// Iterate over all mappings. Yields `(category, original, sanitized)`.
377    ///
378    /// Note: iteration over `DashMap` is not snapshot-consistent if concurrent
379    /// inserts are happening. Call this after all workers have finished.
380    ///
381    /// Implementation note: allocates one `Vec` per category shard to release
382    /// the DashMap shard lock between categories.
383    pub fn iter(&self) -> impl Iterator<Item = (Category, CompactString, CompactString)> + '_ {
384        self.forward.iter().flat_map(|outer| {
385            let cat = outer.key().clone();
386            outer
387                .value()
388                .iter()
389                .map(move |inner| {
390                    (
391                        cat.clone(),
392                        CompactString::new(inner.key().0.as_str()),
393                        inner.value().0.clone(),
394                    )
395                })
396                .collect::<Vec<_>>()
397        })
398    }
399}
400
401/// Zeroize original keys stored in the forward map on drop.
402/// Dropping the outer `DashMap` triggers `Arc::drop` for each inner map; when
403/// the last Arc drops, `ZeroizingString::drop` runs for every key, overwriting
404/// the plaintext before the memory is freed.
405impl Drop for MappingStore {
406    fn drop(&mut self) {
407        drop(std::mem::take(&mut self.forward));
408    }
409}
410
411/// Compile-time assertion that a type is `Send + Sync`.
412macro_rules! static_assertions_send_sync {
413    ($t:ty) => {
414        const _: fn() = || {
415            fn assert_send<T: Send>() {}
416            fn assert_sync<T: Sync>() {}
417            assert_send::<$t>();
418            assert_sync::<$t>();
419        };
420    };
421}
422
423static_assertions_send_sync!(MappingStore);
424
425// ---------------------------------------------------------------------------
426// Unit tests
427// ---------------------------------------------------------------------------
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use crate::generator::{HmacGenerator, RandomGenerator};
433    use std::sync::Arc;
434
435    fn hmac_store(limit: Option<usize>) -> MappingStore {
436        let gen = Arc::new(HmacGenerator::new([42u8; 32]));
437        MappingStore::new(gen, limit)
438    }
439
440    fn random_store() -> MappingStore {
441        let gen = Arc::new(RandomGenerator::new());
442        MappingStore::new(gen, None)
443    }
444
445    // --- Basic operations ---
446
447    #[test]
448    fn insert_and_lookup() {
449        let store = hmac_store(None);
450        let s1 = store
451            .get_or_insert(&Category::Email, "alice@corp.com")
452            .unwrap();
453        assert!(!s1.is_empty());
454        assert!(s1.contains("@corp.com"), "domain must be preserved");
455        assert_eq!(s1.len(), "alice@corp.com".len(), "length must be preserved");
456        assert_eq!(store.len(), 1);
457    }
458
459    #[test]
460    fn same_input_same_output() {
461        let store = hmac_store(None);
462        let s1 = store
463            .get_or_insert(&Category::Email, "alice@corp.com")
464            .unwrap();
465        let s2 = store
466            .get_or_insert(&Category::Email, "alice@corp.com")
467            .unwrap();
468        assert_eq!(s1, s2, "repeated insert must return cached value");
469        assert_eq!(store.len(), 1, "no duplicate entry");
470    }
471
472    #[test]
473    fn different_inputs_different_outputs() {
474        let store = hmac_store(None);
475        let s1 = store
476            .get_or_insert(&Category::Email, "alice@corp.com")
477            .unwrap();
478        let s2 = store
479            .get_or_insert(&Category::Email, "bob@corp.com")
480            .unwrap();
481        assert_ne!(s1, s2);
482        assert_eq!(store.len(), 2);
483    }
484
485    #[test]
486    fn different_categories_different_outputs() {
487        let store = hmac_store(None);
488        let s1 = store.get_or_insert(&Category::Email, "test").unwrap();
489        let s2 = store.get_or_insert(&Category::Name, "test").unwrap();
490        assert_ne!(s1, s2);
491    }
492
493    #[test]
494    fn forward_lookup_works() {
495        let store = hmac_store(None);
496        let sanitized = store.get_or_insert(&Category::IpV4, "192.168.1.1").unwrap();
497        let found = store.forward_lookup(&Category::IpV4, "192.168.1.1");
498        assert_eq!(found, Some(sanitized));
499    }
500
501    #[test]
502    fn forward_lookup_missing() {
503        let store = hmac_store(None);
504        assert!(store.forward_lookup(&Category::Email, "nope").is_none());
505    }
506
507    // --- Capacity limit ---
508
509    #[test]
510    fn capacity_limit_enforced() {
511        let store = hmac_store(Some(2));
512        store.get_or_insert(&Category::Email, "a@a.com").unwrap();
513        store.get_or_insert(&Category::Email, "b@b.com").unwrap();
514        let result = store.get_or_insert(&Category::Email, "c@c.com");
515        assert!(result.is_err());
516        match result.unwrap_err() {
517            SanitizeError::CapacityExceeded {
518                current: 2,
519                limit: 2,
520            } => {}
521            other => panic!("unexpected error: {:?}", other),
522        }
523    }
524
525    #[test]
526    fn capacity_limit_allows_duplicate() {
527        let store = hmac_store(Some(1));
528        store.get_or_insert(&Category::Email, "a@a.com").unwrap();
529        // Re-inserting same value should succeed (fast path).
530        let s2 = store.get_or_insert(&Category::Email, "a@a.com").unwrap();
531        assert!(!s2.is_empty());
532    }
533
534    // --- Random generator within store ---
535
536    #[test]
537    fn random_store_caches() {
538        let store = random_store();
539        let s1 = store
540            .get_or_insert(&Category::Email, "alice@corp.com")
541            .unwrap();
542        let s2 = store
543            .get_or_insert(&Category::Email, "alice@corp.com")
544            .unwrap();
545        assert_eq!(s1, s2, "random store must still cache the first result");
546    }
547
548    // --- Iteration ---
549
550    #[test]
551    fn iter_yields_all_mappings() {
552        let store = hmac_store(None);
553        store.get_or_insert(&Category::Email, "a@a.com").unwrap();
554        store.get_or_insert(&Category::IpV4, "1.2.3.4").unwrap();
555        let collected: Vec<_> = store.iter().collect();
556        assert_eq!(collected.len(), 2);
557    }
558
559    // --- Concurrent inserts (basic smoke test) ---
560
561    #[test]
562    fn concurrent_inserts_no_panic() {
563        use std::sync::Arc;
564        use std::thread;
565
566        let gen = Arc::new(HmacGenerator::new([99u8; 32]));
567        let store = Arc::new(MappingStore::new(gen, None));
568
569        let mut handles = vec![];
570        for t in 0..8 {
571            let store = Arc::clone(&store);
572            handles.push(thread::spawn(move || {
573                for i in 0..1000 {
574                    let val = format!("thread{}-val{}", t, i);
575                    store.get_or_insert(&Category::Email, &val).unwrap();
576                }
577            }));
578        }
579
580        for h in handles {
581            h.join().unwrap();
582        }
583
584        assert_eq!(store.len(), 8000);
585    }
586
587    #[test]
588    fn concurrent_inserts_same_key_idempotent() {
589        use std::sync::Arc;
590        use std::thread;
591
592        let gen = Arc::new(HmacGenerator::new([7u8; 32]));
593        let store = Arc::new(MappingStore::new(gen, None));
594
595        let mut handles = vec![];
596        for _ in 0..8 {
597            let store = Arc::clone(&store);
598            handles.push(thread::spawn(move || {
599                let mut results = Vec::new();
600                for i in 0..100 {
601                    let val = format!("shared-{}", i);
602                    let r = store.get_or_insert(&Category::Email, &val).unwrap();
603                    results.push((val, r));
604                }
605                results
606            }));
607        }
608
609        let mut all_results: Vec<Vec<(String, CompactString)>> = vec![];
610        for h in handles {
611            all_results.push(h.join().unwrap());
612        }
613
614        // All threads must agree on every mapping.
615        assert_eq!(store.len(), 100);
616        for i in 0..100 {
617            let val = format!("shared-{}", i);
618            let expected = store.forward_lookup(&Category::Email, &val).unwrap();
619            for thread_results in &all_results {
620                let (_, got) = &thread_results[i];
621                assert_eq!(
622                    got, &expected,
623                    "all threads must see the same mapping for {}",
624                    val
625                );
626            }
627        }
628    }
629
630    // --- is_empty / clear ---
631
632    #[test]
633    fn is_empty_on_new_store() {
634        let store = hmac_store(None);
635        assert!(store.is_empty());
636    }
637
638    #[test]
639    fn is_empty_false_after_insert() {
640        let store = hmac_store(None);
641        store.get_or_insert(&Category::Email, "a@a.com").unwrap();
642        assert!(!store.is_empty());
643    }
644
645    #[test]
646    fn clear_resets_store() {
647        let mut store = hmac_store(None);
648        store.get_or_insert(&Category::Email, "a@a.com").unwrap();
649        store.get_or_insert(&Category::IpV4, "1.2.3.4").unwrap();
650        assert_eq!(store.len(), 2);
651        store.clear();
652        assert_eq!(store.len(), 0);
653        assert!(store.is_empty());
654    }
655
656    #[test]
657    fn clear_then_reinsert_works() {
658        let mut store = hmac_store(None);
659        store.get_or_insert(&Category::Email, "a@a.com").unwrap();
660        store.clear();
661        let result = store.get_or_insert(&Category::Email, "a@a.com");
662        assert!(result.is_ok());
663        assert_eq!(store.len(), 1);
664    }
665
666    // --- snapshot / iter_since ---
667
668    #[test]
669    fn snapshot_and_iter_since_yields_only_new() {
670        let store = hmac_store(None);
671        store.get_or_insert(&Category::Email, "old@a.com").unwrap();
672        let snap = store.snapshot();
673        store.get_or_insert(&Category::IpV4, "1.2.3.4").unwrap();
674        store.get_or_insert(&Category::Name, "Alice").unwrap();
675
676        let new_entries: Vec<_> = store.iter_since(snap).collect();
677        assert_eq!(new_entries.len(), 2);
678        // None of the new entries should be the pre-snapshot email.
679        assert!(!new_entries
680            .iter()
681            .any(|(cat, orig, _)| { *cat == Category::Email && orig.as_str() == "old@a.com" }));
682    }
683
684    #[test]
685    fn iter_since_zero_yields_all() {
686        let store = hmac_store(None);
687        store.get_or_insert(&Category::Email, "a@a.com").unwrap();
688        store.get_or_insert(&Category::IpV4, "1.2.3.4").unwrap();
689        let all: Vec<_> = store.iter_since(0).collect();
690        assert_eq!(all.len(), 2);
691    }
692
693    #[test]
694    fn iter_since_at_end_yields_nothing() {
695        let store = hmac_store(None);
696        store.get_or_insert(&Category::Email, "a@a.com").unwrap();
697        let snap = store.snapshot();
698        let new: Vec<_> = store.iter_since(snap).collect();
699        assert!(new.is_empty());
700    }
701
702    // --- new_with_allowlist ---
703
704    #[test]
705    fn allowlist_passes_value_through_unchanged() {
706        use crate::allowlist::AllowlistMatcher;
707        let (matcher, _) =
708            AllowlistMatcher::new(vec!["localhost".to_string(), "127.0.0.1".to_string()]);
709        let gen = Arc::new(HmacGenerator::new([42u8; 32]));
710        let store = MappingStore::new_with_allowlist(gen, None, Arc::new(matcher));
711
712        assert!(store.allowlist().is_some());
713
714        // Allowlisted value must be returned verbatim.
715        let result = store
716            .get_or_insert(&Category::Hostname, "localhost")
717            .unwrap();
718        assert_eq!(result.as_str(), "localhost");
719    }
720
721    #[test]
722    fn allowlist_still_replaces_non_listed() {
723        use crate::allowlist::AllowlistMatcher;
724        let (matcher, _) = AllowlistMatcher::new(vec!["localhost".to_string()]);
725        let gen = Arc::new(HmacGenerator::new([42u8; 32]));
726        let store = MappingStore::new_with_allowlist(gen, None, Arc::new(matcher));
727
728        let result = store
729            .get_or_insert(&Category::Hostname, "prod.corp.com")
730            .unwrap();
731        assert_ne!(result.as_str(), "prod.corp.com");
732    }
733}