Skip to main content

forge_alloc/hardening/
split_metadata.rs

1//! `SplitMetadata<I>` — hot/cold metadata isolation: two distinct mmap
2//! regions, one for allocator bookkeeping and one for user data.
3//!
4//! Wraps an [`OsBacked`] allocator (the data region) and pairs it with
5//! a separate [`MmapBacked`] region for metadata. Forwards
6//! [`Allocator`] / [`OsBacked`] / [`FixedRange`] to the data region;
7//! exposes the metadata region via [`meta_base`](SplitMetadata::meta_base)
8//! and [`meta_size`](SplitMetadata::meta_size) for callers (typically a
9//! hardened slab) that want to keep their free-list / block-state /
10//! canary storage in an *unrelated* mapping rather than interleaved with
11//! the data region's cache lines.
12//!
13//! # What this does and does not guarantee
14//!
15//! Metadata lives in a **separate `mmap`** at an unrelated virtual
16//! address, so it cannot be polluted by, or share cache lines with, the
17//! data region. That is the guarantee.
18//!
19//! It does **not** by itself guarantee that a *linear overflow* past a
20//! data allocation cannot reach metadata: the two mappings come from two
21//! independent `mmap`/`VirtualAlloc` calls and the OS is free to place
22//! them adjacent in the address space, so overflow isolation is
23//! *probabilistic* (it holds under ASLR but is not enforced here). For a
24//! **hard** overflow barrier, compose with [`GuardPage`] on the data side
25//! (`GuardPage<SplitMetadata<MmapBacked>>`) — the `#[must_use]` on this
26//! type nudges toward exactly that recipe — which traps an overflow with
27//! a fault before it can reach anything, adjacent or not.
28//!
29//! [`GuardPage`]: crate::hardening::GuardPage
30//!
31//! See `docs/ARCHITECTURE.md` for design context.
32
33use core::ptr::NonNull;
34
35use crate::backing::MmapBacked;
36use forge_alloc_core::{
37    AllocError, Allocator, Deallocator, FixedRange, NonZeroLayout, OsBacked, ProtectFlags,
38};
39
40/// SplitMetadata wrapper.
41///
42/// **Note**: this primitive isolates allocator metadata into a separate
43/// mapping (cache-line / pollution separation). It does **not** by itself
44/// enforce that a linear overflow cannot reach metadata — two independent
45/// mmaps may land adjacent; see the module docs. For a hard overflow
46/// barrier, wrap the data side in [`GuardPage`](crate::hardening::GuardPage).
47///
48/// Adding guard pages on top requires the **inner data region** to be
49/// `OsBacked` (so `GuardPage<SplitMetadata<MmapBacked>>` works, but
50/// `GuardPage<SplitMetadata<Slab<...>>>` does not — `Slab` is not
51/// `OsBacked` and `GuardPage` rejects it at the type level). For the
52/// `HardenedSlab` recipe — the recommended security composition — the
53/// guard pages wrap the OS-mapped data side and the `Slab` lives on
54/// top, giving the form `Slab<T, GuardPage<SplitMetadata<MmapBacked>>>`.
55#[must_use = "SplitMetadata guards the data region only. \
56              For full coverage, compose with GuardPage<_> on an \
57              OsBacked inner: `GuardPage<SplitMetadata<MmapBacked>>` \
58              (then place a Slab on top via `Slab<T, GuardPage<SplitMetadata<MmapBacked>>>`)"]
59pub struct SplitMetadata<I: Allocator> {
60    /// Metadata region — held by value so its `munmap` runs when this
61    /// wrapper drops.
62    meta_region: MmapBacked,
63    /// User-visible data region. All `Allocator` / `OsBacked` /
64    /// `FixedRange` calls forward here.
65    data_region: I,
66}
67
68impl<I: Allocator> SplitMetadata<I> {
69    /// Wrap `data_region` and allocate a fresh metadata mmap of
70    /// `meta_size` bytes.
71    ///
72    /// Returns `Err(AllocError)` if the metadata mmap fails.
73    pub fn new(data_region: I, meta_size: usize) -> Result<Self, AllocError> {
74        let meta_region = MmapBacked::new(meta_size)?;
75        Ok(Self {
76            meta_region,
77            data_region,
78        })
79    }
80
81    /// Wrap with a pre-built metadata region. Useful when the caller
82    /// wants to construct the meta `MmapBacked` with specific flags
83    /// (huge pages, populate, etc.).
84    ///
85    /// **Precondition:** `meta_region` must not overlap the `data_region`'s
86    /// address range. A fresh `MmapBacked::new(..)` always satisfies this
87    /// (the OS returns a distinct mapping); the caveat exists only if you
88    /// hand in a region derived from the data side. Overlap would silently
89    /// defeat the isolation this type provides — it is not checked here.
90    pub fn with_meta(data_region: I, meta_region: MmapBacked) -> Self {
91        Self {
92            meta_region,
93            data_region,
94        }
95    }
96
97    /// First byte of the metadata region.
98    #[inline]
99    pub fn meta_base(&self) -> NonNull<u8> {
100        self.meta_region.base_ptr()
101    }
102
103    /// Length in bytes of the metadata region.
104    #[inline]
105    pub fn meta_size(&self) -> usize {
106        self.meta_region.region_size()
107    }
108
109    /// Borrow the metadata mmap directly. Mainly for callers that
110    /// want to apply `OsBacked` ops (release_pages, protect) to the
111    /// meta region independently.
112    #[inline]
113    pub fn meta(&self) -> &MmapBacked {
114        &self.meta_region
115    }
116
117    /// Borrow the data allocator.
118    #[inline]
119    pub fn data(&self) -> &I {
120        &self.data_region
121    }
122}
123
124unsafe impl<I: Allocator> Deallocator for SplitMetadata<I> {
125    #[inline]
126    unsafe fn deallocate(&self, ptr: NonNull<u8>, layout: NonZeroLayout) {
127        // SAFETY: forwarded; caller's contract preserved against data.
128        unsafe { self.data_region.deallocate(ptr, layout) }
129    }
130}
131
132unsafe impl<I: Allocator> Allocator for SplitMetadata<I> {
133    #[inline]
134    fn allocate(&self, layout: NonZeroLayout) -> Result<NonNull<[u8]>, AllocError> {
135        self.data_region.allocate(layout)
136    }
137
138    #[inline]
139    unsafe fn usable_size(&self, ptr: NonNull<u8>, layout: NonZeroLayout) -> Option<usize> {
140        // Layout-transparent forwarder (data side): `allocate` returns the
141        // data region's block unchanged, so forward `usable_size` too — else an
142        // outer scrub wrapper over `SplitMetadata` would miss the slack tail.
143        // SAFETY: forwarded; caller upholds usable_size's contract on data.
144        unsafe { self.data_region.usable_size(ptr, layout) }
145    }
146
147    #[inline]
148    fn capacity_bytes(&self) -> Option<usize> {
149        self.data_region.capacity_bytes()
150    }
151
152    #[inline]
153    fn corruption_events(&self) -> u64 {
154        self.data_region.corruption_events()
155    }
156}
157
158// OsBacked is implemented only when the data region is itself OsBacked —
159// i.e. SplitMetadata wraps an MmapBacked directly. When the data region
160// is a higher-layer type (e.g. Slab), `release_pages` / `protect` make
161// no sense at this layer and are not forwarded.
162unsafe impl<I: Allocator + OsBacked> OsBacked for SplitMetadata<I> {
163    #[inline]
164    fn base_ptr(&self) -> NonNull<u8> {
165        self.data_region.base_ptr()
166    }
167
168    #[inline]
169    fn region_size(&self) -> usize {
170        self.data_region.region_size()
171    }
172
173    #[inline]
174    unsafe fn release_pages(&self, ptr: NonNull<u8>, size: usize) {
175        // SAFETY: forwarded; affects data region only.
176        unsafe { self.data_region.release_pages(ptr, size) }
177    }
178
179    #[inline]
180    unsafe fn protect(&self, ptr: NonNull<u8>, size: usize, flags: ProtectFlags) {
181        // SAFETY: forwarded.
182        unsafe { self.data_region.protect(ptr, size, flags) }
183    }
184}
185
186impl<I: Allocator + FixedRange> FixedRange for SplitMetadata<I> {
187    #[inline]
188    fn base(&self) -> NonNull<u8> {
189        self.data_region.base()
190    }
191
192    #[inline]
193    fn size(&self) -> usize {
194        self.data_region.size()
195    }
196
197    /// Forward to the data region. The metadata lives in a *separate* mmap,
198    /// so `base`/`size` already track `data_region` 1:1 — no offset
199    /// translation is needed, and a `commit`-aware consumer over a
200    /// `lazy_commit` data region reaches the right pages.
201    #[inline]
202    fn commit(&self, offset: usize, len: usize) -> Result<(), AllocError> {
203        self.data_region.commit(offset, len)
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    /// MmapBacked alone is the simplest OsBacked that satisfies our
212    /// bound. Most real usage wraps a slab or higher-layer type, but
213    /// the wrapper semantics are testable with a bare data region.
214    fn build(data_size: usize, meta_size: usize) -> SplitMetadata<MmapBacked> {
215        let data = MmapBacked::new(data_size).unwrap();
216        SplitMetadata::new(data, meta_size).unwrap()
217    }
218
219    #[test]
220    #[cfg_attr(miri, ignore = "miri-incompatible: mmap / threads")]
221    fn meta_and_data_regions_are_disjoint_virtual_addresses() {
222        let sm = build(64 * 1024, 64 * 1024);
223        let meta = sm.meta_base().as_ptr() as usize;
224        let meta_end = meta + sm.meta_size();
225        let data = sm.base_ptr().as_ptr() as usize;
226        let data_end = data + sm.region_size();
227        // The two regions must NOT overlap — that's the isolation guarantee.
228        // Note: disjoint ≠ non-adjacent. This proves the cache/pollution
229        // isolation the type guarantees, not the (probabilistic, OS-placement-
230        // dependent) overflow isolation — see the module docs.
231        assert!(
232            meta_end <= data || data_end <= meta,
233            "regions overlap: meta=[{meta:x}, {meta_end:x}) data=[{data:x}, {data_end:x})",
234        );
235    }
236
237    #[test]
238    #[cfg_attr(miri, ignore = "miri-incompatible: mmap / threads")]
239    fn forwards_allocate_to_data_region() {
240        let sm = build(64 * 1024, 16 * 1024);
241        let layout = NonZeroLayout::from_size_align(128, 8).unwrap();
242        let block = sm.allocate(layout).unwrap();
243        let p = block.cast::<u8>().as_ptr() as usize;
244        let data = sm.base_ptr().as_ptr() as usize;
245        let data_end = data + sm.region_size();
246        assert!(
247            p >= data && p < data_end,
248            "allocation must come from data region",
249        );
250        unsafe { sm.deallocate(block.cast(), layout) };
251    }
252
253    #[test]
254    #[cfg_attr(miri, ignore = "miri-incompatible: mmap / threads")]
255    fn allocations_never_touch_metadata_region() {
256        let sm = build(64 * 1024, 16 * 1024);
257        let meta_base = sm.meta_base().as_ptr() as usize;
258        let meta_end = meta_base + sm.meta_size();
259        let layout = NonZeroLayout::from_size_align(256, 8).unwrap();
260        for _ in 0..32 {
261            let block = sm.allocate(layout).unwrap();
262            let start = block.cast::<u8>().as_ptr() as usize;
263            let end = start + 256;
264            assert!(
265                end <= meta_base || start >= meta_end,
266                "allocation crosses metadata region",
267            );
268        }
269    }
270
271    #[test]
272    #[cfg_attr(miri, ignore = "miri-incompatible: mmap / threads")]
273    fn meta_region_is_writable() {
274        // The meta region exists so callers can use it for arbitrary
275        // bookkeeping. Verify by writing and reading back.
276        let sm = build(64 * 1024, 4 * 1024);
277        let base = sm.meta_base().as_ptr();
278        unsafe {
279            core::ptr::write_bytes(base, 0xAB, 4 * 1024);
280            for i in [0, 100, 1000, 4095] {
281                assert_eq!(*base.add(i), 0xAB);
282            }
283        }
284    }
285
286    #[test]
287    #[cfg_attr(miri, ignore = "miri-incompatible: mmap / threads")]
288    fn fixed_range_reports_data_region() {
289        let sm = build(64 * 1024, 16 * 1024);
290        assert_eq!(sm.base().as_ptr(), sm.data().base_ptr().as_ptr());
291        assert_eq!(sm.size(), sm.data().region_size());
292    }
293
294    #[test]
295    #[cfg_attr(miri, ignore = "miri-incompatible: mmap / threads")]
296    fn protect_only_affects_data_region() {
297        // PROT_NONE on the data region must not affect meta; verify
298        // by re-writing meta after.
299        let sm = build(64 * 1024, 4 * 1024);
300        unsafe {
301            // First: write to meta to confirm it's accessible.
302            core::ptr::write_bytes(sm.meta_base().as_ptr(), 0x11, 4 * 1024);
303            // Now PROT_NONE the data region.
304            sm.protect(sm.base_ptr(), sm.region_size(), ProtectFlags::NONE);
305            // Meta should STILL be writable.
306            core::ptr::write_bytes(sm.meta_base().as_ptr(), 0x22, 4 * 1024);
307            // Restore data so Drop can unmap it (some kernels require
308            // PROT_READ|PROT_WRITE on unmap; defensive).
309            sm.protect(sm.base_ptr(), sm.region_size(), ProtectFlags::RW);
310        }
311    }
312}