Skip to content

Commit 3b299bf

Browse files
authored
docs: enhance HandleStore with security considerations and fallible constructors (#106)
- Added a `## Security Considerations` section to the module documentation, outlining risks associated with the `FxHashMap` hasher, capacity handling, and sensitive value management. - Introduced `try_new` methods for both `HandleStore` and `ConcurrentHandleStore` to allow fallible capacity reservation, improving safety when dealing with untrusted input. - Updated the `clear` method to attribute removed entries to the `removes` metric, ensuring accurate audit trails. - Added tests to verify the behavior of `clear` and `try_new`, confirming correct metric updates and capacity handling. These changes enhance the security and usability of the `HandleStore`, aligning with best practices for handling untrusted data and improving observability of cache operations.
1 parent 7924b79 commit 3b299bf

1 file changed

Lines changed: 146 additions & 3 deletions

File tree

src/store/handle.rs

Lines changed: 146 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,21 @@
124124
//! - [`HandleStore`] is **not** thread-safe (uses `Cell` for metrics)
125125
//! - [`ConcurrentHandleStore`] is `Send + Sync` via `parking_lot::RwLock`
126126
//!
127+
//! ## Security Considerations
128+
//!
129+
//! - **Hasher:** backed by `FxHashMap`, which is fast but **not** HashDoS-resistant.
130+
//! Handles (`H`) must come from a trusted source — typically a
131+
//! [`KeyInterner`](crate::ds::KeyInterner) producing monotonic `u64` IDs.
132+
//! Passing attacker-controlled hashable values directly as handles is
133+
//! unsupported.
134+
//! - **Capacity:** [`HandleStore::new`] panics on allocator failure. Use
135+
//! [`HandleStore::try_new`] (and the concurrent equivalent) when `capacity`
136+
//! originates from untrusted input.
137+
//! - **Sensitive values:** dropped values are not zeroed. Wrap `V` in
138+
//! `zeroize::Zeroizing` (or equivalent) when caching secrets.
139+
//! - **Lock poisoning:** `parking_lot::RwLock` does not poison on panic.
140+
//! Under `panic = "unwind"`, avoid panicking `Drop` impls on `V`.
141+
//!
127142
//! ## Implementation Notes
128143
//!
129144
//! - Handles must remain stable for the lifetime of stored entries
@@ -197,6 +212,10 @@ impl StoreCounters {
197212
self.removes.set(self.removes.get() + 1);
198213
}
199214

215+
fn add_removes(&self, n: u64) {
216+
self.removes.set(self.removes.get().saturating_add(n));
217+
}
218+
200219
fn inc_eviction(&self) {
201220
self.evictions.set(self.evictions.get() + 1);
202221
}
@@ -256,6 +275,10 @@ impl ConcurrentStoreCounters {
256275
self.removes.fetch_add(1, Ordering::Relaxed);
257276
}
258277

278+
fn add_removes(&self, n: u64) {
279+
self.removes.fetch_add(n, Ordering::Relaxed);
280+
}
281+
259282
fn inc_eviction(&self) {
260283
self.evictions.fetch_add(1, Ordering::Relaxed);
261284
}
@@ -317,6 +340,9 @@ where
317340
{
318341
/// Creates a new handle store with the specified maximum capacity.
319342
///
343+
/// Panics on allocator failure. Use [`try_new`](Self::try_new) when
344+
/// `capacity` is derived from untrusted input.
345+
///
320346
/// # Example
321347
///
322348
/// ```
@@ -334,6 +360,35 @@ where
334360
}
335361
}
336362

363+
/// Fallible constructor for [`new`](Self::new).
364+
///
365+
/// Reserves space for `capacity` entries without panicking on allocator
366+
/// failure. Preferred when `capacity` comes from configuration, RPC input,
367+
/// or any source that is not fully trusted.
368+
///
369+
/// # Errors
370+
///
371+
/// Returns [`TryReserveError`](std::collections::TryReserveError) if the
372+
/// allocator cannot reserve capacity for `capacity` entries.
373+
///
374+
/// # Example
375+
///
376+
/// ```
377+
/// use cachekit::store::handle::HandleStore;
378+
///
379+
/// let store: HandleStore<u64, i32> = HandleStore::try_new(1000).unwrap();
380+
/// assert_eq!(store.capacity(), 1000);
381+
/// ```
382+
pub fn try_new(capacity: usize) -> Result<Self, std::collections::TryReserveError> {
383+
let mut map: FxHashMap<H, Arc<V>> = FxHashMap::with_hasher(Default::default());
384+
map.try_reserve(capacity)?;
385+
Ok(Self {
386+
map,
387+
capacity,
388+
metrics: StoreCounters::default(),
389+
})
390+
}
391+
337392
/// Returns a clone of the value for the given handle.
338393
///
339394
/// Updates hit/miss metrics. Use [`peek`](Self::peek) to avoid metric updates.
@@ -471,9 +526,12 @@ where
471526

472527
/// Removes all entries from the store.
473528
///
474-
/// Does not update metrics. Capacity remains unchanged.
529+
/// Attributes the cleared entries to the `removes` metric so audit trails
530+
/// reflect that entries left the store. Capacity remains unchanged.
475531
pub fn clear(&mut self) {
532+
let removed = self.map.len() as u64;
476533
self.map.clear();
534+
self.metrics.add_removes(removed);
477535
}
478536

479537
/// Returns the current number of entries.
@@ -616,6 +674,9 @@ where
616674
{
617675
/// Creates a new concurrent handle store with the specified capacity.
618676
///
677+
/// Panics on allocator failure. Use [`try_new`](Self::try_new) when
678+
/// `capacity` is derived from untrusted input.
679+
///
619680
/// # Example
620681
///
621682
/// ```
@@ -637,6 +698,37 @@ where
637698
}
638699
}
639700

701+
/// Fallible constructor for [`new`](Self::new).
702+
///
703+
/// Reserves space for `capacity` entries without panicking on allocator
704+
/// failure. Preferred when `capacity` comes from configuration, RPC input,
705+
/// or any source that is not fully trusted.
706+
///
707+
/// # Errors
708+
///
709+
/// Returns [`TryReserveError`](std::collections::TryReserveError) if the
710+
/// allocator cannot reserve capacity for `capacity` entries.
711+
///
712+
/// # Example
713+
///
714+
/// ```
715+
/// use cachekit::store::handle::ConcurrentHandleStore;
716+
/// use cachekit::store::traits::ConcurrentStoreRead;
717+
///
718+
/// let store: ConcurrentHandleStore<u64, String> =
719+
/// ConcurrentHandleStore::try_new(1000).unwrap();
720+
/// assert_eq!(store.capacity(), 1000);
721+
/// ```
722+
pub fn try_new(capacity: usize) -> Result<Self, std::collections::TryReserveError> {
723+
let mut map: FxHashMap<H, Arc<V>> = FxHashMap::with_hasher(Default::default());
724+
map.try_reserve(capacity)?;
725+
Ok(Self {
726+
map: RwLock::new(map),
727+
capacity,
728+
metrics: ConcurrentStoreCounters::default(),
729+
})
730+
}
731+
640732
/// Records an eviction in the metrics.
641733
///
642734
/// Thread-safe via atomic increment. Call when the policy evicts an entry.
@@ -758,9 +850,13 @@ where
758850

759851
/// Removes all entries.
760852
///
761-
/// Acquires a write lock. Does not update metrics.
853+
/// Acquires a write lock. Attributes the cleared entries to the `removes`
854+
/// metric so audit trails reflect that entries left the store.
762855
fn clear(&self) {
763-
self.map.write().clear();
856+
let mut map = self.map.write();
857+
let removed = map.len() as u64;
858+
map.clear();
859+
self.metrics.add_removes(removed);
764860
}
765861
}
766862

@@ -843,6 +939,53 @@ mod tests {
843939
assert!(!store.contains(&1u64));
844940
}
845941

942+
#[test]
943+
fn handle_store_clear_attributes_removes() {
944+
let mut store: HandleStore<u64, ()> = HandleStore::new(4);
945+
store.try_insert(1u64, Arc::new(())).unwrap();
946+
store.try_insert(2u64, Arc::new(())).unwrap();
947+
store.try_insert(3u64, Arc::new(())).unwrap();
948+
949+
store.clear();
950+
951+
assert_eq!(store.len(), 0);
952+
assert_eq!(store.metrics().removes, 3);
953+
}
954+
955+
#[test]
956+
fn handle_store_try_new_reserves_capacity() {
957+
let store: HandleStore<u64, ()> = HandleStore::try_new(0).expect("try_new(0)");
958+
assert_eq!(store.capacity(), 0);
959+
assert_eq!(store.len(), 0);
960+
961+
let store: HandleStore<u64, ()> = HandleStore::try_new(128).expect("try_new(128)");
962+
assert_eq!(store.capacity(), 128);
963+
assert_eq!(store.len(), 0);
964+
}
965+
966+
#[cfg(feature = "concurrency")]
967+
#[test]
968+
fn concurrent_handle_store_clear_attributes_removes() {
969+
let store: ConcurrentHandleStore<u64, ()> = ConcurrentHandleStore::new(4);
970+
store.try_insert(1u64, Arc::new(())).unwrap();
971+
store.try_insert(2u64, Arc::new(())).unwrap();
972+
store.try_insert(3u64, Arc::new(())).unwrap();
973+
974+
store.clear();
975+
976+
assert_eq!(store.len(), 0);
977+
assert_eq!(store.metrics().removes, 3);
978+
}
979+
980+
#[cfg(feature = "concurrency")]
981+
#[test]
982+
fn concurrent_handle_store_try_new_reserves_capacity() {
983+
let store: ConcurrentHandleStore<u64, ()> =
984+
ConcurrentHandleStore::try_new(128).expect("try_new(128)");
985+
assert_eq!(store.capacity(), 128);
986+
assert_eq!(store.len(), 0);
987+
}
988+
846989
#[test]
847990
fn handle_store_metrics_counts() {
848991
let mut store = HandleStore::new(2);

0 commit comments

Comments
 (0)