Soundness of using MaybeUninit<T> for storing arbitrary types

Given the following code:

use core::{
    marker::PhantomData,
    mem::{self, MaybeUninit},
    ptr,
};

pub trait Foo {
    fn bar(&self);
}

struct VTable {
    cast_ref: unsafe fn(*const ()) -> *const dyn Foo,
    cast_mut: unsafe fn(*mut ()) -> *mut dyn Foo,
}

pub struct InlineDynFoo<T> {
    vtable: &'static VTable,
    storage: MaybeUninit<T>,
    _marker: PhantomData<dyn Foo>,
}

impl<T> InlineDynFoo<T> {
    pub fn try_from<U: Foo + 'static>(value: U) -> Result<Self, U> {
        unsafe {
            Self::with_vtable(
                &VTable {
                    cast_ref: |x| x.cast::<U>(),
                    cast_mut: |x| x.cast::<U>(),
                },
                value,
            )
        }
    }

    unsafe fn with_vtable<U: 'static>(vtable: &'static VTable, value: U) -> Result<Self, U> {
        if (mem::size_of::<U>() <= mem::size_of::<T>())
            && (mem::align_of::<U>() <= mem::align_of::<T>())
        {
            let mut storage = MaybeUninit::<T>::uninit();
            storage.as_mut_ptr().cast::<U>().write(value);
            Ok(Self {
                vtable,
                storage,
                _marker: PhantomData,
            })
        } else {
            Err(value)
        }
    }

    pub fn get_ref(&self) -> &dyn Foo {
        unsafe { &*(self.vtable.cast_ref)(self.storage.as_ptr().cast()) }
    }

    pub fn get_mut(&mut self) -> &mut dyn Foo {
        unsafe { &mut *(self.vtable.cast_mut)(self.storage.as_mut_ptr().cast()) }
    }
}

impl<T> Drop for InlineDynFoo<T> {
    fn drop(&mut self) {
        unsafe {
            ptr::drop_in_place(self.get_mut());
        }
    }
}

(Playground)

Is there a type T for which the code is unsound? If so, are there any types, other than the trivial case where T=U, for which the code is sound?

Update:
When attempting to add some simplified test cases to the playground, I discovered that when run through miri an error is found for T=usize; however, miri does not throw an error for the seemingly related case of T=[usize;1].

It seems to be connected with the moving of MaybeUninit<usize> in the constructor and I have a guess. First off, it seems very weird as really [usize; 1] and usize have the same validity invariants and layout as a repr(C) type.

I have built a variant of the code where the storage is never moved but instead initialization takes a &mut InlineDynFoo<T>. Full code is here but the gist is that a default instance with () is constructed first. This passes the same suite of test cases. (It doesn't seem very sound in general though as you could still move it freely, you only don't need to).


impl<T> InlineDynFoo<T> {
    pub fn new() -> Self {
        Self {
            vtable: &VTable {
                cast_ref: |x| x,
                cast_mut: |x| x,
            },
            storage: MaybeUninit::<T>::uninit(),
            _marker: PhantomData,
        }
    }

    pub fn try_set_from<U: Foo + 'static>(&mut self, value: U) -> Result<(), U> {
        unsafe {
            self.with_vtable(
                &VTable {
                    cast_ref: |x| x.cast::<U>(),
                    cast_mut: |x| x.cast::<U>(),
                },
                value,
            )
        }
    }

    unsafe fn with_vtable<U: 'static>(&mut self, vtable: &'static VTable, value: U) -> Result<(), U> {
        if (mem::size_of::<U>() <= mem::size_of::<T>())
            && (mem::align_of::<U>() <= mem::align_of::<T>())
        {
            self.vtable = vtable;
            self.storage.as_mut_ptr().cast::<U>().write(value);
            Ok(())
        } else {
            Err(value)
        }
    }
}

(The following is mostly guess work) I suspect this to be a quirk in the evaluation engine where moving a MaybeUninit, a union, tries to move it by its accessed field. This is an internal but this field has type usize. And reading a scalar in constant evaluation propagates uninitialized bytes. Consequently, the moved storage attribute of the return value is initialized with a completely uninitialized scalar. That would also explain the difference in behaviour for [usize; 1] since here the initialization property is not special cased and purely a copy of memory contents and initialization mask.

Whether this is a bug or not specified in Rust's memory model or undefined behaviour, that is though beyond me to judge. In parts as the exact behaviour of unions is not completely specified.

2 Likes