Skip to main content

fs_core/
ffi.rs

1//! C ABI for the block-device framework.
2//!
3//! Every sister crate (qcow2 reader, partition probe, fs-* drivers) speaks
4//! through the [`FsCoreDevice`] handle defined here, so consumers (Swift
5//! FSKit modules, Go callers, C programs) only learn one device-handle
6//! type and one error convention.
7//!
8//! ## Conventions
9//!
10//! - Handles are opaque `*mut FsCoreDevice`. Allocate via a constructor in
11//!   one of the sister crates (e.g. `qcow2_open` from rust-img-qcow2),
12//!   free via [`fs_core_device_close`] regardless of which crate created
13//!   it.
14//! - Error reporting is errno-style: every fallible function returns an
15//!   [`FsCoreErrorCode`] (0 = OK, non-zero = failure) and stashes a human
16//!   message in a thread-local. Read it via
17//!   [`fs_core_last_error_message`].
18//! - Every entry point catches Rust panics with `catch_unwind` and maps
19//!   them to [`FsCoreErrorCode::Panic`]. Crossing an FFI boundary while
20//!   unwinding is UB; the catch-net is non-negotiable.
21//! - Thread safety: handles wrap `Arc<dyn BlockDevice>`, which is
22//!   `Send + Sync` by trait bound. Multiple threads can call read/write
23//!   concurrently as long as the underlying device's locking permits it.
24
25#![allow(clippy::missing_safety_doc)]
26
27use crate::block::BlockDevice;
28use crate::callback_device::CallbackDevice;
29use crate::error::Error;
30use std::cell::RefCell;
31use std::ffi::{c_char, c_int, c_void, CString};
32use std::io;
33use std::panic::AssertUnwindSafe;
34use std::ptr;
35use std::slice;
36use std::sync::Arc;
37
38// ---------------------------------------------------------------------------
39// Error codes — kept dense and stable so consumers can hard-code them.
40// ---------------------------------------------------------------------------
41
42/// Numeric error codes mirrored across every sister crate's C ABI.
43///
44/// `#[repr(i32)]` so the layout is identical to the matching C `enum`.
45#[repr(i32)]
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum FsCoreErrorCode {
48    /// Success.
49    Ok = 0,
50    /// Underlying I/O failed.
51    Io = 1,
52    /// EOF before the requested range was satisfied.
53    ShortRead = 2,
54    /// Write attempted on a read-only device.
55    ReadOnly = 3,
56    /// Read or write past the end of the device.
57    OutOfBounds = 4,
58    /// Driver-specific error — message in the thread-local last-error.
59    Custom = 5,
60    /// One of the input pointers was null.
61    NullArg = 6,
62    /// `catch_unwind` caught a panic crossing the FFI boundary.
63    Panic = 7,
64    /// Path string was not valid UTF-8 (or NUL-terminated).
65    BadString = 8,
66}
67
68impl FsCoreErrorCode {
69    fn from_error(e: &Error) -> Self {
70        match e {
71            Error::Io(_) => FsCoreErrorCode::Io,
72            Error::ShortRead { .. } => FsCoreErrorCode::ShortRead,
73            Error::ReadOnly => FsCoreErrorCode::ReadOnly,
74            Error::OutOfBounds { .. } => FsCoreErrorCode::OutOfBounds,
75            Error::Custom(_) => FsCoreErrorCode::Custom,
76        }
77    }
78}
79
80// ---------------------------------------------------------------------------
81// Thread-local last-error — errno-style detail companion.
82// ---------------------------------------------------------------------------
83
84thread_local! {
85    static LAST_ERROR: RefCell<Option<CString>> = const { RefCell::new(None) };
86}
87
88/// Stash a message in the thread-local, replacing any previous one. Public
89/// to sister crates so they can populate it for their own error paths.
90pub fn set_last_error(message: impl Into<String>) {
91    let s = message.into();
92    let cs = CString::new(s.replace('\0', "?")).expect("contains no NUL after replace");
93    LAST_ERROR.with(|slot| {
94        *slot.borrow_mut() = Some(cs);
95    });
96}
97
98fn clear_last_error() {
99    LAST_ERROR.with(|slot| {
100        *slot.borrow_mut() = None;
101    });
102}
103
104/// Return a pointer to the calling thread's most recent error message, or
105/// NULL if there is none. The pointer is owned by the framework and remains
106/// valid until the next FFI call on this thread.
107#[unsafe(no_mangle)]
108pub extern "C" fn fs_core_last_error_message() -> *const c_char {
109    LAST_ERROR.with(|slot| {
110        slot.borrow()
111            .as_ref()
112            .map(|cs| cs.as_ptr())
113            .unwrap_or(ptr::null())
114    })
115}
116
117/// Helper for sister crates: run `body`, catch panics, map errors to codes,
118/// stash the message in the thread-local. Returns the error code.
119pub fn ffi_guard<F>(body: F) -> FsCoreErrorCode
120where
121    F: FnOnce() -> Result<(), Error>,
122{
123    clear_last_error();
124    match std::panic::catch_unwind(AssertUnwindSafe(body)) {
125        Ok(Ok(())) => FsCoreErrorCode::Ok,
126        Ok(Err(e)) => {
127            let code = FsCoreErrorCode::from_error(&e);
128            set_last_error(e.to_string());
129            code
130        }
131        Err(panic) => {
132            set_last_error(panic_message(&panic));
133            FsCoreErrorCode::Panic
134        }
135    }
136}
137
138fn panic_message(panic: &Box<dyn std::any::Any + Send>) -> String {
139    if let Some(s) = panic.downcast_ref::<&'static str>() {
140        return (*s).to_string();
141    }
142    if let Some(s) = panic.downcast_ref::<String>() {
143        return s.clone();
144    }
145    "panic in FFI".to_string()
146}
147
148// ---------------------------------------------------------------------------
149// Device handle — opaque to C callers, shared across crates.
150// ---------------------------------------------------------------------------
151
152/// Opaque handle wrapping an `Arc<dyn BlockDevice>`. Allocated by sister
153/// crates' constructors and freed via [`fs_core_device_close`].
154pub struct FsCoreDevice {
155    inner: Arc<dyn BlockDevice>,
156}
157
158impl FsCoreDevice {
159    /// Internal constructor — sister crates use this to wrap their own
160    /// device types (Qcow2Reader, FileDevice, OwnedSlice, etc.) into the
161    /// shared handle type. Returns a `Box::into_raw` pointer ready to hand
162    /// across the FFI boundary.
163    pub fn into_handle(inner: Arc<dyn BlockDevice>) -> *mut FsCoreDevice {
164        Box::into_raw(Box::new(FsCoreDevice { inner }))
165    }
166
167    /// Borrow the inner device. `Arc::clone` it if you want shared
168    /// ownership — e.g. when handing the device to a slice adapter while
169    /// keeping the original handle alive.
170    pub fn inner(&self) -> &Arc<dyn BlockDevice> {
171        &self.inner
172    }
173}
174
175/// Free a device handle. Safe to call with NULL (no-op).
176#[unsafe(no_mangle)]
177pub unsafe extern "C" fn fs_core_device_close(handle: *mut FsCoreDevice) {
178    if handle.is_null() {
179        return;
180    }
181    let _ = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe {
182        drop(Box::from_raw(handle));
183    }));
184}
185
186/// Total device size in bytes. Returns 0 if `handle` is NULL.
187#[unsafe(no_mangle)]
188pub unsafe extern "C" fn fs_core_device_size_bytes(handle: *const FsCoreDevice) -> u64 {
189    if handle.is_null() {
190        return 0;
191    }
192    std::panic::catch_unwind(AssertUnwindSafe(|| unsafe { (*handle).inner.size_bytes() }))
193        .unwrap_or(0)
194}
195
196/// True if `write_at` is likely to succeed. Returns false on NULL.
197#[unsafe(no_mangle)]
198pub unsafe extern "C" fn fs_core_device_is_writable(handle: *const FsCoreDevice) -> bool {
199    if handle.is_null() {
200        return false;
201    }
202    std::panic::catch_unwind(AssertUnwindSafe(|| unsafe {
203        (*handle).inner.is_writable()
204    }))
205    .unwrap_or(false)
206}
207
208/// Read exactly `len` bytes from `offset` into `buf`. `buf` must be at
209/// least `len` bytes. Returns an `FsCoreErrorCode`.
210#[unsafe(no_mangle)]
211pub unsafe extern "C" fn fs_core_device_read_at(
212    handle: *const FsCoreDevice,
213    offset: u64,
214    buf: *mut u8,
215    len: usize,
216) -> FsCoreErrorCode {
217    if handle.is_null() || (buf.is_null() && len > 0) {
218        return FsCoreErrorCode::NullArg;
219    }
220    ffi_guard(|| {
221        let slice_buf = unsafe { slice::from_raw_parts_mut(buf, len) };
222        unsafe { (*handle).inner.read_at(offset, slice_buf) }
223    })
224}
225
226/// Write exactly `len` bytes from `buf` to `offset`. Returns `ReadOnly`
227/// for read-only devices.
228#[unsafe(no_mangle)]
229pub unsafe extern "C" fn fs_core_device_write_at(
230    handle: *const FsCoreDevice,
231    offset: u64,
232    buf: *const u8,
233    len: usize,
234) -> FsCoreErrorCode {
235    if handle.is_null() || (buf.is_null() && len > 0) {
236        return FsCoreErrorCode::NullArg;
237    }
238    ffi_guard(|| {
239        let slice_buf = unsafe { slice::from_raw_parts(buf, len) };
240        unsafe { (*handle).inner.write_at(offset, slice_buf) }
241    })
242}
243
244/// Flush pending writes to stable storage.
245#[unsafe(no_mangle)]
246pub unsafe extern "C" fn fs_core_device_flush(handle: *const FsCoreDevice) -> FsCoreErrorCode {
247    if handle.is_null() {
248        return FsCoreErrorCode::NullArg;
249    }
250    ffi_guard(|| unsafe { (*handle).inner.flush() })
251}
252
253// ---------------------------------------------------------------------------
254// Convenience: open a regular file as a device. Saves callers the trouble
255// of building a Rust crate just to wrap `FileDevice`.
256// ---------------------------------------------------------------------------
257
258/// Open `path` (NUL-terminated UTF-8) as a `FileDevice` and return a
259/// handle. Pass `writable=true` for RW. On failure returns NULL and the
260/// thread-local last-error has detail.
261#[unsafe(no_mangle)]
262pub unsafe extern "C" fn fs_core_file_open(
263    path: *const c_char,
264    writable: bool,
265) -> *mut FsCoreDevice {
266    if path.is_null() {
267        set_last_error("path is null");
268        return ptr::null_mut();
269    }
270    let res = std::panic::catch_unwind(AssertUnwindSafe(|| {
271        let cstr = unsafe { std::ffi::CStr::from_ptr(path) };
272        let s = match cstr.to_str() {
273            Ok(s) => s,
274            Err(_) => {
275                set_last_error("path is not valid UTF-8");
276                return ptr::null_mut();
277            }
278        };
279        let dev = if writable {
280            crate::file_device::FileDevice::open_rw(s)
281        } else {
282            crate::file_device::FileDevice::open(s)
283        };
284        match dev {
285            Ok(d) => FsCoreDevice::into_handle(Arc::new(d)),
286            Err(e) => {
287                set_last_error(e.to_string());
288                ptr::null_mut()
289            }
290        }
291    }));
292    match res {
293        Ok(p) => p,
294        Err(panic) => {
295            set_last_error(panic_message(&panic));
296            ptr::null_mut()
297        }
298    }
299}
300
301// ---------------------------------------------------------------------------
302// Callback-backed device. Used when the caller already owns the underlying
303// resource (FSKit FSBlockDeviceResource, Go file handle, C-side fd) and
304// wants to expose it as an `FsCoreDevice` so it can be stacked under a
305// container reader (qcow2, vhd, ...) before reaching a filesystem driver.
306// ---------------------------------------------------------------------------
307
308/// Read callback. Returns 0 on success, non-zero (errno-like) on failure.
309/// Must fully fill `len` bytes — short reads are treated as I/O errors.
310pub type FsCoreReadCb =
311    Option<unsafe extern "C" fn(ctx: *mut c_void, offset: u64, buf: *mut u8, len: usize) -> c_int>;
312
313/// Write callback. NULL → device is read-only.
314pub type FsCoreWriteCb = Option<
315    unsafe extern "C" fn(ctx: *mut c_void, offset: u64, buf: *const u8, len: usize) -> c_int,
316>;
317
318/// Flush/fsync callback. NULL → flush is a no-op.
319pub type FsCoreFlushCb = Option<unsafe extern "C" fn(ctx: *mut c_void) -> c_int>;
320
321/// Configuration passed to [`fs_core_device_from_callbacks`].
322#[repr(C)]
323pub struct FsCoreCallbackCfg {
324    pub read: FsCoreReadCb,
325    pub write: FsCoreWriteCb,
326    pub flush: FsCoreFlushCb,
327    pub ctx: *mut c_void,
328    pub size: u64,
329}
330
331// `*mut c_void` is `!Send + !Sync` by default and `unsafe impl Send` on a
332// NewType doesn't propagate cleanly through closure auto-traits. Round-trip
333// the pointer through `usize` instead — that's `Copy + Send + Sync`, and
334// the callback contract already puts the host on the hook for thread-safe
335// `ctx` use.
336fn cb_io_err(rc: c_int, op: &str) -> io::Error {
337    io::Error::other(format!("callback {op} returned {rc}"))
338}
339
340/// Build an [`FsCoreDevice`] backed by host-provided callbacks. Returns NULL
341/// on failure (config null, read callback null, etc.) and stashes detail in
342/// the thread-local last-error.
343///
344/// `cfg.ctx` is opaque to fs-core; it is passed back verbatim to every
345/// callback invocation. The caller is responsible for ensuring it remains
346/// valid until [`fs_core_device_close`] is called on the returned handle.
347#[unsafe(no_mangle)]
348pub unsafe extern "C" fn fs_core_device_from_callbacks(
349    cfg: *const FsCoreCallbackCfg,
350) -> *mut FsCoreDevice {
351    if cfg.is_null() {
352        set_last_error("cfg is null");
353        return ptr::null_mut();
354    }
355    let res = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe {
356        let cfg = &*cfg;
357        let read_fn = match cfg.read {
358            Some(f) => f,
359            None => {
360                set_last_error("cfg.read is null");
361                return ptr::null_mut();
362            }
363        };
364        let write_fn = cfg.write;
365        let flush_fn = cfg.flush;
366        let ctx_addr = cfg.ctx as usize;
367        let size = cfg.size;
368
369        let read_cb: crate::callback_device::ReadCb = Box::new(move |off, buf| {
370            let ctx = ctx_addr as *mut c_void;
371            let rc = read_fn(ctx, off, buf.as_mut_ptr(), buf.len());
372            if rc == 0 {
373                Ok(())
374            } else {
375                Err(cb_io_err(rc, "read"))
376            }
377        });
378        let write_cb: Option<crate::callback_device::WriteCb> = write_fn.map(|f| {
379            Box::new(move |off, buf: &[u8]| {
380                let ctx = ctx_addr as *mut c_void;
381                let rc = f(ctx, off, buf.as_ptr(), buf.len());
382                if rc == 0 {
383                    Ok(())
384                } else {
385                    Err(cb_io_err(rc, "write"))
386                }
387            }) as crate::callback_device::WriteCb
388        });
389        let flush_cb: Option<crate::callback_device::FlushCb> = flush_fn.map(|f| {
390            Box::new(move || {
391                let ctx = ctx_addr as *mut c_void;
392                let rc = f(ctx);
393                if rc == 0 {
394                    Ok(())
395                } else {
396                    Err(cb_io_err(rc, "flush"))
397                }
398            }) as crate::callback_device::FlushCb
399        });
400
401        let dev = CallbackDevice {
402            size,
403            read: read_cb,
404            write: write_cb,
405            flush: flush_cb,
406        };
407        FsCoreDevice::into_handle(Arc::new(dev))
408    }));
409    match res {
410        Ok(p) => p,
411        Err(panic) => {
412            set_last_error(panic_message(&panic));
413            ptr::null_mut()
414        }
415    }
416}
417
418// ---------------------------------------------------------------------------
419// Slice constructor. Returns a child `FsCoreDevice` whose byte 0 maps to
420// `start` of the parent and whose addressable range is `length` bytes.
421// Useful for partition-table walkers that want to hand one partition to
422// a filesystem driver without copying. The slice keeps an `Arc` to the
423// parent, so closing the parent before the slice is fine.
424// ---------------------------------------------------------------------------
425
426/// Read-only slice. Writes via the returned handle return
427/// `FS_CORE_READ_ONLY` regardless of the parent's writability.
428#[unsafe(no_mangle)]
429pub unsafe extern "C" fn fs_core_device_slice_ro(
430    parent: *const FsCoreDevice,
431    start: u64,
432    length: u64,
433) -> *mut FsCoreDevice {
434    if parent.is_null() {
435        set_last_error("parent is null");
436        return ptr::null_mut();
437    }
438    let res = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe {
439        let parent_arc = (*parent).inner().clone();
440        // OwnedSlice takes Arc<dyn BlockRead>; trait upcast from
441        // BlockDevice -> BlockRead is supported in the pinned toolchain.
442        let parent_read: Arc<dyn crate::block::BlockRead> = parent_arc;
443        let slice = crate::slice::OwnedSlice::new(parent_read, start, length);
444        FsCoreDevice::into_handle(Arc::new(slice))
445    }));
446    match res {
447        Ok(p) => p,
448        Err(panic) => {
449            set_last_error(panic_message(&panic));
450            ptr::null_mut()
451        }
452    }
453}
454
455/// Read-write slice. Writes are forwarded to the parent at `start +
456/// offset`; writes outside `[0, length)` return `FS_CORE_OUT_OF_BOUNDS`.
457/// If the parent reports `is_writable() == false`, write attempts return
458/// `FS_CORE_READ_ONLY`.
459#[unsafe(no_mangle)]
460pub unsafe extern "C" fn fs_core_device_slice_rw(
461    parent: *const FsCoreDevice,
462    start: u64,
463    length: u64,
464) -> *mut FsCoreDevice {
465    if parent.is_null() {
466        set_last_error("parent is null");
467        return ptr::null_mut();
468    }
469    let res = std::panic::catch_unwind(AssertUnwindSafe(|| unsafe {
470        let parent_arc = (*parent).inner().clone();
471        let slice = crate::slice::OwnedRwSlice::new(parent_arc, start, length);
472        FsCoreDevice::into_handle(Arc::new(slice))
473    }));
474    match res {
475        Ok(p) => p,
476        Err(panic) => {
477            set_last_error(panic_message(&panic));
478            ptr::null_mut()
479        }
480    }
481}
482
483// ---------------------------------------------------------------------------
484// Tests — exercise the FFI surface from Rust. The C side is verified by
485// the consumer crates that use these functions through their own headers.
486// ---------------------------------------------------------------------------
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491    use std::fs::File;
492    use std::io::Write;
493
494    fn tmp_image(bytes: &[u8]) -> String {
495        use std::sync::atomic::{AtomicU32, Ordering};
496        static C: AtomicU32 = AtomicU32::new(0);
497        let n = C.fetch_add(1, Ordering::Relaxed);
498        let p = std::env::temp_dir()
499            .join(format!("fs_core_ffi_{}_{n}.img", std::process::id()))
500            .to_string_lossy()
501            .into_owned();
502        File::create(&p).unwrap().write_all(bytes).unwrap();
503        p
504    }
505
506    #[test]
507    fn open_read_close_round_trip() {
508        let path = tmp_image(b"hello, fs-core ffi");
509        let cpath = CString::new(path.as_str()).unwrap();
510        let h = unsafe { fs_core_file_open(cpath.as_ptr(), false) };
511        assert!(!h.is_null(), "open failed");
512
513        unsafe {
514            assert_eq!(fs_core_device_size_bytes(h), 18);
515            assert!(!fs_core_device_is_writable(h));
516
517            let mut buf = [0u8; 5];
518            let rc = fs_core_device_read_at(h, 0, buf.as_mut_ptr(), buf.len());
519            assert_eq!(rc, FsCoreErrorCode::Ok);
520            assert_eq!(&buf, b"hello");
521
522            // Write should fail with ReadOnly.
523            let rc = fs_core_device_write_at(h, 0, b"x".as_ptr(), 1);
524            assert_eq!(rc, FsCoreErrorCode::ReadOnly);
525
526            fs_core_device_close(h);
527        }
528        let _ = std::fs::remove_file(&path);
529    }
530
531    #[test]
532    fn null_args_return_null_arg() {
533        let mut buf = [0u8; 4];
534        let rc = unsafe { fs_core_device_read_at(ptr::null(), 0, buf.as_mut_ptr(), buf.len()) };
535        assert_eq!(rc, FsCoreErrorCode::NullArg);
536        let rc = unsafe { fs_core_device_flush(ptr::null()) };
537        assert_eq!(rc, FsCoreErrorCode::NullArg);
538    }
539
540    #[test]
541    fn last_error_populated_on_open_failure() {
542        let cpath = CString::new("/path/that/does/not/exist/we/hope").unwrap();
543        let h = unsafe { fs_core_file_open(cpath.as_ptr(), false) };
544        assert!(h.is_null());
545        let msg = fs_core_last_error_message();
546        assert!(!msg.is_null());
547        let s = unsafe { std::ffi::CStr::from_ptr(msg).to_string_lossy().into_owned() };
548        assert!(!s.is_empty(), "expected an error message");
549    }
550
551    // ---- callback-backed device tests --------------------------------
552
553    use std::sync::{Arc as StdArc, Mutex as StdMutex};
554
555    struct CbState {
556        data: Vec<u8>,
557        flushed: u32,
558    }
559
560    /// Trampoline that pulls a `*mut CbState` out of the opaque ctx.
561    unsafe extern "C" fn t_read(ctx: *mut c_void, offset: u64, buf: *mut u8, len: usize) -> c_int {
562        let st = unsafe { &mut *(ctx as *mut CbState) };
563        let off = offset as usize;
564        if off + len > st.data.len() {
565            return 5; // out of bounds
566        }
567        unsafe {
568            std::ptr::copy_nonoverlapping(st.data.as_ptr().add(off), buf, len);
569        }
570        0
571    }
572    unsafe extern "C" fn t_write(
573        ctx: *mut c_void,
574        offset: u64,
575        buf: *const u8,
576        len: usize,
577    ) -> c_int {
578        let st = unsafe { &mut *(ctx as *mut CbState) };
579        let off = offset as usize;
580        if off + len > st.data.len() {
581            return 5;
582        }
583        unsafe {
584            std::ptr::copy_nonoverlapping(buf, st.data.as_mut_ptr().add(off), len);
585        }
586        0
587    }
588    unsafe extern "C" fn t_flush(ctx: *mut c_void) -> c_int {
589        let st = unsafe { &mut *(ctx as *mut CbState) };
590        st.flushed += 1;
591        0
592    }
593
594    #[test]
595    fn callback_device_round_trip_rw() {
596        let mut st = Box::new(CbState {
597            data: vec![0u8; 32],
598            flushed: 0,
599        });
600        for (i, b) in st.data.iter_mut().enumerate() {
601            *b = i as u8;
602        }
603        let ctx = &mut *st as *mut CbState as *mut c_void;
604
605        let cfg = FsCoreCallbackCfg {
606            read: Some(t_read),
607            write: Some(t_write),
608            flush: Some(t_flush),
609            ctx,
610            size: 32,
611        };
612        let h = unsafe { fs_core_device_from_callbacks(&cfg) };
613        assert!(!h.is_null(), "device_from_callbacks returned NULL");
614
615        unsafe {
616            assert_eq!(fs_core_device_size_bytes(h), 32);
617            assert!(fs_core_device_is_writable(h));
618
619            let mut buf = [0u8; 4];
620            let rc = fs_core_device_read_at(h, 4, buf.as_mut_ptr(), buf.len());
621            assert_eq!(rc, FsCoreErrorCode::Ok);
622            assert_eq!(buf, [4, 5, 6, 7]);
623
624            let payload = [0xDE, 0xAD, 0xBE, 0xEF];
625            let rc = fs_core_device_write_at(h, 8, payload.as_ptr(), payload.len());
626            assert_eq!(rc, FsCoreErrorCode::Ok);
627
628            let rc = fs_core_device_flush(h);
629            assert_eq!(rc, FsCoreErrorCode::Ok);
630
631            let mut readback = [0u8; 4];
632            let rc = fs_core_device_read_at(h, 8, readback.as_mut_ptr(), readback.len());
633            assert_eq!(rc, FsCoreErrorCode::Ok);
634            assert_eq!(readback, payload);
635
636            fs_core_device_close(h);
637        }
638        assert_eq!(st.flushed, 1);
639        assert_eq!(&st.data[8..12], &[0xDE, 0xAD, 0xBE, 0xEF]);
640    }
641
642    #[test]
643    fn callback_device_readonly_when_write_null() {
644        let mut st = Box::new(CbState {
645            data: vec![0xAAu8; 16],
646            flushed: 0,
647        });
648        let ctx = &mut *st as *mut CbState as *mut c_void;
649        let cfg = FsCoreCallbackCfg {
650            read: Some(t_read),
651            write: None,
652            flush: None,
653            ctx,
654            size: 16,
655        };
656        let h = unsafe { fs_core_device_from_callbacks(&cfg) };
657        assert!(!h.is_null());
658        unsafe {
659            assert!(!fs_core_device_is_writable(h));
660            let rc = fs_core_device_write_at(h, 0, [1u8].as_ptr(), 1);
661            assert_eq!(rc, FsCoreErrorCode::ReadOnly);
662            // Flush is a no-op when callback is NULL.
663            assert_eq!(fs_core_device_flush(h), FsCoreErrorCode::Ok);
664            fs_core_device_close(h);
665        }
666        // suppress unused warning
667        let _ = StdArc::new(StdMutex::new(0u8));
668    }
669
670    #[test]
671    fn callback_device_null_cfg_returns_null() {
672        let h = unsafe { fs_core_device_from_callbacks(ptr::null()) };
673        assert!(h.is_null());
674        let msg = fs_core_last_error_message();
675        assert!(!msg.is_null());
676    }
677}