Correctness: casting via `strict_cast()` vs `as`

I'm looking for opinions and advice on the best way to cast "size of buffer" inputs required by ffi calls.

given a function of the shape:

pub unsafe fn DeviceIoControl(
    hdevice: HANDLE,
    dwiocontrolcode: u32,
    lpinbuffer: *const core::ffi::c_void,
    ninbuffersize: u32,  // size of lpinbuffer
    lpoutbuffer: *mut core::ffi::c_void,
    noutbuffersize: u32,  // size of lpoutbuffer
    lpbytesreturned: *mut u32,
    lpoverlapped: *mut OVERLAPPED,
)

I'm trying to decide between 2 fundamental approaches for npinbuffersize & npoutbuffersize

Option 1: const { size_of::<BUFFERTYPE>().strict_cast() }

Advantages I see:
  • compile time validation. Compilation failure is better than runtime panic (or UB)
Disadvantages I see:
  • risk: if BUFFERTYPE changes this line also needs to change
  • cross-compilation - risky pattern? Is this a possible footgun when used to cast to usize, so while safe in the above situation which is casting usize as u32, the pattern would be dangerous if ever used in places where the resulting type is usize?
  • cross-compilation - does const { size_of::<T> } use the target or host size of T or does the addition of const bring in a footgun which I didn't notice in the docs? [Edit: found the (only?) reference, which confirms "The interpretation happens in the environment of the compilation target and not the host" - so this appears safe]

Option 2: size_of_val(lpinbuffer_val) as u32

Advantages I see:
  • Guaranteed to use runtime size
  • No experimental feature usage
  • idiomatic
Disadvantages I see:
  • Runtime panic is better and UB, but not as good as compilation error
  • Won't panic on release-build. Needs safety comment & consideration of overflow/saturation risk.

Alternatives / Variations

  • use try_into() and handle error in code - While this is how I'd work in higher-level code it feels noisy & out of place during an ffi call in an unsafe block
  • non-const option 1
  • size_of_val().strict_cast() - guaranteed runtime panic, better than UB, but not as good as compile error (IMHO).
  • Re-reading the docs for size_of_val() I see it is also const, so maybe wrapping this in const is the best option ... (pending cross-compilation footguns) ??? [EDIT: now I remember why I didn't do this - the buffer isn't const, so it won't compile)

I have once written some code wrapping DeviceIoControl, so I can do typed io control calls. the idea is very simple:

use bytemuck::Pod;

unsafe trait IoControlFunction {
    const CODE: u32;
    type Input: Pod,
    type Output: Pod;
}

// wrapper for a device handle
struct DeviceHandle {...}

impl DeviceHandle {
    pub(crate) fn ioctl<F: IoControlFunction>(&self, input: &F::Input) -> Result<F::Output> {
        let ibuf = bytemuck::bytes_of(input);
        let mut obuf = MaybeUninit::<F::Output>::zeroed();
        let mut output_size = 0;
        let result = unsafe {
            DeviceIoControl(
                self.handle,
                F::CODE,
                ibuf.as_ptr().cast(),
                ibuf.len() as DWORD,
                obuf.as_mut_ptr().cast(),
                size_of::<F::Output>() as DWORD,
                &mut output_size,
                null_mut(),
            )
        };
        // this was enough for my use case
        assert_eq!(output_size, size_of::<F::Output>() as DWORD);
        result.ok()?;
        unsafe { Ok(obuf.assume_init()) }
    }
}

and then I created high level apis on top of this, e.g. to talk to the SCSI mini port driver, and the app code never need to call the low level device io control directly.

for my use case, the buffer sizes were completely determined by the io control code, so I think it was more aligned with your option 1, except I didn't force compile time check with const.

I believe that if you know the type in advance (in a compile time) it is better to get its size statically.

  • risk: if BUFFERTYPE changes this line also needs to change

This function is pretty low-level, I think it would be better to create a high-level wrapper that uses high-level structure instead of low-level BUFFERTYPE.

usize as u32

Microsoft didn't give us a choice: it is DWORD in an API, so we have to cast structure size to u32.

  • cross-compilation

I think it is safe to assume that T has a size size_of::<T> even with cross-compilation, because compiler needs to know T size to allocate space in stack etc.