Exposing mock (ffi, hardware) bindings for test, doc, coding on another target

Looking for anyone who has tried this approach and can either provide a "won't work" or "good idea" experience.

Background

I'm currently working on a crate & app which (initially) targets windows and includes custom ffi bindings to provide hardware access.[1]

I code on linux and cross-compile, while I can set up rust-analyzer to work with an alternative target, I'd also like to no-run rather than ignore doc-tests and ideally create & run unit tests.

Idea

I've just started to work out the following pattern:

// src/win/bindings/mod.rs

mod bindgen;
mod mocks;

#[cfg(all(target_family = "windows", not(any(test, doc))))]
pub(crate) use bindgen::*;

#[cfg(any(test, doc, not(target_family = "windows")))]
// ffi functions
pub(crate) use mocks::{
    CloseHandle, CreateFile2, ...
};

#[cfg(any(test, doc, not(target_family = "windows")))]
// ffi types & consts
pub use bindgen::{
    CDDA, CDROM_READ_TOC_EX, ...
};

That way I can:

  1. have examples in my docs, which compile, even though they use functionality currently only available on windows (done)
  2. create unit tests of functionality which relies on hardware access (I can only get so far by splitting IO & processing into separate functions) and error-handling in the IO-functions, by providing concrete results from the mocks. (next step todo)
  3. still create a pub mod linux; in the future with a similar structure and provide cfg(target-family = ...)-gated, pub use re-exports in lib.rs for consumers (sometime in the future)

Help, please

Has anyone tried this before? Is it a usually pattern I've just not noticed? Is it fundamentally broken and I'll only notice that after quite a bit of work? ...

Thanks in advance!


  1. in my case I'm working on reading audio Cds and need specific access to the hardware to do this via windows APIs rather than raw SCSI ↩︎

After 24hrs experience with this, I'm happy with the approach.

  • It forces proper and full understanding of the called ffi function, at least in the required use cases
  • Documenting that understanding as safety and usage docs on the mocks makes it much easier to reason about safety at the call site
  • Fearless refactoring of mid-level code which relies on ffi is now possible
  • Miri needs tests or a binary to run against - now it has tests

I think you might be able to simplify the tests by using GitHub - XTSoftwareLabs/shimforge: Test Rust code without test-only traits or changes to production code. · GitHub

The mock layer may be removed. Directly mock the system functions. For example:

let mut session = shimforge::Session::new(); 

let create_file = shimforge::mock!(
    session,
    CreateFile2,
    unsafe extern "system" fn( *const u16, u32, u32, u32, 
    *constCREATEFILE2_EXTENDED_PARAMETERS, ) -> HANDLE
);

create_file
 .expect()
 .with(|_, access, _, disposition, _| { 
        *access == GENERIC_READ && *disposition == OPEN_EXISTING
 })
 .once()
 .returning(|_, _, _, _, _| 0x1234usize as HANDLE);

That looks interesting in other contexts. But with ffi I found there was so much extra value in creating the mocks.

Creating rusty documentation including safety instructions from the c++ docs ensured I really reviewed the requirements and makes coding so much nicer thanks to IDE-pops

Adding additional custom safety validation inside the mock helps guard against UB. E.g.:

unsafe fn ReadSomething(
    read_command: u32,
    out_buf: *mut c_void,
    out_buf_size: u32,
    returned_bytes: *mut u32
) {
    assert!(!out_buf.is_null());
    assert!(!returned_bytes.is_null());
    // make sure we're not accidentally reusing a variable or casting something wierd
    assert_eq!(unsafe {*returned_bytes}, 0_u32);

    // after matching on the command, so we know exactly what should be returned
    assert_eq!(
        out_buf_size,
        const { size_of::<RETURNTYPE>().strict_cast::<u32>()
    });
}