forge_alloc/layout/slab.rs
1//! `Slab<T, B, M>` — typed fixed-stride block allocator with optional
2//! freelist MAC.
3//!
4//! Each slot holds either a live `T` (size & alignment per `T`) or a
5//! a `FreeLink` (when on the free list). The block stride is the max of the
6//! two, so slot reuse is always size- and alignment-compatible.
7//!
8//! Freelist uses 1-based indices (`0` = empty), separating the "list empty"
9//! sentinel from any valid slot — see `docs/ARCHITECTURE.md` for the
10//! freelist design.
11
12use core::cell::UnsafeCell;
13use core::marker::PhantomData;
14use core::mem::{align_of, size_of};
15use core::ptr::NonNull;
16use core::sync::atomic::{AtomicUsize, Ordering};
17
18use forge_alloc_core::{
19 AllocError, Allocator, Deallocator, FixedRange, FreelistProtection, NoProtection, NonZeroLayout,
20};
21
22// Cross-check: `B: FixedRange` is required so that `Slab` can re-query
23// `self.backing.base()` after the struct has moved. Backings whose
24// `base()` is structure-relative (e.g. `InlineBacked`) return DIFFERENT
25// addresses before and after a move; storing an absolute `NonNull<u8>`
26// captured at construction time would leave the slab pointing at the
27// OLD location, corrupting every subsequent `allocate` / `deallocate`.
28// See the "Slab move-safety" pin test for the exact failure mode.
29
30/// A free-list link stored inside a free slot.
31///
32/// `next_idx` is the 1-based index of the next free slot; `0` means end of
33/// list. `mac` is the integrity tag computed by [`FreelistProtection::sign`];
34/// [`NoProtection`] always writes `0`. The field exists unconditionally so
35/// that block_stride is stable across `M` choices.
36#[repr(C)]
37#[derive(Copy, Clone)]
38struct FreeLink {
39 next_idx: u32,
40 mac: u32,
41}
42
43/// Fixed-stride typed slab.
44///
45/// `T` is the value type; `B` is the underlying backing (any `Allocator`);
46/// `M` is the freelist integrity policy (default [`NoProtection`], with
47/// zero overhead). The slab takes one large allocation from `B` at
48/// construction; individual `allocate` / `deallocate` calls return slots
49/// from within that allocation in O(1).
50///
51/// # Usage discipline (read before unsafe-calling either trait method)
52///
53/// `Slab` issues raw `NonNull<u8>` pointers into typed-but-uninitialised
54/// slots — it does **not** track value lifecycle. The caller is responsible
55/// for the four following invariants:
56///
57/// 1. **Allocate-then-write**: the bytes inside the returned slice are
58/// uninitialised. The caller must write a valid `T` (e.g. via
59/// `core::ptr::write`) before reading.
60/// 2. **Drop-before-deallocate**: `deallocate` overwrites the slot with a
61/// a `FreeLink`. If `T: Drop`, the caller must run `T`'s destructor
62/// (typically `core::ptr::drop_in_place::<T>(ptr.as_ptr().cast())`)
63/// *before* calling `deallocate`. Failure to do so leaks resources owned
64/// by `T` (file handles, heap allocations, locks) and may cause UB if
65/// `T`'s drop is required for soundness.
66/// 3. **Layout-must-fit-stride**: callers should request layouts whose
67/// `size <= block_stride()` and `align <= max(align_of::<T>(),
68/// align_of::<FreeLink>())`. Mis-sized requests fail at allocate;
69/// mis-aligned ones may not match a slot index and trip a debug
70/// assertion in `deallocate`.
71/// 4. **Slab-drop-does-not-drop-Ts**: when the `Slab` itself drops, it
72/// returns the underlying backing region to `B` but does *not* iterate
73/// live slots to drop `T`. Callers responsible for any still-live `T`
74/// must drain them before dropping the slab — e.g. via a higher-level
75/// typed wrapper such as `GenerationalSlab`, which tracks per-slot
76/// generations and runs `T::drop` for outstanding handles.
77///
78/// `Slab::allocate` returns a `NonNull<[u8]>` whose slice length is
79/// [`block_stride()`](Self::block_stride), **not** the requested
80/// `layout.size()`. Callers who care about the exact requested size must
81/// remember it themselves; callers who want to use the extra padding bytes
82/// (e.g. for footers / metadata) may write through the full stride window.
83///
84/// # Thread safety
85///
86/// `Send` if `T`, `B`, `M` are `Send`. `Sync`: NO. The free list head and
87/// next-uncarved cursor live in `UnsafeCell`s so that `Allocator::allocate`
88/// can take `&self`. Cross-thread deallocation uses `SlabRemote` —
89/// not this type directly.
90///
91/// # API-misuse compile-failures (pinned)
92///
93/// `T` must not be a zero-sized type. The `ASSERT_T_NON_ZST` associated
94/// const turns the previously runtime-only rejection
95/// (`size_of::<T>() == 0` → `AllocError`) into a build error, so the
96/// failure surfaces at the call site instead of after a successful build.
97///
98/// ```compile_fail
99/// // FAILS TO COMPILE: ZST T is rejected by `Slab::ASSERT_T_NON_ZST`.
100/// // The const_assert fires when `with_protection` is monomorphised, so
101/// // the build halts before any test runs.
102/// use forge_alloc::InlineBacked;
103/// use forge_alloc::Slab;
104/// let _: Slab<(), InlineBacked<128>> =
105/// Slab::new(8, InlineBacked::<128>::new()).unwrap();
106/// ```
107pub struct Slab<T, B: Allocator + FixedRange, M: FreelistProtection = NoProtection> {
108 backing: B,
109 mac: M,
110 /// Byte offset from `backing.base()` to the start of the slab's
111 /// `capacity * block_stride` slot region.
112 ///
113 /// We deliberately do NOT store an absolute pointer. Backings whose
114 /// `base()` is structure-relative (e.g. `InlineBacked<N>` returns
115 /// `&self.storage`) report a DIFFERENT address before and after the
116 /// backing has been moved. An absolute pointer captured at
117 /// construction would then point at the backing's OLD location after
118 /// the slab was returned from its constructor by value. Storing the
119 /// offset and computing `self.backing.base().as_ptr().add(offset)`
120 /// at each access keeps the slab move-safe.
121 base_offset: usize,
122 /// 1-based slot index; 0 = list empty.
123 free_head: UnsafeCell<u32>,
124 /// Index of the first slot never yet allocated (always carved from front).
125 next_uncarved: UnsafeCell<u32>,
126 block_stride: usize,
127 /// `block_stride.trailing_zeros()` when `block_stride.is_power_of_two()`,
128 /// `0` otherwise. `slot_index` uses this to replace runtime `/ stride`
129 /// and `% stride` with `>> shift` and `& (stride - 1)` for the common
130 /// pow2-stride case (every `T` whose size and align are powers of two
131 /// with size ≥ `size_of::<FreeLink>()`, which is most real types). The
132 /// sentinel `0` is safe because real strides are always ≥ `size_of::<FreeLink>()` = 8,
133 /// so a true pow2 stride has shift ≥ 3.
134 stride_shift: u32,
135 capacity: u32,
136 backing_layout: NonZeroLayout,
137 /// Count of detected freelist corruption events (MAC verify
138 /// failures + out-of-range `next_idx` defense-in-depth tripwires).
139 /// Each event bumps this counter before the freelist is abandoned;
140 /// the slab keeps serving allocations from `next_uncarved`. Exposed
141 /// via [`Allocator::corruption_events`] for operator observability.
142 ///
143 /// **Width:** `AtomicUsize` (not `AtomicU64`) so this compiles on
144 /// 32-bit bare-metal targets (Cortex-M3/M4, `thumbv7em-none-eabihf`)
145 /// that lack native 64-bit atomic ops. The trait method
146 /// [`Allocator::corruption_events`] still returns `u64`; the cast
147 /// happens at the trait boundary. Practical impact: on 32-bit
148 /// hosts the counter saturates at `u32::MAX ≈ 4.3 B` corruption
149 /// events — overflow is irrelevant in any realistic timeframe
150 /// (one event/ns ≈ 4.3 s, but real workloads see ≪1 event/year).
151 corruption_events: AtomicUsize,
152 _phantom: PhantomData<T>,
153}
154
155impl<T, B: Allocator + FixedRange> Slab<T, B, NoProtection> {
156 /// Construct a slab with the default `NoProtection` policy.
157 ///
158 /// `capacity` is the number of `T` slots. Errors if the backing cannot
159 /// supply the required region or if the total size overflows.
160 pub fn new(capacity: usize, backing: B) -> Result<Self, AllocError> {
161 Self::with_protection(capacity, backing, NoProtection)
162 }
163}
164
165impl<T, B: Allocator + FixedRange, M: FreelistProtection> Slab<T, B, M> {
166 /// Compile-time assertion that `T` is not a ZST.
167 ///
168 /// Forcing this associated const inside `with_protection` triggers a
169 /// compile error when the slab is instantiated with a ZST `T`,
170 /// promoting the previously runtime-only ZST rejection (`size_of::<T>()
171 /// == 0` → `AllocError`) to a build-time error. This is purely
172 /// additive: every `T` that was accepted before still compiles, and
173 /// the runtime check below remains for backwards-compatibility and
174 /// defense-in-depth against any future generic path that might bypass
175 /// the const.
176 const ASSERT_T_NON_ZST: () = assert!(
177 size_of::<T>() > 0,
178 "Slab<T, B, M>: T must not be a zero-sized type — a freelist over \
179 zero-byte slots has no meaningful pointer arithmetic. See the \
180 `compile_fail` doctest on `Slab` for the rejection example.",
181 );
182
183 /// Construct a slab with an explicit freelist-protection policy.
184 ///
185 /// `capacity` must be `> 0` and ≤ `u32::MAX` — the slab uses 32-bit slot
186 /// indices internally. `T` must not be a ZST (a freelist over zero-sized
187 /// slots has no meaning); this is now enforced at **compile time** via
188 /// `ASSERT_T_NON_ZST` — instantiating `Slab<(), _, _>` is a build error
189 /// rather than a runtime `AllocError`.
190 pub fn with_protection(capacity: usize, backing: B, mac: M) -> Result<Self, AllocError> {
191 // Force compile-time evaluation of the ZST check. If `T` is a ZST
192 // the build fails here; otherwise the const is `()` and emits no
193 // code.
194 let _: () = Self::ASSERT_T_NON_ZST;
195 // capacity == 0 makes the slab unusable.
196 if capacity == 0 {
197 return Err(AllocError);
198 }
199 // ZST T: belt-and-braces — `ASSERT_T_NON_ZST` already rejected
200 // this at compile time, but a future generic path that somehow
201 // bypasses the const should still produce an honest error rather
202 // than dividing by zero downstream.
203 if size_of::<T>() == 0 {
204 return Err(AllocError);
205 }
206 // Slot indices fit in `u32`; reject overly large slabs up front.
207 let cap_u32 = u32::try_from(capacity).map_err(|_| AllocError)?;
208
209 // block_stride = max(size_of::<T>(), size_of::<FreeLink>()), then
210 // round up to max(align_of::<T>(), align_of::<FreeLink>()) so each
211 // slot is properly aligned for both views.
212 let slot_align = core::cmp::max(align_of::<T>(), align_of::<FreeLink>());
213 let raw_stride = core::cmp::max(size_of::<T>(), size_of::<FreeLink>());
214 // Round `raw_stride` up to `slot_align` without risking overflow on
215 // pathologically large `T`. `slot_align` is always a power of two so
216 // the mask is correct.
217 let block_stride = raw_stride
218 .checked_add(slot_align - 1)
219 .map(|v| v & !(slot_align - 1))
220 .ok_or(AllocError)?;
221
222 // Total bytes = capacity * block_stride.
223 let total = block_stride.checked_mul(capacity).ok_or(AllocError)?;
224 let backing_layout =
225 NonZeroLayout::from_size_align(total, slot_align).map_err(|_| AllocError)?;
226
227 let block = backing.allocate(backing_layout)?;
228 // Capture the OFFSET of the allocated region from `backing.base()`.
229 // We need a stable identifier that survives the imminent move of
230 // `backing` into `Self`; the offset is invariant under struct
231 // moves (the relative layout inside the backing is fixed), while
232 // an absolute `NonNull<u8>` captured here would point at the
233 // backing's pre-move address and silently corrupt every later
234 // access. See the struct-field comment on `base_offset`.
235 let block_addr = block.cast::<u8>().as_ptr() as usize;
236 let backing_base_addr = backing.base().as_ptr() as usize;
237 // `block_addr >= backing_base_addr` always for a fresh
238 // `backing.allocate(...)` whose backing implements `FixedRange`
239 // honestly. If the backing returns a pointer outside its own
240 // range, that's a backing bug, not ours — defend with a
241 // checked subtraction so we surface it as `AllocError` rather
242 // than producing a wrap-bounded offset that explodes later.
243 let base_offset = block_addr
244 .checked_sub(backing_base_addr)
245 .ok_or(AllocError)?;
246
247 // Pre-compute the pow2-stride shift; 0 is the "not pow2" sentinel
248 // (real strides are always ≥ 8, so pow2 strides have shift ≥ 3).
249 let stride_shift = if block_stride.is_power_of_two() {
250 block_stride.trailing_zeros()
251 } else {
252 0
253 };
254
255 Ok(Self {
256 backing,
257 mac,
258 base_offset,
259 free_head: UnsafeCell::new(0),
260 next_uncarved: UnsafeCell::new(0),
261 block_stride,
262 stride_shift,
263 capacity: cap_u32,
264 backing_layout,
265 corruption_events: AtomicUsize::new(0),
266 _phantom: PhantomData,
267 })
268 }
269
270 /// Resolve the slab's base pointer from the (current) backing
271 /// location plus the captured offset. Recomputing every call keeps
272 /// us safe against moves of the slab between construction and use.
273 #[inline]
274 fn base_ptr(&self) -> NonNull<u8> {
275 // SAFETY: `backing.base()` is the start of the backing's region;
276 // `base_offset` is `<= backing.size() - capacity*block_stride`
277 // (the backing.allocate at construction reserved that range).
278 // The resulting pointer is non-null because backing.base() is non-null.
279 unsafe { NonNull::new_unchecked(self.backing.base().as_ptr().add(self.base_offset)) }
280 }
281
282 /// Number of slots in this slab.
283 #[inline]
284 pub fn capacity(&self) -> usize {
285 self.capacity as usize
286 }
287
288 /// Bytes per slot (≥ `size_of::<T>()`).
289 #[inline]
290 pub fn block_stride(&self) -> usize {
291 self.block_stride
292 }
293
294 /// Borrow the underlying backing.
295 #[inline]
296 pub fn backing(&self) -> &B {
297 &self.backing
298 }
299
300 /// Pointer to slot `idx` (0-based). No bounds check — internal helper.
301 #[inline]
302 fn slot_ptr(&self, idx: u32) -> *mut u8 {
303 // SAFETY: base + idx*stride is in-range when idx < capacity. Callers
304 // verify the bound.
305 unsafe {
306 self.base_ptr()
307 .as_ptr()
308 .add(idx as usize * self.block_stride)
309 }
310 }
311
312 /// 0-based slot index for `ptr`, or `None` if it's not aligned to a slot
313 /// boundary or out of range.
314 #[inline]
315 fn slot_index(&self, ptr: NonNull<u8>) -> Option<u32> {
316 let p = ptr.as_ptr() as usize;
317 let base = self.base_ptr().as_ptr() as usize;
318 if p < base {
319 return None;
320 }
321 let offset = p - base;
322 // Pow2-stride fast path: replace `/ stride` and `% stride` with
323 // `>> shift` and `& (stride - 1)`. On x86-64 this removes a 20-40
324 // cycle integer divide from every deallocate when T's stride is a
325 // power of two (the common case: any T whose `size` and `align`
326 // are both powers of two and `size >= 8`).
327 let (idx, rem) = if self.stride_shift != 0 {
328 let mask = self.block_stride - 1;
329 (offset >> self.stride_shift, offset & mask)
330 } else {
331 (offset / self.block_stride, offset % self.block_stride)
332 };
333 if rem != 0 {
334 return None;
335 }
336 if idx >= self.capacity as usize {
337 return None;
338 }
339 // `idx < self.capacity` and `self.capacity: u32`, so `idx` always
340 // fits in `u32`. Use `try_from` to make that explicit and defend
341 // against future capacity-type changes.
342 u32::try_from(idx).ok()
343 }
344}
345
346unsafe impl<T, B: Allocator + FixedRange, M: FreelistProtection> Deallocator for Slab<T, B, M> {
347 /// Push the slot identified by `ptr` onto the freelist.
348 ///
349 /// # Safety
350 ///
351 /// Per the [`Deallocator`] contract, `ptr` must have been returned by a
352 /// previous call to `self.allocate(layout)`. Specifically:
353 ///
354 /// - `ptr` must lie at the base of a slot in this slab (not an offset
355 /// within a slot, not a pointer from another slab or allocator).
356 /// - The caller is responsible for running `T`'s destructor (e.g. via
357 /// `core::ptr::drop_in_place`) before calling `deallocate`. This method
358 /// overwrites the slot's bytes with a `FreeLink`.
359 /// - Passing the same `ptr` twice without an intervening `allocate` is a
360 /// double-free and is UB. **No protection level — including
361 /// `SipHashMAC` — detects a base-of-slot double-free.** The tripwire
362 /// (`next_idx <= capacity`) does not catch it: the second free rewrites
363 /// the slot's own `FreeLink` to point at the still-live head (an in-range
364 /// index), so later allocations alias the same live slot. `SipHashMAC`
365 /// does not catch it either, because the MAC binds a link to *its own
366 /// slot index*: the second free re-signs a perfectly valid MAC for that
367 /// same index in place, which then verifies on pop. The MAC's protection
368 /// is against a link *forged or relocated to a different slot* (and the
369 /// move-safety false-fail the index nonce fixes) — not an in-place
370 /// re-sign. *Detecting* double-free needs orthogonal per-slot state — a
371 /// live/free bit or a generation tag, e.g.
372 /// [`GenerationalSlab`](crate::GenerationalSlab), which rejects a stale
373 /// handle on its second free — which this slab does not carry by design.
374 /// A [`Quarantine`](crate::Quarantine) layer does *not* detect it (it
375 /// keeps no per-slot state and would forward both frees to the inner
376 /// slab); it only delays slot reuse, shrinking the window in which the
377 /// aliased slot is handed back out.
378 #[inline]
379 unsafe fn deallocate(&self, ptr: NonNull<u8>, _layout: NonZeroLayout) {
380 // Layout sanity: an honest caller's layout fits within block_stride.
381 // Release builds skip the check (per contract this is UB anyway).
382 debug_assert!(
383 _layout.size().get() <= self.block_stride
384 && _layout.align().get() <= align_of::<T>().max(align_of::<FreeLink>()),
385 "Slab::deallocate: layout exceeds slot stride or alignment",
386 );
387
388 // Resolve the slot index. In a properly used Slab the index is valid;
389 // an out-of-range pointer is UB (per the trait contract). Debug
390 // builds catch it; release builds skip.
391 let idx = match self.slot_index(ptr) {
392 Some(i) => i,
393 None => {
394 debug_assert!(false, "Slab::deallocate: pointer outside slab range");
395 return;
396 }
397 };
398
399 // Push slot onto the free list.
400 // SAFETY: !Sync — no concurrent access to free_head.
401 unsafe {
402 let head_ptr = self.free_head.get();
403 let old_head = *head_ptr; // 1-based; 0 means empty
404 // Bind the MAC to the slot INDEX, not its absolute address. The
405 // index is move-invariant; the address is not — `InlineBacked` and
406 // other move-relative backings change every slot's address when the
407 // `Slab` is moved (the very reason `slot_ptr` re-derives through
408 // `&self`). Signing over `ptr.as_ptr()` here but verifying over the
409 // re-derived `slot_ptr(idx)` in `allocate` would spuriously fail for
410 // any slab moved between a free and a later alloc. The index is an
411 // equally strong nonce: it uniquely identifies the slot, so a link
412 // copied to a different slot still fails verification.
413 let mac = self.mac.sign(old_head, idx as usize);
414 let link = FreeLink {
415 next_idx: old_head,
416 mac,
417 };
418 // Write the FreeLink into the slot's memory.
419 //
420 // Stacked Borrows: we MUST NOT write through the user-supplied
421 // `ptr` directly. `ptr`'s provenance was derived from the
422 // backing's `SharedReadWrite` tag at allocate time, and an
423 // outer wrapper (e.g. `Quarantine::drop`, `SlabOwner::drop`,
424 // `PoisonOnFree::drop`) may have taken a `&mut self` covering
425 // the whole composition — that Unique retag invalidates the
426 // older SharedReadWrite tag in the borrow stack. Writing
427 // through the stale tag is then UB.
428 //
429 // The fix is to re-derive the slot pointer through `&self`:
430 // `self.slot_ptr(idx)` calls `self.base_ptr()` which calls
431 // `self.backing.base()`, each of which traverses fresh shared
432 // reborrows. The resulting pointer sits at the top of the
433 // borrow stack and is valid even after the outer Unique
434 // retag. (Miri caught the original bug across
435 // SlabOwner / Quarantine / PoisonOnFree / etc.)
436 let slot_ptr = self.slot_ptr(idx);
437 slot_ptr.cast::<FreeLink>().write(link);
438 *head_ptr = idx + 1; // store 1-based
439 }
440 }
441}
442
443unsafe impl<T, B: Allocator + FixedRange, M: FreelistProtection> Allocator for Slab<T, B, M> {
444 #[inline]
445 fn allocate(&self, layout: NonZeroLayout) -> Result<NonNull<[u8]>, AllocError> {
446 // Reject layouts the slab cannot satisfy.
447 let req_align = layout.align().get();
448 let req_size = layout.size().get();
449 if req_align > align_of::<T>().max(align_of::<FreeLink>()) {
450 return Err(AllocError);
451 }
452 if req_size > self.block_stride {
453 return Err(AllocError);
454 }
455
456 // SAFETY: !Sync — no concurrent access to free_head or next_uncarved.
457 unsafe {
458 // Try to pop from the free list first.
459 let head_ptr = self.free_head.get();
460 let head = *head_ptr; // 1-based; 0 = empty
461 if head != 0 {
462 // head - 1 is the slot index.
463 let slot_idx = head - 1;
464 let slot = self.slot_ptr(slot_idx);
465 let link = slot.cast::<FreeLink>().read();
466 // Verify MAC. On corruption, drop the link (don't propagate)
467 // and fall through to next_uncarved — defense-in-depth, the
468 // attacker's poisoned link is now disarmed. The nonce is the
469 // slot INDEX (move-invariant), matching `deallocate`'s `sign`.
470 let mac_ok = self
471 .mac
472 .verify(link.next_idx, link.mac, slot_idx as usize)
473 .is_ok();
474 // Defense-in-depth: even with `NoProtection` (or a future MAC
475 // impl with a bug), reject a next_idx that would cause OOB
476 // slot_ptr arithmetic. A valid free-list entry can only point
477 // to a slot index in `0..capacity` (we store `idx+1` as 1-based
478 // and the slab is single-threaded). `next_idx > capacity` ⇒
479 // either corruption or an out-of-spec foreign write — treat
480 // the same as MAC failure.
481 if mac_ok && link.next_idx <= self.capacity {
482 *head_ptr = link.next_idx;
483 return Ok(NonNull::slice_from_raw_parts(
484 NonNull::new_unchecked(slot),
485 self.block_stride,
486 ));
487 } else {
488 // Record the corruption event BEFORE the debug_assert
489 // so the counter reflects the detection regardless of
490 // build profile: in release the assert is compiled out
491 // and only the counter remains; in debug the counter
492 // is updated and then the assert panics — but the
493 // counter increment is already visible to any panic
494 // handler (or `catch_unwind`-wrapped test) that
495 // inspects `corruption_events` post-detection. The
496 // event is the FIRST observable sign of an in-progress
497 // attack — silent disarm without the counter leaves
498 // operators blind. Ordering of the two statements
499 // matches `ExtendableSlab::deallocate` and
500 // `UntypedSlab::{allocate_slot, free_slot}` so the
501 // debug/release semantics agree across all
502 // corruption-detect sites.
503 //
504 // `Relaxed` is correct: the counter is advisory,
505 // monotonically increasing, and read eventually-
506 // consistently — no other state synchronizes against
507 // it.
508 self.corruption_events.fetch_add(1, Ordering::Relaxed);
509 debug_assert!(
510 false,
511 "Slab freelist corruption: mac_ok={mac_ok}, next_idx={}, capacity={}",
512 link.next_idx, self.capacity,
513 );
514 // Abandon the free list to prevent following corrupted
515 // links; force fresh allocation from next_uncarved.
516 *head_ptr = 0;
517 }
518 }
519 // Carve from next_uncarved.
520 let nxt_ptr = self.next_uncarved.get();
521 let nxt = *nxt_ptr;
522 if nxt >= self.capacity {
523 return Err(AllocError);
524 }
525 let slot = self.slot_ptr(nxt);
526 *nxt_ptr = nxt + 1;
527 Ok(NonNull::slice_from_raw_parts(
528 NonNull::new_unchecked(slot),
529 self.block_stride,
530 ))
531 }
532 }
533
534 #[inline]
535 unsafe fn usable_size(&self, ptr: NonNull<u8>, _layout: NonZeroLayout) -> Option<usize> {
536 // Every slot is `block_stride` bytes, which can exceed the requested
537 // `layout.size()` (e.g. `Slab<u8>` → 8-byte slots). Report the true
538 // usable extent so an outer scrub wrapper (`PoisonOnFree`/
539 // `ZeroizeOnFree`) wipes the WHOLE slot on free, not just the requested
540 // prefix — otherwise the stride-slack tail keeps freed secret bytes.
541 // Contract `n >= layout.size()` holds: `allocate` rejects
542 // `req_size > block_stride`.
543 debug_assert!(
544 self.slot_index(ptr).is_some(),
545 "Slab::usable_size: pointer outside slab range",
546 );
547 Some(self.block_stride)
548 }
549
550 #[inline]
551 fn capacity_bytes(&self) -> Option<usize> {
552 Some(self.capacity as usize * self.block_stride)
553 }
554
555 #[inline]
556 fn corruption_events(&self) -> u64 {
557 // Cast `usize → u64` at the trait boundary so the public API
558 // stays uniform across 32-bit and 64-bit targets. Lossless: on
559 // 32-bit hosts the inner counter is u32 (≤ u32::MAX ≈ 4.3 B);
560 // on 64-bit hosts it is u64.
561 self.corruption_events.load(Ordering::Relaxed) as u64
562 }
563}
564
565impl<T, B: Allocator + FixedRange, M: FreelistProtection> FixedRange for Slab<T, B, M> {
566 #[inline]
567 fn base(&self) -> NonNull<u8> {
568 self.base_ptr()
569 }
570
571 #[inline]
572 fn size(&self) -> usize {
573 self.capacity as usize * self.block_stride
574 }
575}
576
577impl<T, B: Allocator + FixedRange, M: FreelistProtection> Drop for Slab<T, B, M> {
578 fn drop(&mut self) {
579 // Debug-only sanity check: walk the freelist and verify every
580 // carved slot has been returned. A mismatch means the caller
581 // dropped the slab with live allocations outstanding — for
582 // `T: Drop` the destructor never runs, which is a real leak of
583 // resources owned by `T` (heap allocations inside `T`, file
584 // handles, locks, etc.). For `T: Copy` (or any `!Drop` type),
585 // the only loss is the un-reclaimed slot index, which is fine
586 // because the backing region drops on the next line anyway.
587 // We therefore skip the check when `T: !Drop` so existing test
588 // patterns (allocate-then-drop-slab on `u64`-style payloads)
589 // continue to compile cleanly.
590 //
591 // Walks freelist links by `next_idx` only (no MAC verification);
592 // a corrupted chain would either loop or land on an out-of-range
593 // index, both of which we detect explicitly.
594 //
595 // We compute the imbalance BEFORE returning the backing region so
596 // that — even if the eventual `debug_assert!` panics — the backing
597 // chunk is still released. Without this ordering an assertion-on-
598 // leak would *itself* leak the backing region (the asserting drop
599 // unwinds past the deallocate call), upgrading the bug we wanted
600 // to catch into a strictly worse leak.
601 //
602 // **Drop-during-unwind escalation**: the `debug_assert!` below
603 // only fires in debug builds, and only when the caller failed to
604 // free all live slots before drop. If the slab is being dropped
605 // as part of an in-flight panic-unwind AND a slot is leaked AND
606 // we are in a debug build, the assertion's panic-while-panicking
607 // triggers an **immediate process abort** (Rust language rule).
608 // Release builds never assert and never abort here. Treat the
609 // debug-only abort as a louder version of the leak-detection
610 // signal it already is — not as a regression. The condition is
611 // a caller bug (live slots at slab drop); the abort makes the
612 // bug impossible to ignore.
613 #[cfg(debug_assertions)]
614 let imbalance: Option<(u32, u32)> = if core::mem::needs_drop::<T>() {
615 // SAFETY: &mut self — exclusive access; the cells are owned.
616 let next_uncarved = unsafe { *self.next_uncarved.get() };
617 let mut head = unsafe { *self.free_head.get() };
618 let mut freelist_len: u32 = 0;
619 // Bound the walk to capacity to defend against a corrupted
620 // cycle (would otherwise loop forever).
621 while head != 0 && freelist_len <= self.capacity {
622 let slot_idx = head - 1;
623 if slot_idx >= self.capacity {
624 // Corrupted index — abandon the count; surface as a
625 // softer assertion below.
626 break;
627 }
628 let slot_ptr = self.slot_ptr(slot_idx);
629 // SAFETY: slot holds a FreeLink (we put it there in deallocate).
630 let link = unsafe { slot_ptr.cast::<FreeLink>().read() };
631 head = link.next_idx;
632 freelist_len += 1;
633 }
634 if freelist_len == next_uncarved {
635 None
636 } else {
637 Some((next_uncarved, freelist_len))
638 }
639 } else {
640 None
641 };
642 // SAFETY: base and backing_layout came from a single backing.allocate
643 // call in `with_protection`. We hold the only path to either field
644 // (no Clone impl, no exposed mutator). Run the deallocate BEFORE the
645 // assertion so a leaked-T panic does not also leak the backing.
646 // `base_ptr()` recomputes from the (post-move-safe) backing.base()
647 // and the captured offset — same address the construction site
648 // recorded into `base_offset`.
649 unsafe {
650 self.backing
651 .deallocate(self.base_ptr(), self.backing_layout)
652 };
653 #[cfg(debug_assertions)]
654 if let Some((next_uncarved, freelist_len)) = imbalance {
655 debug_assert!(
656 false,
657 "Slab dropped with {} live slot(s) (carved={}, freelist={}). \
658 Caller failed to deallocate all outstanding `T`s before drop — \
659 any T: Drop on those slots was leaked.",
660 next_uncarved - freelist_len,
661 next_uncarved,
662 freelist_len,
663 );
664 }
665 }
666}
667
668// Send if all components are Send. !Sync via UnsafeCell.
669unsafe impl<T, B, M> Send for Slab<T, B, M>
670where
671 T: Send,
672 B: Allocator + FixedRange + Send,
673 M: FreelistProtection + Send,
674{
675}
676
677// ============================================================================
678// Kani proof harnesses
679//
680// These prove correctness properties of the freelist push/pop and slot-index
681// recovery logic on a tiny slab. Kani enumerates all input combinations
682// symbolically; the proofs run under `cargo kani` only and are invisible to
683// stable builds.
684// ============================================================================
685
686// Kani proofs depend on `crate::backing::InlineBacked`; the `backing` module is gated
687// behind the `std` feature in this crate (see Cargo.toml), so the proof
688// module must be gated similarly. Kani CI must run with the `std`
689// feature enabled for these proofs to compile.
690#[cfg(all(kani, feature = "std"))]
691mod kani_proofs {
692 use super::*;
693 use crate::backing::InlineBacked;
694
695 /// Allocate-then-deallocate-then-allocate returns the SAME slot
696 /// pointer. This is the LIFO property of the freelist push/pop.
697 #[kani::proof]
698 #[kani::unwind(3)]
699 fn alloc_dealloc_alloc_returns_same_slot() {
700 let s: Slab<u64, InlineBacked<512>, NoProtection> =
701 Slab::new(8, InlineBacked::<512>::new()).unwrap();
702 let layout = NonZeroLayout::for_type::<u64>().unwrap();
703 let a = s.allocate(layout).unwrap().cast::<u8>();
704 let a_ptr = a.as_ptr();
705 unsafe { s.deallocate(a, layout) };
706 let b = s.allocate(layout).unwrap().cast::<u8>();
707 assert!(b.as_ptr() == a_ptr);
708 }
709
710 /// Two distinct live allocations never overlap. (Single-step
711 /// version — full coverage of N allocations would need a loop
712 /// Kani can unwind.)
713 #[kani::proof]
714 #[kani::unwind(3)]
715 fn two_allocs_never_overlap() {
716 let s: Slab<u64, InlineBacked<512>, NoProtection> =
717 Slab::new(8, InlineBacked::<512>::new()).unwrap();
718 let layout = NonZeroLayout::for_type::<u64>().unwrap();
719 let a = s.allocate(layout).unwrap().cast::<u8>();
720 let b = s.allocate(layout).unwrap().cast::<u8>();
721 assert!(a.as_ptr() != b.as_ptr());
722 }
723
724 /// Slot-index recovery from a returned pointer round-trips
725 /// correctly: the index computed from the pointer matches the
726 /// slot the allocator just carved out.
727 #[kani::proof]
728 #[kani::unwind(3)]
729 fn slot_index_round_trips() {
730 let s: Slab<u64, InlineBacked<512>, NoProtection> =
731 Slab::new(8, InlineBacked::<512>::new()).unwrap();
732 let layout = NonZeroLayout::for_type::<u64>().unwrap();
733 let a = s.allocate(layout).unwrap().cast::<u8>();
734 let idx = s.slot_index(a).expect("slot index must resolve");
735 // idx is 0-based; first carved slot is index 0.
736 assert!(idx == 0);
737 }
738}
739
740#[cfg(test)]
741mod tests {
742 use super::*;
743 use crate::backing::InlineBacked;
744
745 /// Test struct to exercise the slab.
746 #[derive(Debug, PartialEq)]
747 struct Foo(u64);
748
749 /// A backing big enough to hold many Foo slots.
750 /// Foo is 8 bytes, FreeLink is 8 bytes, so block_stride = 8.
751 /// 128 slots × 8 bytes = 1024 bytes — fits in InlineBacked<1024>.
752 fn make_slab() -> Slab<Foo, InlineBacked<1024>, NoProtection> {
753 Slab::new(128, InlineBacked::<1024>::new()).unwrap()
754 }
755
756 #[test]
757 fn capacity_matches() {
758 let s = make_slab();
759 assert_eq!(s.capacity(), 128);
760 assert_eq!(s.block_stride(), 8);
761 assert_eq!(s.capacity_bytes(), Some(1024));
762 }
763
764 /// `usable_size` reports the full slot stride, not the requested size, so
765 /// an outer `PoisonOnFree`/`ZeroizeOnFree` scrubs the whole slot on free.
766 /// `Slab<u8>` has a 1-byte type but an 8-byte stride (FreeLink floor).
767 #[test]
768 fn usable_size_reports_full_stride() {
769 let s: Slab<u8, InlineBacked<512>> = Slab::new(8, InlineBacked::<512>::new()).unwrap();
770 assert_eq!(s.block_stride(), 8);
771 let layout = NonZeroLayout::from_size_align(1, 1).unwrap();
772 let block = s.allocate(layout).unwrap();
773 let ptr = block.cast::<u8>();
774 let us = unsafe { s.usable_size(ptr, layout) };
775 assert_eq!(us, Some(8), "usable_size must report the full slot stride");
776 unsafe { s.deallocate(ptr, layout) };
777 }
778
779 #[test]
780 fn allocate_returns_distinct_slots() {
781 let s = make_slab();
782 let layout = NonZeroLayout::for_type::<Foo>().unwrap();
783 let a = s.allocate(layout).unwrap();
784 let b = s.allocate(layout).unwrap();
785 assert_ne!(a.cast::<u8>().as_ptr(), b.cast::<u8>().as_ptr());
786 // Slots are stride-apart.
787 assert_eq!(
788 (b.cast::<u8>().as_ptr() as usize) - (a.cast::<u8>().as_ptr() as usize),
789 8
790 );
791 }
792
793 #[test]
794 fn allocate_then_deallocate_reuses_slot() {
795 let s = make_slab();
796 let layout = NonZeroLayout::for_type::<Foo>().unwrap();
797 let a = s.allocate(layout).unwrap();
798 let a_addr = a.cast::<u8>().as_ptr();
799 unsafe { s.deallocate(a.cast(), layout) };
800 let b = s.allocate(layout).unwrap();
801 // LIFO — the just-freed slot should come back.
802 assert_eq!(a_addr, b.cast::<u8>().as_ptr());
803 }
804
805 #[test]
806 fn allocate_exhausts_capacity() {
807 let s: Slab<u64, InlineBacked<64>, NoProtection> =
808 Slab::new(8, InlineBacked::<64>::new()).unwrap();
809 let layout = NonZeroLayout::for_type::<u64>().unwrap();
810 for _ in 0..8 {
811 assert!(s.allocate(layout).is_ok());
812 }
813 assert!(s.allocate(layout).is_err());
814 }
815
816 #[test]
817 fn allocate_rejects_oversized_layout() {
818 let s = make_slab();
819 let too_big = NonZeroLayout::from_size_align(64, 8).unwrap();
820 assert!(s.allocate(too_big).is_err());
821 }
822
823 #[test]
824 fn allocate_rejects_overaligned_layout() {
825 let s = make_slab();
826 let over_aligned = NonZeroLayout::from_size_align(8, 64).unwrap();
827 assert!(s.allocate(over_aligned).is_err());
828 }
829
830 #[cfg(feature = "std")]
831 #[test]
832 fn alloc_dealloc_alloc_round_trip_many() {
833 let s: Slab<u64, InlineBacked<1024>, NoProtection> =
834 Slab::new(128, InlineBacked::<1024>::new()).unwrap();
835 let layout = NonZeroLayout::for_type::<u64>().unwrap();
836 // Allocate a bunch, free a bunch, re-allocate — all the freed slots
837 // come back from the freelist in LIFO order.
838 let mut ptrs = Vec::new();
839 for _ in 0..64 {
840 ptrs.push(s.allocate(layout).unwrap());
841 }
842 // Free in reverse order.
843 for p in ptrs.iter().rev() {
844 unsafe { s.deallocate(p.cast(), layout) };
845 }
846 // Re-allocate — should get them back in the original order (LIFO).
847 for p in ptrs.iter() {
848 let b = s.allocate(layout).unwrap();
849 assert_eq!(p.cast::<u8>().as_ptr(), b.cast::<u8>().as_ptr());
850 }
851 }
852
853 #[test]
854 fn pow2_stride_shift_is_set() {
855 // u64 stride is 8 (pow2) — shift must be 3.
856 let s: Slab<u64, InlineBacked<64>, NoProtection> =
857 Slab::new(8, InlineBacked::<64>::new()).unwrap();
858 assert_eq!(s.block_stride, 8);
859 assert_eq!(s.stride_shift, 3);
860 }
861
862 #[cfg(feature = "std")]
863 #[test]
864 fn non_pow2_stride_shift_is_zero_sentinel() {
865 // String is 24 bytes on 64-bit — not a power of two. Shift sentinel
866 // is 0, forcing the slow div/mod path in slot_index.
867 let s: Slab<String, InlineBacked<256>, NoProtection> =
868 Slab::new(8, InlineBacked::<256>::new()).unwrap();
869 assert_eq!(s.block_stride, 24);
870 assert_eq!(s.stride_shift, 0);
871 // Round-trip verifies both alloc and dealloc handle the non-pow2 path.
872 let layout = NonZeroLayout::for_type::<String>().unwrap();
873 let a = s.allocate(layout).unwrap();
874 unsafe { s.deallocate(a.cast(), layout) };
875 // After dealloc, the slot is back on the freelist — the next alloc
876 // must return the same pointer (LIFO).
877 let b = s.allocate(layout).unwrap();
878 assert_eq!(a.cast::<u8>().as_ptr(), b.cast::<u8>().as_ptr());
879 // Balance the alloc/dealloc so Slab's debug-only leak check passes
880 // on drop (we never wrote a String into the slot, so it's safe to
881 // free without drop_in_place).
882 unsafe { s.deallocate(b.cast(), layout) };
883 }
884
885 #[test]
886 fn fixed_range_contains_slots() {
887 let s = make_slab();
888 let layout = NonZeroLayout::for_type::<Foo>().unwrap();
889 let a = s.allocate(layout).unwrap();
890 assert!(s.contains(a.cast::<u8>()));
891 }
892
893 #[cfg(feature = "std")]
894 #[test]
895 fn slab_with_string_payload() {
896 // A larger T to verify block_stride > FreeLink size.
897 // String is ~24 bytes on 64-bit.
898 let s: Slab<String, InlineBacked<256>, NoProtection> =
899 Slab::new(8, InlineBacked::<256>::new()).unwrap();
900 let layout = NonZeroLayout::for_type::<String>().unwrap();
901 let a = s.allocate(layout).unwrap();
902 unsafe {
903 a.cast::<String>().as_ptr().write("hello".to_string());
904 // Then drop and free; we never re-read so this is safe.
905 core::ptr::drop_in_place(a.cast::<String>().as_ptr());
906 s.deallocate(a.cast(), layout);
907 }
908 }
909
910 /// Boundary: `Slab::new(0, _)` must fail — a zero-capacity slab is
911 /// useless and would underflow the index math at every `next_uncarved`
912 /// check (we use `>=` so 0 >= 0 correctly fails alloc, but rejecting at
913 /// construction is the documented contract).
914 #[test]
915 fn rejects_zero_capacity() {
916 let r = Slab::<u64, InlineBacked<64>, NoProtection>::new(0, InlineBacked::<64>::new());
917 assert!(r.is_err());
918 }
919
920 // Note: the previous runtime test `rejects_zst_payload` constructed
921 // `Slab::<(), InlineBacked<64>, NoProtection>::new(...)` and expected
922 // `Err(AllocError)`. That rejection was later promoted to a
923 // compile-time const_assert (`Slab::ASSERT_T_NON_ZST`), so the
924 // misuse can no longer be expressed as a runtime test — it would
925 // fail to compile at every call site. The equivalent pin lives as a
926 // `compile_fail` doctest on the `Slab` type's docs (line 98).
927
928 /// `capacity = usize::MAX` triggers either the u32 conversion guard or
929 /// the `block_stride * capacity` overflow guard — never panics.
930 #[test]
931 fn rejects_usize_max_capacity() {
932 let r =
933 Slab::<u64, InlineBacked<64>, NoProtection>::new(usize::MAX, InlineBacked::<64>::new());
934 assert!(r.is_err());
935 }
936
937 /// `T` whose `align_of` is 1 and `size_of` is 1 (e.g. `u8`) — stride
938 /// must round up to `size_of::<FreeLink>() = 8` so a freelist link
939 /// fits in the slot.
940 #[test]
941 fn stride_for_u8_payload_rounds_up_to_freelink_size() {
942 let s: Slab<u8, InlineBacked<128>, NoProtection> =
943 Slab::new(8, InlineBacked::<128>::new()).unwrap();
944 assert_eq!(
945 s.block_stride(),
946 8,
947 "u8 stride must round up to FreeLink size"
948 );
949 // And round-trip an allocation to confirm the freelist can store
950 // a FreeLink in the u8-sized slot.
951 let layout = NonZeroLayout::for_type::<u8>().unwrap();
952 let a = s.allocate(layout).unwrap();
953 unsafe { s.deallocate(a.cast(), layout) };
954 let b = s.allocate(layout).unwrap();
955 assert_eq!(a.cast::<u8>().as_ptr(), b.cast::<u8>().as_ptr());
956 unsafe { s.deallocate(b.cast(), layout) };
957 }
958
959 /// Allocate exactly `capacity` slots; the `capacity+1`-th allocate
960 /// must return `AllocError` (next_uncarved exhaustion).
961 #[test]
962 fn allocate_capacity_plus_one_returns_err() {
963 const CAP: usize = 4;
964 let s: Slab<u64, InlineBacked<64>, NoProtection> =
965 Slab::new(CAP, InlineBacked::<64>::new()).unwrap();
966 let layout = NonZeroLayout::for_type::<u64>().unwrap();
967 // capacity allocs all succeed.
968 for i in 0..CAP {
969 assert!(s.allocate(layout).is_ok(), "slot {i} should succeed");
970 }
971 // The (CAP+1)-th must fail.
972 assert!(
973 s.allocate(layout).is_err(),
974 "alloc past capacity must return AllocError",
975 );
976 }
977
978 /// Boundary: a release-build double-free is documented UB, but the
979 /// debug build's `debug_assert!` on stride alignment catches a
980 /// pointer pulled from a freelist-link slot's `next_idx` byte (which
981 /// is NOT on a stride boundary).
982 ///
983 /// Direct verification: deallocate the same slot twice without
984 /// `debug_assertions` would corrupt; in debug, the slot's `next_idx`
985 /// loops onto itself in the freelist and the next allocate either
986 /// returns the same slot OR loops via the defense-in-depth
987 /// `next_idx > capacity` rejection. We can't assert UB safely, but
988 /// we can check that a single allocate after a (legitimate) free
989 /// returns the LIFO-correct slot.
990 #[test]
991 fn lifo_property_holds_after_alloc_dealloc_realloc() {
992 let s: Slab<u64, InlineBacked<128>, NoProtection> =
993 Slab::new(16, InlineBacked::<128>::new()).unwrap();
994 let layout = NonZeroLayout::for_type::<u64>().unwrap();
995 // Regression: slab.base() must agree with the slab's owned backing's
996 // base. A prior bug stored an absolute pointer captured BEFORE the
997 // backing moved into Self, leaving `slab.base` pointing at the old
998 // location of the InlineBacked's storage. That pointer was stale
999 // for the rest of the slab's life and writes through it landed
1000 // in someone else's stack frame.
1001 use forge_alloc_core::FixedRange;
1002 let backing_storage = s.backing().base();
1003 assert_eq!(
1004 s.base().as_ptr(),
1005 backing_storage.as_ptr(),
1006 "Slab base pointer must agree with current backing.base() — \
1007 stale-pointer bug if not",
1008 );
1009 // Alloc 3, free in reverse, re-alloc in order. Each re-alloc
1010 // returns the most-recently-freed slot.
1011 let a = s.allocate(layout).unwrap();
1012 let b = s.allocate(layout).unwrap();
1013 let c = s.allocate(layout).unwrap();
1014 let a_addr = a.cast::<u8>().as_ptr();
1015 let b_addr = b.cast::<u8>().as_ptr();
1016 let c_addr = c.cast::<u8>().as_ptr();
1017 unsafe {
1018 s.deallocate(a.cast(), layout);
1019 s.deallocate(b.cast(), layout);
1020 s.deallocate(c.cast(), layout);
1021 }
1022 // Free order: a, b, c — so head is c. LIFO: alloc returns c, b, a.
1023 let r1 = s.allocate(layout).unwrap().cast::<u8>().as_ptr();
1024 let r2 = s.allocate(layout).unwrap().cast::<u8>().as_ptr();
1025 let r3 = s.allocate(layout).unwrap().cast::<u8>().as_ptr();
1026 assert_eq!(r1, c_addr);
1027 assert_eq!(r2, b_addr);
1028 assert_eq!(r3, a_addr);
1029 }
1030
1031 #[cfg(feature = "siphasher")]
1032 #[test]
1033 fn siphash_protected_slab_round_trips() {
1034 use forge_alloc_core::SipHashMAC;
1035 let s: Slab<u64, InlineBacked<1024>, SipHashMAC> = Slab::with_protection(
1036 128,
1037 InlineBacked::<1024>::new(),
1038 SipHashMAC::with_key([0x42; 16]),
1039 )
1040 .unwrap();
1041 let layout = NonZeroLayout::for_type::<u64>().unwrap();
1042 let a = s.allocate(layout).unwrap();
1043 unsafe { s.deallocate(a.cast(), layout) };
1044 let b = s.allocate(layout).unwrap();
1045 assert_eq!(a.cast::<u8>().as_ptr(), b.cast::<u8>().as_ptr());
1046 }
1047
1048 /// Regression: the freelist MAC must survive the slab being MOVED between a
1049 /// free and a later alloc. `InlineBacked`'s storage is structure-relative,
1050 /// so a move changes every slot's absolute address — the MAC nonce is the
1051 /// slot INDEX (move-invariant). Were it the free-time absolute address (the
1052 /// bug this guards), popping the freed slot after the move would false-fail
1053 /// verification, bump `corruption_events`, leak the slot, and carve a fresh
1054 /// one instead of reusing slot 0.
1055 #[cfg(feature = "siphasher")]
1056 #[test]
1057 fn siphash_freelist_survives_move() {
1058 use forge_alloc_core::SipHashMAC;
1059 let s: Slab<u64, InlineBacked<1024>, SipHashMAC> = Slab::with_protection(
1060 128,
1061 InlineBacked::<1024>::new(),
1062 SipHashMAC::with_key([0x99; 16]),
1063 )
1064 .unwrap();
1065 let layout = NonZeroLayout::for_type::<u64>().unwrap();
1066 let a = s.allocate(layout).unwrap();
1067 assert_eq!(s.slot_index(a.cast()), Some(0));
1068 unsafe { s.deallocate(a.cast(), layout) };
1069 // Move the slab; the slot region relocates.
1070 let moved = s;
1071 let b = moved.allocate(layout).unwrap();
1072 assert_eq!(
1073 moved.corruption_events(),
1074 0,
1075 "freelist MAC must not false-fail across a move",
1076 );
1077 assert_eq!(
1078 moved.slot_index(b.cast()),
1079 Some(0),
1080 "freed slot 0 must be reused, not abandoned to a false corruption",
1081 );
1082 }
1083}