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