forge_alloc/hardening/quarantine.rs
1//! `Quarantine<I, const EPOCHS: usize>` — holds freed blocks in a ring
2//! buffer for `EPOCHS` deallocate cycles before returning them to the inner
3//! allocator for reuse.
4//!
5//! Security property: a dangling pointer must survive `EPOCHS` deallocate
6//! cycles on this allocator instance before the slot's address can be reused
7//! by a subsequent allocate, raising the bar for UAF / type-confusion
8//! exploitation. With `EPOCHS = 16`, an attacker must spray 16 deallocations
9//! of the correct shape between free and exploit attempt.
10//!
11//! Compose with [`crate::hardening::PoisonOnFree`] for both content destruction and
12//! reuse delay: `PoisonOnFree<Quarantine<Slab<...>, 16>>` poisons on free
13//! (immediate content wipe), then quarantines the poisoned slot for 16
14//! cycles (delayed reuse).
15//!
16//! See `docs/ARCHITECTURE.md` for the composable-wrapper design.
17
18use core::cell::UnsafeCell;
19use core::ptr::NonNull;
20
21use forge_alloc_core::{AllocError, Allocator, Deallocator, FixedRange, NonZeroLayout};
22
23/// A freed block held in quarantine until its epoch expires.
24#[derive(Copy, Clone)]
25struct QuarantinedBlock {
26 ptr: NonNull<u8>,
27 layout: NonZeroLayout,
28}
29
30/// Quarantine wrapper.
31///
32/// `EPOCHS` is the ring length. Must be >= 1 (statically asserted). Each
33/// `deallocate` puts the freed block into slot `(free_count % EPOCHS)`,
34/// evicting whatever was there to the inner allocator first. This means a
35/// block stays quarantined for at most `EPOCHS` subsequent deallocates.
36///
37/// # Thread safety
38///
39/// `Send` when `I: Send`. `Sync`: NO. The ring buffer uses `UnsafeCell` for
40/// `&self` mutation on the deallocate path. Cross-thread use requires an
41/// outer synchronization layer.
42///
43/// # Panic safety
44///
45/// `Quarantine` relies on `inner.deallocate` being **panic-free** per the
46/// `Deallocator` contract. If `inner.deallocate` panics during a
47/// `Quarantine::deallocate` call (e.g. a `debug_assert!` in a lower-layer
48/// `Statistics` wrapper fires), the evicted block held in the local
49/// `prior` binding is **leaked**: the ring slot has already been
50/// overwritten with the new block, and the unwinding path drops `prior`
51/// without re-routing it. Subsequent deallocates will continue normally
52/// at the next ring index, so the quarantine remains usable — but the
53/// one leaked block's memory will only be reclaimed when the inner's
54/// own backing region drops (typically at process exit for an mmap-
55/// backed slab; never, for a slab leased from a long-lived backing).
56///
57/// During `Drop`, a panicking `inner.deallocate` aborts the drain loop,
58/// leaking the remaining quarantined slots in the same way.
59///
60/// **Drop-during-unwind escalation**: if `Quarantine` is itself dropped
61/// as part of stack unwinding (i.e. an earlier panic is already in
62/// flight) and `inner.deallocate` panics inside the drain loop, the
63/// second panic-while-panicking triggers an immediate **process abort**
64/// (this is a Rust language rule, not a Quarantine choice). Concretely:
65/// a panic from the inner `deallocate` during normal Drop becomes a
66/// leak; the same panic from inner `deallocate` during unwinding Drop
67/// becomes a fatal abort. The Quarantine layer cannot defuse the
68/// second case without `catch_unwind` (which would require `std` and
69/// is contrary to no-panic-in-Drop being the contract everywhere
70/// below us). Treat a panicking `inner.deallocate` as a critical bug
71/// to fix in the inner — not a recoverable condition.
72///
73/// These outcomes are acceptable for `Quarantine`'s intended threat
74/// model — a panicking inner `deallocate` already signals an
75/// allocator-state violation, and the priority is to avoid double-free
76/// rather than guarantee reclamation. Callers needing leak-free panic
77/// recovery should wrap with a separate drop-guard layer.
78///
79/// # Composition with size-classed inners
80///
81/// If the inner serves multiple sizes (e.g. `SizeClassed`), all sizes share
82/// the same `EPOCHS`-slot ring, so per-class quarantine depth degrades to
83/// `EPOCHS / active_sizes`. Recommended: place `Quarantine` INSIDE
84/// `SizeClassed` (`SizeClassed<Quarantine<Slab<T, _>, 16>, N>`) for
85/// per-class quarantine, OR keep `Quarantine` on a typed `Slab<T, _>` where
86/// all slots are the same size.
87///
88/// # Inner exhaustion while items are quarantined
89///
90/// During the `EPOCHS` window between dealloc-into-quarantine and
91/// eviction-to-inner, the freed slot is **still owned by the inner**
92/// allocator — Slab counts it as live, `SizeClassed` keeps the class slot
93/// off the freelist, etc. If the application exhausts the inner's capacity
94/// while items wait in quarantine, the next `Quarantine::allocate` call
95/// forwards to the inner and surfaces `AllocError` immediately (no waiting,
96/// no fancy retry). The quarantined slot becomes reusable once `EPOCHS`
97/// further deallocates evict it; until then the program is at reduced
98/// capacity. Size the inner with at least `EPOCHS` worth of slack if your
99/// workload runs near steady-state full.
100pub struct Quarantine<I: Allocator, const EPOCHS: usize> {
101 inner: I,
102 /// Ring buffer of `Option<QuarantinedBlock>` slots in `UnsafeCell`.
103 /// We use `UnsafeCell` of the whole array (rather than per-slot) because
104 /// `Quarantine` is `!Sync` and only one thread accesses the ring at a
105 /// time.
106 ring: UnsafeCell<[Option<QuarantinedBlock>; EPOCHS]>,
107 /// Number of deallocate calls received. Position in ring is
108 /// `count % EPOCHS`. Wraps at `usize::MAX` — see `deallocate_count`.
109 count: UnsafeCell<usize>,
110}
111
112impl<I: Allocator, const EPOCHS: usize> Quarantine<I, EPOCHS> {
113 /// Compile-time check that `EPOCHS >= 1`.
114 const ASSERT_EPOCHS: () = assert!(EPOCHS >= 1, "Quarantine<_, EPOCHS> requires EPOCHS >= 1");
115
116 /// Wrap an inner allocator with `EPOCHS`-cycle quarantine.
117 #[inline]
118 pub fn new(inner: I) -> Self {
119 let _: () = Self::ASSERT_EPOCHS;
120 // `Option::None` is a valid initial state for every slot, no
121 // `MaybeUninit` dance required.
122 Self {
123 inner,
124 ring: UnsafeCell::new([None; EPOCHS]),
125 count: UnsafeCell::new(0),
126 }
127 }
128
129 /// Borrow the inner allocator.
130 #[inline]
131 pub fn inner(&self) -> &I {
132 &self.inner
133 }
134
135 /// Total number of deallocate calls received.
136 ///
137 /// Internally this counter increments with `wrapping_add`, so after
138 /// `usize::MAX` deallocations it wraps to `0`. This is intentional —
139 /// ring indexing uses `count % EPOCHS` and wrap is harmless there —
140 /// but callers reading this value as a long-running statistic should
141 /// account for the wrap.
142 #[inline]
143 pub fn deallocate_count(&self) -> usize {
144 // SAFETY: !Sync — single-threaded access.
145 unsafe { *self.count.get() }
146 }
147}
148
149unsafe impl<I: Allocator, const EPOCHS: usize> Deallocator for Quarantine<I, EPOCHS> {
150 #[inline]
151 unsafe fn deallocate(&self, ptr: NonNull<u8>, layout: NonZeroLayout) {
152 // SAFETY: !Sync — single-threaded access to ring + count.
153 unsafe {
154 let count_ptr = self.count.get();
155 let cnt = *count_ptr;
156 let idx = cnt % EPOCHS;
157 let ring_ptr = self.ring.get();
158 // Increment count BEFORE the inner.deallocate so a panicking
159 // inner leaves the ring + count in a consistent state for the
160 // next call. See the "Panic safety" section on the type docs.
161 *count_ptr = cnt.wrapping_add(1);
162 // Swap the new block in; if a prior block was there, evict it
163 // to the inner allocator.
164 let evicted = (*ring_ptr)[idx].replace(QuarantinedBlock { ptr, layout });
165 if let Some(prior) = evicted {
166 // SAFETY: this block was put into the ring by an earlier
167 // call to our deallocate(); the inner allocator issued it,
168 // so it's valid for inner.deallocate.
169 self.inner.deallocate(prior.ptr, prior.layout);
170 }
171 }
172 }
173}
174
175unsafe impl<I: Allocator, const EPOCHS: usize> Allocator for Quarantine<I, EPOCHS> {
176 #[inline]
177 fn allocate(&self, layout: NonZeroLayout) -> Result<NonNull<[u8]>, AllocError> {
178 self.inner.allocate(layout)
179 }
180
181 #[inline]
182 fn allocate_zeroed(&self, layout: NonZeroLayout) -> Result<NonNull<[u8]>, AllocError> {
183 self.inner.allocate_zeroed(layout)
184 }
185
186 #[inline]
187 unsafe fn usable_size(&self, ptr: NonNull<u8>, layout: NonZeroLayout) -> Option<usize> {
188 // Layout-transparent forwarder: `allocate` returns the inner's block
189 // unchanged. Forwarding `usable_size` is REQUIRED for the documented
190 // `PoisonOnFree<Quarantine<..>>` / `ZeroizeOnFree<Quarantine<..>>`
191 // composition — without it the outer scrub wrapper sees `None`, falls
192 // back to `layout.size()`, and leaves the `[size, usable_size)` slack
193 // tail un-scrubbed (a freed-secret leak).
194 // SAFETY: forwarded; caller upholds usable_size's contract on inner.
195 unsafe { self.inner.usable_size(ptr, layout) }
196 }
197
198 #[inline]
199 fn capacity_bytes(&self) -> Option<usize> {
200 self.inner.capacity_bytes()
201 }
202
203 #[inline]
204 fn corruption_events(&self) -> u64 {
205 self.inner.corruption_events()
206 }
207
208 // grow/shrink inherit the default; the discarded buffer gets routed
209 // through `self.deallocate`, putting it into quarantine. This is the
210 // desired behavior — grown-out buffers must respect the quarantine.
211}
212
213impl<I: Allocator + FixedRange, const EPOCHS: usize> FixedRange for Quarantine<I, EPOCHS> {
214 #[inline]
215 fn base(&self) -> NonNull<u8> {
216 self.inner.base()
217 }
218
219 #[inline]
220 fn size(&self) -> usize {
221 self.inner.size()
222 }
223
224 /// Pass-through forward so a `commit`-aware consumer reaches the inner
225 /// backing when this wrapper sits over a `lazy_commit` `MmapBacked`.
226 #[inline]
227 fn commit(&self, offset: usize, len: usize) -> Result<(), AllocError> {
228 self.inner.commit(offset, len)
229 }
230}
231
232impl<I: Allocator, const EPOCHS: usize> Drop for Quarantine<I, EPOCHS> {
233 fn drop(&mut self) {
234 // Drain quarantine: return every held block to inner before inner
235 // itself drops. Critical for Slab-style backings where leaked slots
236 // would leak the entire backing's allocation. For mmap-backed
237 // inners the inner's drop reclaims everything anyway, but we drain
238 // for symmetry and to avoid surprise.
239 //
240 // Stacked Borrows note: we MUST NOT create a `&mut [Option<Block>;
241 // EPOCHS]` reference over the ring (which `self.ring.get_mut()`
242 // would do). A Unique retag at the ring level — or, transitively,
243 // anywhere over the ring's storage — invalidates the
244 // SharedReadWrite tag covering the inner allocator's backing
245 // (e.g. `InlineBacked::storage`). Each `Block.ptr` was derived
246 // from that SharedReadWrite tag during the earlier `allocate`
247 // call; using it through `self.inner.deallocate(b.ptr, ...)` after
248 // the Unique retag is UB under SB. (Miri caught the same class of
249 // bug in `SlabOwner`.)
250 //
251 // Work with raw pointers throughout the drain. The `&mut self`
252 // signature is the Drop trait's; we are careful to never
253 // materialize a `&mut` to the ring or its contents while a
254 // `Block.ptr` is still live.
255 let ring_ptr: *mut [Option<QuarantinedBlock>; EPOCHS] = self.ring.get();
256 for i in 0..EPOCHS {
257 // SAFETY: `ring_ptr` is valid, single-threaded access (Drop
258 // has exclusive access to `self`), in-bounds index.
259 let slot_ptr: *mut Option<QuarantinedBlock> =
260 unsafe { (ring_ptr as *mut Option<QuarantinedBlock>).add(i) };
261 // SAFETY: read the current value by bit-copy. We then
262 // immediately overwrite the slot with `None`, so no
263 // double-drop risk. Equivalent to `slot.take()` without
264 // creating a `&mut Option<…>` reference at the ring level.
265 let taken: Option<QuarantinedBlock> = unsafe { core::ptr::read(slot_ptr) };
266 unsafe { core::ptr::write(slot_ptr, None) };
267 if let Some(b) = taken {
268 // SAFETY: this block was put into the ring by an earlier
269 // call to our deallocate(); the inner allocator issued
270 // it, so it's valid for inner.deallocate. `self.inner`
271 // is accessed via a shared borrow (the `Deallocator`
272 // trait method takes `&self`), which does NOT trigger
273 // an additional Unique retag.
274 unsafe { self.inner.deallocate(b.ptr, b.layout) };
275 }
276 }
277 }
278}
279
280// Send when I: Send. !Sync via UnsafeCell.
281unsafe impl<I: Allocator + Send, const EPOCHS: usize> Send for Quarantine<I, EPOCHS> {}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286 use crate::backing::InlineBacked;
287 use crate::layout::Slab;
288
289 /// Helper: Slab<u64> wrapped in Quarantine.
290 fn build<const E: usize>() -> Quarantine<Slab<u64, InlineBacked<512>>, E> {
291 Quarantine::new(Slab::new(8, InlineBacked::<512>::new()).unwrap())
292 }
293
294 #[test]
295 fn allocate_passes_through() {
296 let q = build::<4>();
297 let layout = NonZeroLayout::for_type::<u64>().unwrap();
298 let block = q.allocate(layout).unwrap();
299 unsafe { q.deallocate(block.cast(), layout) };
300 }
301
302 #[test]
303 fn freed_slot_not_reused_immediately() {
304 // EPOCHS=4: after free, the next 3 deallocs cycle the ring; on the
305 // 4th additional dealloc, the original slot is evicted to inner and
306 // becomes reusable.
307 let q = build::<4>();
308 let layout = NonZeroLayout::for_type::<u64>().unwrap();
309 // Allocate 4 slots, remember their addresses.
310 let a = q.allocate(layout).unwrap().cast::<u8>();
311 let _b = q.allocate(layout).unwrap().cast::<u8>();
312 let _c = q.allocate(layout).unwrap().cast::<u8>();
313 let _d = q.allocate(layout).unwrap().cast::<u8>();
314 let _e = q.allocate(layout).unwrap().cast::<u8>();
315
316 // Free `a`. With Quarantine, `a` does NOT immediately go back to
317 // Slab's free list; it sits in quarantine slot 0.
318 unsafe { q.deallocate(a, layout) };
319 // Slab still has 3 unallocated slots (8 - 5 = 3). The next alloc
320 // takes from `next_uncarved`, NOT from `a` (because `a` is in
321 // quarantine). Verify by checking the new ptr differs from `a`.
322 let f = q.allocate(layout).unwrap().cast::<u8>();
323 assert_ne!(
324 a.as_ptr(),
325 f.as_ptr(),
326 "EPOCHS=4 quarantine should hold `a`"
327 );
328 }
329
330 #[test]
331 fn evicted_block_reachable_after_epochs() {
332 // EPOCHS=2: after 2 additional deallocs, the original block leaves
333 // quarantine and is back on Slab's freelist.
334 let q = build::<2>();
335 let layout = NonZeroLayout::for_type::<u64>().unwrap();
336 let a = q.allocate(layout).unwrap().cast::<u8>();
337 let b = q.allocate(layout).unwrap().cast::<u8>();
338 let c = q.allocate(layout).unwrap().cast::<u8>();
339
340 unsafe { q.deallocate(a, layout) }; // ring[0] = a, count=1
341 unsafe { q.deallocate(b, layout) }; // ring[1] = b, count=2
342 unsafe { q.deallocate(c, layout) }; // ring[0] evicts a, ring[0] = c, count=3
343
344 // a is now on the Slab freelist. The next alloc returns a (LIFO).
345 let g = q.allocate(layout).unwrap().cast::<u8>();
346 assert_eq!(a.as_ptr(), g.as_ptr(), "evicted `a` should be reusable");
347 }
348
349 #[test]
350 fn drop_drains_quarantine() {
351 let s = Slab::<u64, InlineBacked<512>>::new(8, InlineBacked::<512>::new()).unwrap();
352 let layout = NonZeroLayout::for_type::<u64>().unwrap();
353 let a_addr;
354 let b_addr;
355 {
356 let q: Quarantine<&Slab<u64, InlineBacked<512>>, 8> = Quarantine::new(&s);
357 // Carve ALL 8 slab slots so the freelist is empty and the cursor is
358 // exhausted — then the ONLY way a post-drop allocate can succeed is
359 // via a slot the drain returned. (Previously this test left 6 slots
360 // uncarved, so it passed even if Drop drained nothing — vacuous.)
361 let a = q.allocate(layout).unwrap().cast::<u8>();
362 let b = q.allocate(layout).unwrap().cast::<u8>();
363 a_addr = a.as_ptr();
364 b_addr = b.as_ptr();
365 for _ in 0..6 {
366 let _ = q.allocate(layout).unwrap();
367 }
368 // Quarantine a and b (EPOCHS=8 holds both, no eviction).
369 unsafe {
370 q.deallocate(a, layout);
371 q.deallocate(b, layout);
372 }
373 // q drops here → drain must return a and b to the slab freelist.
374 }
375 // Slab is fully carved; this can only succeed from a drained slot. A
376 // no-op Drop would leave the slab exhausted and this would be Err.
377 let c = s
378 .allocate(layout)
379 .expect("Drop must have drained a slot back to the slab");
380 let c_addr = c.cast::<u8>().as_ptr();
381 assert!(
382 c_addr == a_addr || c_addr == b_addr,
383 "post-drop allocation must reuse a drained slot",
384 );
385 }
386
387 #[test]
388 fn epochs_one_evicts_immediately() {
389 // EPOCHS=1 is the degenerate ring: each deallocate evicts the
390 // previously-quarantined block (count % 1 == 0). After freeing `a`
391 // then `b`, `a` is already back on the freelist.
392 let q = build::<1>();
393 let layout = NonZeroLayout::for_type::<u64>().unwrap();
394 let a = q.allocate(layout).unwrap().cast::<u8>();
395 let b = q.allocate(layout).unwrap().cast::<u8>();
396 unsafe { q.deallocate(a, layout) }; // ring[0] = a, count=1
397 unsafe { q.deallocate(b, layout) }; // evicts a, ring[0] = b, count=2
398 let g = q.allocate(layout).unwrap().cast::<u8>();
399 assert_eq!(
400 a.as_ptr(),
401 g.as_ptr(),
402 "EPOCHS=1 should evict `a` immediately",
403 );
404 }
405
406 /// Minimal inner that reports `slack` extra usable bytes — proves
407 /// `Quarantine` forwards `usable_size`, the fix for the
408 /// `PoisonOnFree<Quarantine<..>>` slack-scrub leak. Without the forward
409 /// `q.usable_size` would be the trait-default `None`.
410 struct SlackReporting<I>(I, usize);
411 unsafe impl<I: Deallocator> Deallocator for SlackReporting<I> {
412 unsafe fn deallocate(&self, ptr: NonNull<u8>, layout: NonZeroLayout) {
413 // SAFETY: forwarded.
414 unsafe { self.0.deallocate(ptr, layout) }
415 }
416 }
417 unsafe impl<I: Allocator> Allocator for SlackReporting<I> {
418 fn allocate(&self, layout: NonZeroLayout) -> Result<NonNull<[u8]>, AllocError> {
419 self.0.allocate(layout)
420 }
421 unsafe fn usable_size(&self, _ptr: NonNull<u8>, layout: NonZeroLayout) -> Option<usize> {
422 Some(layout.size().get() + self.1)
423 }
424 }
425
426 #[test]
427 fn forwards_usable_size_for_slack_scrub() {
428 let inner = SlackReporting(
429 Slab::<u64, InlineBacked<512>>::new(8, InlineBacked::<512>::new()).unwrap(),
430 16,
431 );
432 let q: Quarantine<_, 4> = Quarantine::new(inner);
433 let layout = NonZeroLayout::for_type::<u64>().unwrap();
434 let block = q.allocate(layout).unwrap();
435 let ptr = block.cast::<u8>();
436 // Quarantine must surface the inner's slack so an outer scrub wrapper
437 // wipes the whole usable extent; the trait default would be `None`.
438 let us = unsafe { q.usable_size(ptr, layout) };
439 assert_eq!(us, Some(layout.size().get() + 16));
440 unsafe { q.deallocate(ptr, layout) };
441 }
442
443 #[test]
444 fn deallocate_count_advances() {
445 let q = build::<4>();
446 let layout = NonZeroLayout::for_type::<u64>().unwrap();
447 let a = q.allocate(layout).unwrap();
448 let b = q.allocate(layout).unwrap();
449 unsafe { q.deallocate(a.cast(), layout) };
450 unsafe { q.deallocate(b.cast(), layout) };
451 assert_eq!(q.deallocate_count(), 2);
452 }
453}