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}