04 / Systems practice
Unsafe boundaries and soundness
Review a small unsafe function, explain its safety rules, and check it with Miri.
By Robert DeVore · Download Markdown
Unsafe does not suspend Rust's rules
Unsafe operations have requirements the compiler cannot completely verify. An unsafe block makes the programmer responsible for meeting those requirements; undefined behavior remains forbidden. A safe API is sound only if no allowed safe caller can trigger undefined behavior through it.
The lab reimplements a tiny slice split for study. Production application code should use the standard split_at_mut. The separate lab lets us study unsafe code while forbidding it in the application.
//! Study-only unsafe boundary. In application code prefer `slice::split_at_mut`.
/// Split at a checked index, preserving exclusive access to disjoint elements.
///
/// Panics if `mid > slice.len()`. Safe callers have no extra obligations.
pub fn split<T>(slice: &mut [T], mid: usize) -> (&mut [T], &mut [T]) {
let len = slice.len();
assert!(mid <= len, "split index exceeds length");
let ptr = slice.as_mut_ptr();
// SAFETY: ptr comes from a valid exclusive slice. mid <= len keeps the
// offset in the allocation or one-past. Both ranges contain initialized T,
// are aligned/non-null even when empty, and partition the original range.
// Returned lifetimes are tied to slice; the original borrow cannot be used
// while these reborrows are live. ZSTs have disjoint logical elements even
// when their addresses coincide. No ownership or destructor is duplicated.
unsafe {
(
std::slice::from_raw_parts_mut(ptr, mid),
std::slice::from_raw_parts_mut(ptr.add(mid), len - mid),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn partitions() {
for mid in 0..=4 {
let mut data = [0; 4];
let (a, b) = split(&mut data, mid);
a.fill(1);
b.fill(2);
assert_eq!(data[..mid], vec![1; mid]);
assert_eq!(data[mid..], vec![2; 4 - mid]);
}
}
#[test]
fn empty_and_zst() {
let mut empty: [u8; 0] = [];
assert!(split(&mut empty, 0).0.is_empty());
let mut z = [(); 4];
let (a, b) = split(&mut z, 2);
assert_eq!((a.len(), b.len()), (2, 2));
}
#[test]
fn drops_once() {
use std::rc::Rc;
let token = Rc::new(());
{
let mut data = [token.clone(), token.clone()];
let (a, b) = split(&mut data, 1);
std::mem::swap(&mut a[0], &mut b[0]);
}
assert_eq!(Rc::strong_count(&token), 1);
}
#[test]
#[should_panic(expected = "split index exceeds length")]
fn rejects_oob() {
split(&mut [1], 2);
}
}
The safety argument
What must remain true? Both returned slices cover initialized elements from the original slice, stay within its allocation, retain valid alignment and provenance, and refer to disjoint logical element ranges. Their references cannot outlive the original borrow.
Who maintains it? The caller supplies a valid exclusive slice through a safe Rust type. The function checks the split index before pointer arithmetic. Its implementation constructs exactly the two ranges. Borrow checking relates the returned lifetimes to the input.
Which safe callers are allowed? Any valid mutable slice, including empty slices, zero-sized element types, and types with destructors. A split at either endpoint is valid. An out-of-range index must panic before unsafe operations. Safe callers must not need to follow extra, undocumented rules.
How is the unsafe code kept small? One unsafe block follows a checked boundary. The raw pointer does not escape. No allocation is freed and no element ownership is duplicated. The application never depends on this study implementation.
What would make it unsound? Removing the bounds check, overlapping nonempty element ranges, inventing a longer return lifetime, accepting an arbitrary unvalidated pointer, or allowing conflicting access through the original slice while the returned borrows are live.
Aliasing is more than addresses
Zero-sized elements can share an address without representing overlapping stored bytes. A simplistic “different addresses means safe” argument would fail to cover them. Likewise, UnsafeCell relaxes shared-reference immutability for its contents but does not erase exclusive-reference rules or synchronize concurrent accesses.
The Reference explicitly notes that exact aliasing rules remain an active specification area. Miri checks executions under its models. A passing run does not prove all executions safe, and its models are not the final language specification. We test ordinary elements, endpoints, empty input, zero-sized elements, and drop behavior, then inspect the invariant argument separately.
Exercise
Run the isolated lab under Miri:
rustup toolchain install nightly-2026-09-05 --profile minimal --component miri
cargo +nightly-2026-09-05 miri test -p unsafe-lab
Explain why no destructor may run twice and why a safe caller using mem::forget must not invalidate the abstraction. Do not execute deliberately undefined examples in a normal production process.
Solution and acceptance check
The function returns borrows, not new owners of elements. Dropping a slice reference does not drop its elements. The original owner retains destruction responsibility. Forgetting a reference does not produce conflicting access by itself; the implementation does not rely on its destructor running. The tests check selected cases. The safety argument must cover every allowed safe caller.
Sources: undefined behavior, Rustonomicon, from_raw_parts_mut, and Miri.