Skip to main content

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}