Skip to main content

forge_alloc/hardening/
guard_page.rs

1//! `GuardPage<I>` — wraps an [`OsBacked`] allocator with leading and trailing
2//! unmapped guard pages. Walking past the usable region (linear overflow,
3//! out-of-bounds write) triggers a fault rather than corrupting adjacent
4//! memory.
5//!
6//! Unlike a thin "decorator" wrapper, `GuardPage` does not forward to
7//! `inner.allocate`. The inner OsBacked's own bump cursor is unaware of the
8//! protected pages and would happily hand out pointers inside them. Instead,
9//! `GuardPage` carves the usable subrange (`[base + page_size, base +
10//! region_size - page_size)`) at construction and manages its own cursor
11//! inside that subrange.
12//!
13//! See `docs/ARCHITECTURE.md` for the composable-wrapper design.
14
15use core::cell::UnsafeCell;
16use core::ptr::NonNull;
17
18use forge_alloc_core::{
19    AllocError, Allocator, Deallocator, FixedRange, NonZeroLayout, OsBacked, ProtectFlags,
20};
21
22/// Guard-page wrapper.
23///
24/// At construction, `inner.protect(...)` marks the first and last
25/// `page_size` bytes of the inner region as `PROT_NONE`. The wrapper then
26/// allocates bump-style from the strictly-interior subrange.
27///
28/// # Thread safety
29///
30/// `Send` when `I: Send`. `Sync`: NO — the cursor uses `UnsafeCell`.
31pub struct GuardPage<I: OsBacked> {
32    inner: I,
33    page_size: usize,
34    /// Base of the usable subrange (inner.base + page_size).
35    base: NonNull<u8>,
36    /// Bytes of usable subrange (inner.region - 2*page_size).
37    usable_size: usize,
38    /// Bump cursor inside the usable subrange.
39    cursor: UnsafeCell<usize>,
40}
41
42impl<I: OsBacked> GuardPage<I> {
43    /// Wrap with leading + trailing guard pages of `page_size` bytes each.
44    ///
45    /// Errors if `page_size` is zero / not a power of two, if `inner.base_ptr()`
46    /// is not `page_size`-aligned, or if the region is too small.
47    pub fn new(inner: I, page_size: usize) -> Result<Self, AllocError> {
48        if page_size == 0 || !page_size.is_power_of_two() {
49            return Err(AllocError);
50        }
51        let region_size = inner.region_size();
52        let needed = 2usize
53            .checked_mul(page_size)
54            .and_then(|v| v.checked_add(1))
55            .ok_or(AllocError)?;
56        if region_size < needed {
57            return Err(AllocError);
58        }
59        let base_addr = inner.base_ptr().as_ptr() as usize;
60        if base_addr & (page_size - 1) != 0 {
61            return Err(AllocError);
62        }
63        // The tail guard sits at `base + region_size - page_size`; that address
64        // is only page-aligned if `region_size` is a multiple of `page_size`.
65        // `OsBacked` requires a page-rounded `region_size`, but verify it here
66        // so a non-conforming backing can't yield a misaligned `protect` range
67        // (which on Unix rounds the start down and would silently extend
68        // PROT_NONE into the usable region).
69        if region_size & (page_size - 1) != 0 {
70            return Err(AllocError);
71        }
72
73        // Install guards.
74        // The `protect` trait is infallible-by-signature, but the underlying
75        // syscall (mprotect / VirtualProtect) can fail. For a security
76        // wrapper this is critical: a silent mprotect failure leaves the
77        // "guard" pages writable, defeating the entire purpose. Drain the
78        // per-thread last-error slot before each call and abort construction
79        // if either protect raised an error.
80        // SAFETY: head/tail ranges lie inside the inner region per the checks
81        // above; no live allocations have been served by us yet, and inner's
82        // own cursor is at 0 (fresh).
83        crate::backing::mmap_clear_last_os_error();
84        unsafe {
85            inner.protect(inner.base_ptr(), page_size, ProtectFlags::NONE);
86        }
87        if crate::backing::mmap_last_os_error().is_some() {
88            return Err(AllocError);
89        }
90        // SAFETY: same as above; tail range lies at the very end of the
91        // inner region per the size check.
92        unsafe {
93            let tail = inner.base_ptr().as_ptr().add(region_size - page_size);
94            inner.protect(NonNull::new_unchecked(tail), page_size, ProtectFlags::NONE);
95        }
96        if crate::backing::mmap_last_os_error().is_some() {
97            return Err(AllocError);
98        }
99
100        // SAFETY: base + page_size is in-range (region_size > page_size verified).
101        let base = unsafe { NonNull::new_unchecked(inner.base_ptr().as_ptr().add(page_size)) };
102        let usable_size = region_size - 2 * page_size;
103
104        Ok(Self {
105            inner,
106            page_size,
107            base,
108            usable_size,
109            cursor: UnsafeCell::new(0),
110        })
111    }
112
113    /// Borrow the inner allocator.
114    #[inline]
115    pub fn inner(&self) -> &I {
116        &self.inner
117    }
118
119    /// Guard page size in bytes.
120    #[inline]
121    pub fn page_size(&self) -> usize {
122        self.page_size
123    }
124
125    /// Bytes currently allocated from the usable subrange.
126    #[inline]
127    pub fn allocated(&self) -> usize {
128        // SAFETY: !Sync.
129        unsafe { *self.cursor.get() }
130    }
131}
132
133unsafe impl<I: OsBacked> Deallocator for GuardPage<I> {
134    #[inline]
135    unsafe fn deallocate(&self, _ptr: NonNull<u8>, _layout: NonZeroLayout) {
136        // Bump-style: reclaim via drop.
137    }
138}
139
140unsafe impl<I: OsBacked> Allocator for GuardPage<I> {
141    fn allocate(&self, layout: NonZeroLayout) -> Result<NonNull<[u8]>, AllocError> {
142        let align = layout.align().get();
143        let size = layout.size().get();
144        let base_addr = self.base.as_ptr() as usize;
145        // SAFETY: !Sync.
146        unsafe {
147            let cursor_ptr = self.cursor.get();
148            let cur = *cursor_ptr;
149            let raw = base_addr.checked_add(cur).ok_or(AllocError)?;
150            let aligned = raw.checked_add(align - 1).ok_or(AllocError)? & !(align - 1);
151            let aligned_off = aligned - base_addr;
152            let end_off = aligned_off.checked_add(size).ok_or(AllocError)?;
153            if end_off > self.usable_size {
154                return Err(AllocError);
155            }
156            *cursor_ptr = end_off;
157            let p = self.base.as_ptr().add(aligned_off);
158            Ok(NonNull::slice_from_raw_parts(
159                NonNull::new_unchecked(p),
160                size,
161            ))
162        }
163    }
164
165    #[inline]
166    fn capacity_bytes(&self) -> Option<usize> {
167        Some(self.usable_size)
168    }
169
170    #[inline]
171    fn corruption_events(&self) -> u64 {
172        // GuardPage doesn't have a Rust-observable corruption site —
173        // the guard pages trap via SIGSEGV / VirtualProtect at the OS
174        // level, never returning control. The inner allocator is the
175        // backing region, not a wrappable allocator; no forward needed.
176        0
177    }
178
179    // No `usable_size` override — it correctly defaults to `None`. GuardPage is
180    // its own bump allocator over the guarded middle region and hands back
181    // exactly `layout.size()` bytes with no rounding slack, so an outer scrub
182    // wrapper's `unwrap_or(layout.size())` fallback already scrubs the precise
183    // extent. (Contrast `HugePageAligned`, which is size-transparent over an
184    // inner that may report slack and therefore *does* forward usable_size; and
185    // `Canary`, which hands back a sub-slice and must withhold. GuardPage is in
186    // the third category: it owns the allocation and reports nothing because
187    // there is nothing extra to report.)
188}
189
190impl<I: OsBacked> FixedRange for GuardPage<I> {
191    /// Base of the usable subrange (not the inner region).
192    #[inline]
193    fn base(&self) -> NonNull<u8> {
194        self.base
195    }
196
197    /// Usable bytes (inner region minus the two guard pages).
198    #[inline]
199    fn size(&self) -> usize {
200        self.usable_size
201    }
202}
203
204// Send when I: Send. !Sync via UnsafeCell.
205unsafe impl<I: OsBacked + Send> Send for GuardPage<I> {}
206
207#[cfg(test)]
208#[cfg(feature = "std")]
209mod tests {
210    use super::*;
211    use crate::backing::{page_size, MmapBacked};
212
213    #[test]
214    #[cfg_attr(miri, ignore = "miri-incompatible: mmap / threads")]
215    fn construct_succeeds_for_large_region() {
216        let inner = MmapBacked::new(64 * 1024).unwrap();
217        let g = GuardPage::new(inner, page_size()).unwrap();
218        assert!(g.capacity_bytes().unwrap() >= 8 * 1024);
219    }
220
221    #[test]
222    #[cfg_attr(miri, ignore = "miri-incompatible: mmap / threads")]
223    fn construct_rejects_undersized_region() {
224        let inner = MmapBacked::new(4096).unwrap();
225        assert!(GuardPage::new(inner, page_size()).is_err());
226    }
227
228    #[test]
229    #[cfg_attr(miri, ignore = "miri-incompatible: mmap / threads")]
230    fn construct_rejects_non_power_of_two_page() {
231        let inner = MmapBacked::new(64 * 1024).unwrap();
232        assert!(GuardPage::new(inner, 3000).is_err());
233    }
234
235    #[test]
236    #[cfg_attr(miri, ignore = "miri-incompatible: mmap / threads")]
237    fn allocate_within_usable_doesnt_fault() {
238        let inner = MmapBacked::new(64 * 1024).unwrap();
239        let g = GuardPage::new(inner, page_size()).unwrap();
240        let layout = NonZeroLayout::from_size_align(256, 8).unwrap();
241        let block = g.allocate(layout).unwrap();
242        let p = block.cast::<u8>();
243        unsafe { core::ptr::write_bytes(p.as_ptr(), 0xAA, 256) };
244    }
245
246    #[test]
247    #[cfg_attr(miri, ignore = "miri-incompatible: mmap / threads")]
248    fn allocate_returns_ptr_past_head_guard() {
249        let inner = MmapBacked::new(64 * 1024).unwrap();
250        let inner_base = inner.base_ptr().as_ptr() as usize;
251        let g = GuardPage::new(inner, page_size()).unwrap();
252        let layout = NonZeroLayout::from_size_align(8, 8).unwrap();
253        let block = g.allocate(layout).unwrap();
254        let p_addr = block.cast::<u8>().as_ptr() as usize;
255        assert!(
256            p_addr >= inner_base + page_size(),
257            "allocation must sit beyond the head guard"
258        );
259    }
260
261    #[test]
262    #[cfg_attr(miri, ignore = "miri-incompatible: mmap / threads")]
263    fn allocate_rejects_oversized_request() {
264        let inner = MmapBacked::new(64 * 1024).unwrap();
265        let g = GuardPage::new(inner, page_size()).unwrap();
266        let huge = NonZeroLayout::from_size_align(64 * 1024, 1).unwrap();
267        assert!(g.allocate(huge).is_err());
268    }
269
270    #[test]
271    #[cfg_attr(miri, ignore = "miri-incompatible: mmap / threads")]
272    fn fixed_range_excludes_guards() {
273        let inner = MmapBacked::new(64 * 1024).unwrap();
274        let inner_base = inner.base_ptr().as_ptr() as usize;
275        let inner_size = inner.region_size();
276        let g = GuardPage::new(inner, page_size()).unwrap();
277        assert_eq!(g.base().as_ptr() as usize, inner_base + page_size());
278        assert_eq!(g.size(), inner_size - 2 * page_size());
279    }
280
281    /// Backing that wraps a real `MmapBacked` but whose `protect` always fails
282    /// — it provokes a genuine OS error and records it into the shared
283    /// last-error slot, exactly as a failed mprotect/VirtualProtect would
284    /// inside the real backing. Used to prove `GuardPage::new` aborts on a
285    /// guard-install failure instead of silently shipping writable guards.
286    struct FailingProtect(MmapBacked);
287
288    unsafe impl Deallocator for FailingProtect {
289        unsafe fn deallocate(&self, ptr: NonNull<u8>, layout: NonZeroLayout) {
290            // SAFETY: forwarded to the real backing.
291            unsafe { self.0.deallocate(ptr, layout) }
292        }
293    }
294    unsafe impl Allocator for FailingProtect {
295        fn allocate(&self, layout: NonZeroLayout) -> Result<NonNull<[u8]>, AllocError> {
296            self.0.allocate(layout)
297        }
298    }
299    unsafe impl OsBacked for FailingProtect {
300        fn base_ptr(&self) -> NonNull<u8> {
301            self.0.base_ptr()
302        }
303        fn region_size(&self) -> usize {
304            self.0.region_size()
305        }
306        unsafe fn release_pages(&self, ptr: NonNull<u8>, size: usize) {
307            // SAFETY: forwarded to the real backing.
308            unsafe { self.0.release_pages(ptr, size) }
309        }
310        unsafe fn protect(&self, _ptr: NonNull<u8>, _size: usize, _flags: ProtectFlags) {
311            // Provoke a real OS error (open a path that cannot exist) and
312            // capture it into the shared slot — the same recording path a
313            // genuine guard-install failure takes inside `MmapBacked`.
314            let _ = std::fs::File::open("__forge_alloc_guard_install_should_fail__");
315            crate::backing::mmap_record_os_error();
316        }
317    }
318
319    #[test]
320    #[cfg_attr(miri, ignore = "miri-incompatible: mmap")]
321    fn construct_aborts_when_guard_protect_fails() {
322        let inner = FailingProtect(MmapBacked::new(64 * 1024).unwrap());
323        // The head-guard `protect` records an error; `new` must detect it and
324        // return Err rather than hand back writable "guard" pages — the
325        // security-critical abort path, previously untested.
326        assert!(
327            GuardPage::new(inner, page_size()).is_err(),
328            "GuardPage::new must abort when a guard protect fails",
329        );
330    }
331
332    /// The security premise itself: writing into either guard page must fault.
333    /// `fork()` a child that writes one byte into the guard; the kernel kills
334    /// it with SIGSEGV/SIGBUS. The parent asserts the child was signalled
335    /// rather than exiting cleanly (a clean exit ⇒ the guard was writable).
336    /// Unix-only — the trap mechanism is platform-specific.
337    #[cfg(unix)]
338    mod trap {
339        use super::*;
340
341        /// # Safety
342        /// `addr` must be an address the caller expects to be unmapped /
343        /// protected; the child writes one byte there.
344        unsafe fn child_faults_writing(addr: *mut u8) -> bool {
345            // SAFETY: fork in a test; the child does only async-signal-safe
346            // work (a volatile write that faults, then `_exit`).
347            let pid = unsafe { libc::fork() };
348            assert!(pid >= 0, "fork failed");
349            if pid == 0 {
350                unsafe {
351                    core::ptr::write_volatile(addr, 0xFFu8);
352                    // Reached only if the write did NOT fault.
353                    libc::_exit(0);
354                }
355            }
356            let mut status: libc::c_int = 0;
357            // SAFETY: valid out-pointer; pid is our child.
358            unsafe { libc::waitpid(pid, &mut status, 0) };
359            libc::WIFSIGNALED(status)
360                && (libc::WTERMSIG(status) == libc::SIGSEGV
361                    || libc::WTERMSIG(status) == libc::SIGBUS)
362        }
363
364        #[test]
365        #[cfg_attr(miri, ignore = "miri-incompatible: mmap / fork / signals")]
366        fn head_and_tail_guards_trap_on_access() {
367            let inner = MmapBacked::new(64 * 1024).unwrap();
368            let inner_base = inner.base_ptr().as_ptr();
369            let region = inner.region_size();
370            let ps = page_size();
371            let g = GuardPage::new(inner, ps).unwrap();
372            unsafe {
373                // Last byte of the leading guard (just below the usable base).
374                let head = g.base().as_ptr().sub(1);
375                assert!(child_faults_writing(head), "head guard did not trap");
376                // First byte of the trailing guard.
377                let tail = inner_base.add(region - ps);
378                assert!(child_faults_writing(tail), "tail guard did not trap");
379            }
380        }
381    }
382}