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