sanitize_engine/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<&Arc<AllowlistMatcher>> {
165 self.allowlist.as_ref()
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 loop {
223 let current = self.len.load(Ordering::Acquire);
224 if current >= limit {
225 // One more chance: key may have been inserted by another thread.
226 if let Some(existing) = inner.get(original) {
227 return Ok(existing.value().0.clone());
228 }
229 return Err(SanitizeError::CapacityExceeded { current, limit });
230 }
231 if self
232 .len
233 .compare_exchange_weak(
234 current,
235 current + 1,
236 Ordering::AcqRel,
237 Ordering::Acquire,
238 )
239 .is_ok()
240 {
241 break;
242 }
243 // CAS failed → another thread incremented; retry.
244 }
245
246 // Slot reserved — generate and insert (first-writer-wins).
247 let mut was_inserted = false;
248 let insertion_index = self.len.load(Ordering::Acquire).saturating_sub(1);
249 let result = inner
250 .entry(ZeroizingString(original.to_owned()))
251 .or_insert_with(|| {
252 was_inserted = true;
253 let val = self.generator.generate(category, original);
254 (CompactString::new(val), insertion_index)
255 })
256 .value()
257 .0
258 .clone();
259
260 if !was_inserted {
261 // Another thread inserted first — release our reserved slot.
262 self.len.fetch_sub(1, Ordering::Release);
263 }
264
265 Ok(result)
266 } else {
267 // No capacity limit — generate inside the entry lock so only the
268 // first writer calls the generator (first-writer-wins semantics).
269 let result = inner
270 .entry(ZeroizingString(original.to_owned()))
271 .or_insert_with(|| {
272 let insertion_index = self.len.fetch_add(1, Ordering::AcqRel);
273 let val = self.generator.generate(category, original);
274 (CompactString::new(val), insertion_index)
275 })
276 .value()
277 .0
278 .clone();
279
280 Ok(result)
281 }
282 }
283
284 /// Look up an existing forward mapping without creating one.
285 #[must_use]
286 pub fn forward_lookup(&self, category: &Category, original: &str) -> Option<CompactString> {
287 let inner = self.forward.get(category)?;
288 inner.value().get(original).map(|r| r.value().0.clone())
289 }
290
291 // ---------------- Metrics ----------------
292
293 /// Number of unique mappings in the store.
294 #[must_use]
295 pub fn len(&self) -> usize {
296 self.len.load(Ordering::Relaxed)
297 }
298
299 /// Whether the store is empty.
300 #[must_use]
301 pub fn is_empty(&self) -> bool {
302 self.len() == 0
303 }
304
305 /// Remove all mappings, zeroizing the original plaintexts.
306 ///
307 /// This is useful for resetting the store between runs without
308 /// dropping and recreating it.
309 pub fn clear(&mut self) {
310 // Dropping the map entries triggers ZeroizingString::drop on each inner key.
311 // If any cloned Arcs are still live (e.g., in a concurrent thread's stack),
312 // those inner maps survive until the last Arc drops — but `clear` is only
313 // called after all workers have finished, so this is safe in practice.
314 drop(std::mem::take(&mut self.forward));
315 self.len.store(0, Ordering::Release);
316 }
317
318 // ---------------- Snapshot / diff (for format-preserving pass) ----------------
319
320 /// Snapshot the current insertion count.
321 ///
322 /// Returns an opaque `usize` that can be passed to [`Self::iter_since`] to
323 /// iterate only the entries added *after* this point — useful for
324 /// finding which mappings a structured processor pass discovered without
325 /// building a full `HashSet` of all existing keys.
326 ///
327 /// O(1), no allocation.
328 #[must_use]
329 pub fn snapshot(&self) -> usize {
330 self.len.load(Ordering::Acquire)
331 }
332
333 /// Iterate over entries added at or after the given snapshot.
334 ///
335 /// `snapshot` is the value returned by a previous call to [`Self::snapshot`].
336 /// Entries whose insertion index is ≥ `snapshot` are yielded; older
337 /// entries are skipped. Still O(n) in total store size, but avoids
338 /// allocating a `HashSet` of all prior keys.
339 ///
340 /// Implementation note: the inner `.collect::<Vec<_>>()` inside the
341 /// `flat_map` is required to release the DashMap shard lock before
342 /// yielding items — it allocates one `Vec` per category shard visited.
343 pub fn iter_since(
344 &self,
345 snapshot: usize,
346 ) -> impl Iterator<Item = (Category, CompactString, CompactString)> + '_ {
347 self.forward.iter().flat_map(move |outer| {
348 let cat = outer.key().clone();
349 outer
350 .value()
351 .iter()
352 .filter_map(move |inner| {
353 let (sanitized, idx) = inner.value();
354 if *idx >= snapshot {
355 Some((
356 cat.clone(),
357 CompactString::new(inner.key().0.as_str()),
358 sanitized.clone(),
359 ))
360 } else {
361 None
362 }
363 })
364 .collect::<Vec<_>>()
365 })
366 }
367
368 // ---------------- Iteration (for external use) ----------------
369
370 /// Iterate over all mappings. Yields `(category, original, sanitized)`.
371 ///
372 /// Note: iteration over `DashMap` is not snapshot-consistent if concurrent
373 /// inserts are happening. Call this after all workers have finished.
374 ///
375 /// Implementation note: allocates one `Vec` per category shard to release
376 /// the DashMap shard lock between categories.
377 pub fn iter(&self) -> impl Iterator<Item = (Category, CompactString, CompactString)> + '_ {
378 self.forward.iter().flat_map(|outer| {
379 let cat = outer.key().clone();
380 outer
381 .value()
382 .iter()
383 .map(move |inner| {
384 (
385 cat.clone(),
386 CompactString::new(inner.key().0.as_str()),
387 inner.value().0.clone(),
388 )
389 })
390 .collect::<Vec<_>>()
391 })
392 }
393}
394
395/// Zeroize original keys stored in the forward map on drop.
396/// Dropping the outer `DashMap` triggers `Arc::drop` for each inner map; when
397/// the last Arc drops, `ZeroizingString::drop` runs for every key, overwriting
398/// the plaintext before the memory is freed.
399impl Drop for MappingStore {
400 fn drop(&mut self) {
401 drop(std::mem::take(&mut self.forward));
402 }
403}
404
405/// Compile-time assertion that a type is `Send + Sync`.
406macro_rules! static_assertions_send_sync {
407 ($t:ty) => {
408 const _: fn() = || {
409 fn assert_send<T: Send>() {}
410 fn assert_sync<T: Sync>() {}
411 assert_send::<$t>();
412 assert_sync::<$t>();
413 };
414 };
415}
416
417static_assertions_send_sync!(MappingStore);
418
419// ---------------------------------------------------------------------------
420// Unit tests
421// ---------------------------------------------------------------------------
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426 use crate::generator::{HmacGenerator, RandomGenerator};
427 use std::sync::Arc;
428
429 fn hmac_store(limit: Option<usize>) -> MappingStore {
430 let gen = Arc::new(HmacGenerator::new([42u8; 32]));
431 MappingStore::new(gen, limit)
432 }
433
434 fn random_store() -> MappingStore {
435 let gen = Arc::new(RandomGenerator::new());
436 MappingStore::new(gen, None)
437 }
438
439 // --- Basic operations ---
440
441 #[test]
442 fn insert_and_lookup() {
443 let store = hmac_store(None);
444 let s1 = store
445 .get_or_insert(&Category::Email, "alice@corp.com")
446 .unwrap();
447 assert!(!s1.is_empty());
448 assert!(s1.contains("@corp.com"), "domain must be preserved");
449 assert_eq!(s1.len(), "alice@corp.com".len(), "length must be preserved");
450 assert_eq!(store.len(), 1);
451 }
452
453 #[test]
454 fn same_input_same_output() {
455 let store = hmac_store(None);
456 let s1 = store
457 .get_or_insert(&Category::Email, "alice@corp.com")
458 .unwrap();
459 let s2 = store
460 .get_or_insert(&Category::Email, "alice@corp.com")
461 .unwrap();
462 assert_eq!(s1, s2, "repeated insert must return cached value");
463 assert_eq!(store.len(), 1, "no duplicate entry");
464 }
465
466 #[test]
467 fn different_inputs_different_outputs() {
468 let store = hmac_store(None);
469 let s1 = store
470 .get_or_insert(&Category::Email, "alice@corp.com")
471 .unwrap();
472 let s2 = store
473 .get_or_insert(&Category::Email, "bob@corp.com")
474 .unwrap();
475 assert_ne!(s1, s2);
476 assert_eq!(store.len(), 2);
477 }
478
479 #[test]
480 fn different_categories_different_outputs() {
481 let store = hmac_store(None);
482 let s1 = store.get_or_insert(&Category::Email, "test").unwrap();
483 let s2 = store.get_or_insert(&Category::Name, "test").unwrap();
484 assert_ne!(s1, s2);
485 }
486
487 #[test]
488 fn forward_lookup_works() {
489 let store = hmac_store(None);
490 let sanitized = store.get_or_insert(&Category::IpV4, "192.168.1.1").unwrap();
491 let found = store.forward_lookup(&Category::IpV4, "192.168.1.1");
492 assert_eq!(found, Some(sanitized));
493 }
494
495 #[test]
496 fn forward_lookup_missing() {
497 let store = hmac_store(None);
498 assert!(store.forward_lookup(&Category::Email, "nope").is_none());
499 }
500
501 // --- Capacity limit ---
502
503 #[test]
504 fn capacity_limit_enforced() {
505 let store = hmac_store(Some(2));
506 store.get_or_insert(&Category::Email, "a@a.com").unwrap();
507 store.get_or_insert(&Category::Email, "b@b.com").unwrap();
508 let result = store.get_or_insert(&Category::Email, "c@c.com");
509 assert!(result.is_err());
510 match result.unwrap_err() {
511 SanitizeError::CapacityExceeded {
512 current: 2,
513 limit: 2,
514 } => {}
515 other => panic!("unexpected error: {:?}", other),
516 }
517 }
518
519 #[test]
520 fn capacity_limit_allows_duplicate() {
521 let store = hmac_store(Some(1));
522 store.get_or_insert(&Category::Email, "a@a.com").unwrap();
523 // Re-inserting same value should succeed (fast path).
524 let s2 = store.get_or_insert(&Category::Email, "a@a.com").unwrap();
525 assert!(!s2.is_empty());
526 }
527
528 // --- Random generator within store ---
529
530 #[test]
531 fn random_store_caches() {
532 let store = random_store();
533 let s1 = store
534 .get_or_insert(&Category::Email, "alice@corp.com")
535 .unwrap();
536 let s2 = store
537 .get_or_insert(&Category::Email, "alice@corp.com")
538 .unwrap();
539 assert_eq!(s1, s2, "random store must still cache the first result");
540 }
541
542 // --- Iteration ---
543
544 #[test]
545 fn iter_yields_all_mappings() {
546 let store = hmac_store(None);
547 store.get_or_insert(&Category::Email, "a@a.com").unwrap();
548 store.get_or_insert(&Category::IpV4, "1.2.3.4").unwrap();
549 let collected: Vec<_> = store.iter().collect();
550 assert_eq!(collected.len(), 2);
551 }
552
553 // --- Concurrent inserts (basic smoke test) ---
554
555 #[test]
556 fn concurrent_inserts_no_panic() {
557 use std::sync::Arc;
558 use std::thread;
559
560 let gen = Arc::new(HmacGenerator::new([99u8; 32]));
561 let store = Arc::new(MappingStore::new(gen, None));
562
563 let mut handles = vec![];
564 for t in 0..8 {
565 let store = Arc::clone(&store);
566 handles.push(thread::spawn(move || {
567 for i in 0..1000 {
568 let val = format!("thread{}-val{}", t, i);
569 store.get_or_insert(&Category::Email, &val).unwrap();
570 }
571 }));
572 }
573
574 for h in handles {
575 h.join().unwrap();
576 }
577
578 assert_eq!(store.len(), 8000);
579 }
580
581 #[test]
582 fn concurrent_inserts_same_key_idempotent() {
583 use std::sync::Arc;
584 use std::thread;
585
586 let gen = Arc::new(HmacGenerator::new([7u8; 32]));
587 let store = Arc::new(MappingStore::new(gen, None));
588
589 let mut handles = vec![];
590 for _ in 0..8 {
591 let store = Arc::clone(&store);
592 handles.push(thread::spawn(move || {
593 let mut results = Vec::new();
594 for i in 0..100 {
595 let val = format!("shared-{}", i);
596 let r = store.get_or_insert(&Category::Email, &val).unwrap();
597 results.push((val, r));
598 }
599 results
600 }));
601 }
602
603 let mut all_results: Vec<Vec<(String, CompactString)>> = vec![];
604 for h in handles {
605 all_results.push(h.join().unwrap());
606 }
607
608 // All threads must agree on every mapping.
609 assert_eq!(store.len(), 100);
610 for i in 0..100 {
611 let val = format!("shared-{}", i);
612 let expected = store.forward_lookup(&Category::Email, &val).unwrap();
613 for thread_results in &all_results {
614 let (_, got) = &thread_results[i];
615 assert_eq!(
616 got, &expected,
617 "all threads must see the same mapping for {}",
618 val
619 );
620 }
621 }
622 }
623
624 // --- is_empty / clear ---
625
626 #[test]
627 fn is_empty_on_new_store() {
628 let store = hmac_store(None);
629 assert!(store.is_empty());
630 }
631
632 #[test]
633 fn is_empty_false_after_insert() {
634 let store = hmac_store(None);
635 store.get_or_insert(&Category::Email, "a@a.com").unwrap();
636 assert!(!store.is_empty());
637 }
638
639 #[test]
640 fn clear_resets_store() {
641 let mut store = hmac_store(None);
642 store.get_or_insert(&Category::Email, "a@a.com").unwrap();
643 store.get_or_insert(&Category::IpV4, "1.2.3.4").unwrap();
644 assert_eq!(store.len(), 2);
645 store.clear();
646 assert_eq!(store.len(), 0);
647 assert!(store.is_empty());
648 }
649
650 #[test]
651 fn clear_then_reinsert_works() {
652 let mut store = hmac_store(None);
653 store.get_or_insert(&Category::Email, "a@a.com").unwrap();
654 store.clear();
655 let result = store.get_or_insert(&Category::Email, "a@a.com");
656 assert!(result.is_ok());
657 assert_eq!(store.len(), 1);
658 }
659
660 // --- snapshot / iter_since ---
661
662 #[test]
663 fn snapshot_and_iter_since_yields_only_new() {
664 let store = hmac_store(None);
665 store.get_or_insert(&Category::Email, "old@a.com").unwrap();
666 let snap = store.snapshot();
667 store.get_or_insert(&Category::IpV4, "1.2.3.4").unwrap();
668 store.get_or_insert(&Category::Name, "Alice").unwrap();
669
670 let new_entries: Vec<_> = store.iter_since(snap).collect();
671 assert_eq!(new_entries.len(), 2);
672 // None of the new entries should be the pre-snapshot email.
673 assert!(!new_entries
674 .iter()
675 .any(|(cat, orig, _)| { *cat == Category::Email && orig.as_str() == "old@a.com" }));
676 }
677
678 #[test]
679 fn iter_since_zero_yields_all() {
680 let store = hmac_store(None);
681 store.get_or_insert(&Category::Email, "a@a.com").unwrap();
682 store.get_or_insert(&Category::IpV4, "1.2.3.4").unwrap();
683 let all: Vec<_> = store.iter_since(0).collect();
684 assert_eq!(all.len(), 2);
685 }
686
687 #[test]
688 fn iter_since_at_end_yields_nothing() {
689 let store = hmac_store(None);
690 store.get_or_insert(&Category::Email, "a@a.com").unwrap();
691 let snap = store.snapshot();
692 let new: Vec<_> = store.iter_since(snap).collect();
693 assert!(new.is_empty());
694 }
695
696 // --- new_with_allowlist ---
697
698 #[test]
699 fn allowlist_passes_value_through_unchanged() {
700 use crate::allowlist::AllowlistMatcher;
701 let (matcher, _) =
702 AllowlistMatcher::new(vec!["localhost".to_string(), "127.0.0.1".to_string()]);
703 let gen = Arc::new(HmacGenerator::new([42u8; 32]));
704 let store = MappingStore::new_with_allowlist(gen, None, Arc::new(matcher));
705
706 assert!(store.allowlist().is_some());
707
708 // Allowlisted value must be returned verbatim.
709 let result = store
710 .get_or_insert(&Category::Hostname, "localhost")
711 .unwrap();
712 assert_eq!(result.as_str(), "localhost");
713 }
714
715 #[test]
716 fn allowlist_still_replaces_non_listed() {
717 use crate::allowlist::AllowlistMatcher;
718 let (matcher, _) = AllowlistMatcher::new(vec!["localhost".to_string()]);
719 let gen = Arc::new(HmacGenerator::new([42u8; 32]));
720 let store = MappingStore::new_with_allowlist(gen, None, Arc::new(matcher));
721
722 let result = store
723 .get_or_insert(&Category::Hostname, "prod.corp.com")
724 .unwrap();
725 assert_ne!(result.as_str(), "prod.corp.com");
726 }
727}