ps-alloc 0.1.0-9

a reasonably safe allocator
Documentation
use ps_alloc::{
    alloc, free, realloc, AllocationError, DeallocationError, ReallocationError, HEADER_SIZE,
};

#[test]
fn alloc_free_roundtrip() {
    let ptr = alloc(100).unwrap();

    unsafe {
        for i in 0..100 {
            ptr.add(i).write(i as u8);
        }

        for i in 0..100 {
            assert_eq!(ptr.add(i).read(), i as u8);
        }

        free(ptr).unwrap();
    }
}

#[test]
fn alloc_zero_size() {
    let ptr = alloc(0).unwrap();

    assert!(!ptr.is_null());
    assert_eq!(ptr as usize % 16, 0);

    unsafe { free(ptr).unwrap() };
}

#[test]
fn alloc_alignment() {
    for size in [1, 2, 3, 8, 15, 16, 17, 31, 32, 33, 64, 100, 1000] {
        let ptr = alloc(size).unwrap();

        assert_eq!(ptr as usize % 16, 0, "size {size}");

        unsafe { free(ptr).unwrap() };
    }
}

#[test]
fn alloc_overflow() {
    assert_eq!(alloc(usize::MAX), Err(AllocationError::ArithmeticError));
}

#[test]
fn alloc_layout_error() {
    // isize::MAX survives the size arithmetic but exceeds the maximum valid layout size
    let result = alloc(isize::MAX as usize);

    assert!(
        matches!(result, Err(AllocationError::LayoutError(_))),
        "expected a layout error: {result:?}"
    );
}

#[test]
fn free_null() {
    let result = unsafe { free(std::ptr::null_mut()) };

    assert_eq!(result, Err(DeallocationError::NullPtr));
}

#[test]
fn free_misaligned() {
    let ptr = alloc(8).unwrap();

    // `ptr + 1` stays in bounds because the buffer is 8 bytes long
    let result = unsafe { free(ptr.add(1)) };

    assert_eq!(result, Err(DeallocationError::ImproperAlignment));

    unsafe { free(ptr).unwrap() };
}

// The second `free` reads freed memory: undefined behaviour by design (the double-free
// check is best-effort), so this test must not run under Miri. The allocator may reuse
// the freed header for its own bookkeeping, so either DoubleFree (marker intact) or
// CorruptedMarker (marker clobbered) constitutes detection.
#[test]
#[cfg_attr(miri, ignore)]
fn double_free_detected() {
    let ptr = alloc(8).unwrap();

    unsafe {
        free(ptr).unwrap();

        let result = free(ptr);

        assert!(
            matches!(
                result,
                Err(DeallocationError::DoubleFree | DeallocationError::CorruptedMarker)
            ),
            "double free was not detected: {result:?}"
        );
    }
}

#[test]
fn corrupted_marker_detected() {
    let ptr = alloc(8).unwrap();

    unsafe {
        // the header is part of the same allocation, so this stays in bounds
        let header_ptr = ptr.sub(HEADER_SIZE);

        let mut saved = [0u8; HEADER_SIZE];

        // save the header, then overwrite it with zeros, which match neither marker
        std::ptr::copy_nonoverlapping(header_ptr, saved.as_mut_ptr(), HEADER_SIZE);
        header_ptr.write_bytes(0, HEADER_SIZE);

        assert_eq!(free(ptr), Err(DeallocationError::CorruptedMarker));

        // restore the header so the allocation can be freed without leaking
        std::ptr::copy_nonoverlapping(saved.as_ptr(), header_ptr, HEADER_SIZE);

        free(ptr).unwrap();
    }
}

#[test]
fn realloc_grow_preserves_data() {
    let ptr = alloc(16).unwrap();

    unsafe {
        for i in 0..16 {
            ptr.add(i).write(i as u8);
        }

        let new_ptr = realloc(ptr, 64).unwrap();

        for i in 0..16 {
            assert_eq!(new_ptr.add(i).read(), i as u8);
        }

        free(new_ptr).unwrap();
    }
}

#[test]
fn realloc_shrink_preserves_data() {
    let ptr = alloc(64).unwrap();

    unsafe {
        for i in 0..64 {
            ptr.add(i).write(i as u8);
        }

        let new_ptr = realloc(ptr, 8).unwrap();

        // only the surviving bytes are guaranteed to be preserved
        for i in 0..8 {
            assert_eq!(new_ptr.add(i).read(), i as u8);
        }

        free(new_ptr).unwrap();
    }
}

#[test]
fn realloc_null_is_alloc() {
    let ptr = unsafe { realloc(std::ptr::null_mut(), 32) }.unwrap();

    assert!(!ptr.is_null());

    unsafe { free(ptr).unwrap() };
}

#[test]
fn realloc_null_zero_is_alloc_zero() {
    let ptr = unsafe { realloc(std::ptr::null_mut(), 0) }.unwrap();

    assert!(!ptr.is_null());

    unsafe { free(ptr).unwrap() };
}

#[test]
fn realloc_zero_is_free() {
    let ptr = alloc(32).unwrap();

    let result = unsafe { realloc(ptr, 0) }.unwrap();

    assert!(result.is_null());
}

#[test]
fn realloc_same_rounded_size_is_noop() {
    let ptr = alloc(10).unwrap();

    // 10 and 16 round up to the same total allocation size
    let new_ptr = unsafe { realloc(ptr, 16) }.unwrap();

    assert_eq!(new_ptr, ptr);

    unsafe { free(new_ptr).unwrap() };
}

#[test]
fn realloc_misaligned() {
    let ptr = alloc(8).unwrap();

    // `ptr + 1` stays in bounds because the buffer is 8 bytes long
    let result = unsafe { realloc(ptr.add(1), 8) };

    assert_eq!(result, Err(ReallocationError::ImproperAlignment));

    unsafe { free(ptr).unwrap() };
}

#[test]
fn realloc_corrupted_marker_detected() {
    let ptr = alloc(8).unwrap();

    unsafe {
        // the header is part of the same allocation, so this stays in bounds
        let header_ptr = ptr.sub(HEADER_SIZE);

        let mut saved = [0u8; HEADER_SIZE];

        // save the header, then overwrite it with zeros, which match neither marker
        std::ptr::copy_nonoverlapping(header_ptr, saved.as_mut_ptr(), HEADER_SIZE);
        header_ptr.write_bytes(0, HEADER_SIZE);

        assert_eq!(realloc(ptr, 64), Err(ReallocationError::MarkerCorrupted));

        // restore the header so the allocation can be freed without leaking
        std::ptr::copy_nonoverlapping(saved.as_ptr(), header_ptr, HEADER_SIZE);

        free(ptr).unwrap();
    }
}

#[test]
fn realloc_overflow_leaves_pointer_valid() {
    let ptr = alloc(8).unwrap();

    unsafe {
        for i in 0..8 {
            ptr.add(i).write(i as u8);
        }

        let result = realloc(ptr, usize::MAX);

        assert_eq!(
            result,
            Err(ReallocationError::AllocationError(
                AllocationError::ArithmeticError
            ))
        );

        // the failed call must not have modified the allocation
        for i in 0..8 {
            assert_eq!(ptr.add(i).read(), i as u8);
        }

        free(ptr).unwrap();
    }
}

#[test]
fn realloc_chain() {
    unsafe {
        let ptr = alloc(8).unwrap();
        let ptr = realloc(ptr, 100).unwrap();
        let ptr = realloc(ptr, 3000).unwrap();
        let ptr = realloc(ptr, 5).unwrap();

        free(ptr).unwrap();
    }
}

#[test]
fn errors_are_send_sync() {
    fn assert_send_sync<T: Send + Sync + Unpin>() {}

    assert_send_sync::<AllocationError>();
    assert_send_sync::<DeallocationError>();
    assert_send_sync::<ReallocationError>();
}