Skip to main content

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}