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    /// A read the source could not satisfy in full — it ran out of data.
53    /// What a file-backed handle returns for a read off the end of the
54    /// file, and what a slice returns for a read past its own end.
55    ShortRead = 2,
56    /// Write attempted on a read-only device.
57    ReadOnly = 3,
58    /// A request refused up front because its range lies outside the
59    /// device's declared size; nothing was transferred.
60    ///
61    /// This crate returns it only for a **write** past the end of an RW
62    /// slice. It reaches **reads** from sister crates whose container
63    /// declares a virtual size (the `img-*` readers), and from this
64    /// crate's caching / read-only / slice wrappers when they forward
65    /// such a parent's error. A C consumer that only wants to know "the
66    /// read overran the device", and does not control which crate opened
67    /// the handle, should accept this and `FS_CORE_SHORT_READ` alike —
68    /// and should not treat the pair as exhaustive, since a
69    /// callback-backed handle reports its host's refusal as
70    /// `FS_CORE_IO`.
71    OutOfBounds = 4,
72    /// Driver-specific error — message in the thread-local last-error.
73    Custom = 5,
74    /// One of the input pointers was null.
75    NullArg = 6,
76    /// `catch_unwind` caught a panic crossing the FFI boundary.
77    Panic = 7,
78    /// Reserved. Never returned.
79    ///
80    /// It was meant for a path that is not valid UTF-8, but the one
81    /// function that meets that case — `fs_core_open_file` — returns a
82    /// POINTER, not a code, so it reports the failure as NULL plus a
83    /// message and cannot return this. No other entry point takes a
84    /// path.
85    ///
86    /// Kept rather than removed because the numbering is published in
87    /// `include/fs_core.h` and a consumer may already switch on 8;
88    /// renumbering the codes after it would be an ABI break for a
89    /// tidiness gain. A future path-taking function that returns a code
90    /// should use this rather than invent another.
91    BadString = 8,
92}
93
94impl FsCoreErrorCode {
95    fn from_error(e: &Error) -> Self {
96        match e {
97            Error::Io(_) => FsCoreErrorCode::Io,
98            Error::ShortRead { .. } => FsCoreErrorCode::ShortRead,
99            Error::ReadOnly => FsCoreErrorCode::ReadOnly,
100            Error::OutOfBounds { .. } => FsCoreErrorCode::OutOfBounds,
101            Error::Custom(_) => FsCoreErrorCode::Custom,
102        }
103    }
104}
105
106// ---------------------------------------------------------------------------
107// Thread-local last-error — errno-style detail companion.
108// ---------------------------------------------------------------------------
109
110thread_local! {
111    static LAST_ERROR: RefCell<Option<CString>> = const { RefCell::new(None) };
112}
113
114/// Stash a message in the thread-local, replacing any previous one. Public
115/// to sister crates so they can populate it for their own error paths.
116pub fn set_last_error(message: impl Into<String>) {
117    let s = message.into();
118    let cs = CString::new(s.replace('\0', "?")).expect("contains no NUL after replace");
119    LAST_ERROR.with(|slot| {
120        *slot.borrow_mut() = Some(cs);
121    });
122}
123
124fn clear_last_error() {
125    LAST_ERROR.with(|slot| {
126        *slot.borrow_mut() = None;
127    });
128}
129
130/// Return a pointer to the calling thread's most recent error message, or
131/// NULL if there is none. The pointer is owned by the framework and remains
132/// valid until the next FFI call on this thread.
133#[unsafe(no_mangle)]
134pub extern "C" fn fs_core_last_error_message() -> *const c_char {
135    LAST_ERROR.with(|slot| {
136        slot.borrow()
137            .as_ref()
138            .map(|cs| cs.as_ptr())
139            .unwrap_or(ptr::null())
140    })
141}
142
143/// Helper for sister crates: run `body`, catch panics, map errors to codes,
144/// stash the message in the thread-local. Returns the error code.
145pub fn ffi_guard<F>(body: F) -> FsCoreErrorCode
146where
147    F: FnOnce() -> Result<(), Error>,
148{
149    clear_last_error();
150    match std::panic::catch_unwind(AssertUnwindSafe(body)) {
151        Ok(Ok(())) => FsCoreErrorCode::Ok,
152        Ok(Err(e)) => {
153            let code = FsCoreErrorCode::from_error(&e);
154            set_last_error(e.to_string());
155            code
156        }
157        Err(panic) => {
158            set_last_error(panic_message(&panic));
159            FsCoreErrorCode::Panic
160        }
161    }
162}
163
164/// Run `body`, catching a panic and returning `fail` instead — and
165/// recording the panic's message where a caller can read it.
166///
167/// # Why the message matters more than the fallback
168///
169/// Every fallback value here is also a legitimate answer. Zero is what
170/// an empty device reports for its size; `false` is what a read-only
171/// device reports for writability; a null pointer is what a failed open
172/// returns. So a caller that only sees the fallback cannot tell an
173/// ordinary answer from a driver that exploded computing it.
174///
175/// [`fs_core_last_error_message`] is what separates them, and a guard
176/// that returns the fallback without setting it throws away the only
177/// evidence there was.
178///
179/// # Why this is separate from [`ffi_guard`]
180///
181/// `ffi_guard` returns an [`FsCoreErrorCode`] and takes a body that
182/// returns `Result<(), Error>`. That fits an entry point whose whole
183/// answer is a status code, and fits nothing else — which is why the
184/// eight entry points in this file that return a size, a flag or a
185/// pointer each wrote `catch_unwind(AssertUnwindSafe(…)).unwrap_or(…)`
186/// by hand instead, sixty lines below the helper.
187///
188/// Sister crates did the same: eleven of them re-roll one of these two
189/// shapes rather than share either.
190///
191/// The error slot is cleared on entry, like [`ffi_guard`]: a call that
192/// succeeds must not leave the previous call's message in place for a
193/// caller to read and attribute to this one.
194///
195/// `AssertUnwindSafe` is used deliberately. The bodies here touch a
196/// handle the caller owns and a thread-local error slot; a panic can
197/// leave neither in a state another call can observe as inconsistent,
198/// because the handle is not read again on this path and the slot is
199/// overwritten whole.
200pub fn ffi_guard_or<T, F>(fail: T, body: F) -> T
201where
202    F: FnOnce() -> T,
203{
204    clear_last_error();
205    match std::panic::catch_unwind(AssertUnwindSafe(body)) {
206        Ok(value) => value,
207        Err(panic) => {
208            set_last_error(panic_message(&panic));
209            fail
210        }
211    }
212}
213
214fn panic_message(panic: &Box<dyn std::any::Any + Send>) -> String {
215    if let Some(s) = panic.downcast_ref::<&'static str>() {
216        return (*s).to_string();
217    }
218    if let Some(s) = panic.downcast_ref::<String>() {
219        return s.clone();
220    }
221    "panic in FFI".to_string()
222}
223
224// ---------------------------------------------------------------------------
225// Device handle — opaque to C callers, shared across crates.
226// ---------------------------------------------------------------------------
227
228/// Opaque handle wrapping an `Arc<dyn BlockDevice>`. Allocated by sister
229/// crates' constructors and freed via [`fs_core_device_close`].
230pub struct FsCoreDevice {
231    inner: Arc<dyn BlockDevice>,
232}
233
234impl FsCoreDevice {
235    /// Internal constructor — sister crates use this to wrap their own
236    /// device types (Qcow2Reader, FileDevice, OwnedSlice, etc.) into the
237    /// shared handle type. Returns a `Box::into_raw` pointer ready to hand
238    /// across the FFI boundary.
239    pub fn into_handle(inner: Arc<dyn BlockDevice>) -> *mut FsCoreDevice {
240        Box::into_raw(Box::new(FsCoreDevice { inner }))
241    }
242
243    /// Borrow the inner device. `Arc::clone` it if you want shared
244    /// ownership — e.g. when handing the device to a slice adapter while
245    /// keeping the original handle alive.
246    pub fn inner(&self) -> &Arc<dyn BlockDevice> {
247        &self.inner
248    }
249}
250
251/// Free a device handle. Safe to call with NULL (no-op).
252#[unsafe(no_mangle)]
253pub unsafe extern "C" fn fs_core_device_close(handle: *mut FsCoreDevice) {
254    if handle.is_null() {
255        return;
256    }
257    ffi_guard_or((), || unsafe {
258        drop(Box::from_raw(handle));
259    });
260}
261
262/// Total device size in bytes. Returns 0 if `handle` is NULL.
263#[unsafe(no_mangle)]
264pub unsafe extern "C" fn fs_core_device_size_bytes(handle: *const FsCoreDevice) -> u64 {
265    if handle.is_null() {
266        return 0;
267    }
268    ffi_guard_or(0, || unsafe { (*handle).inner.size_bytes() })
269}
270
271/// True if `write_at` is likely to succeed. Returns false on NULL.
272#[unsafe(no_mangle)]
273pub unsafe extern "C" fn fs_core_device_is_writable(handle: *const FsCoreDevice) -> bool {
274    if handle.is_null() {
275        return false;
276    }
277    ffi_guard_or(false, || unsafe { (*handle).inner.is_writable() })
278}
279
280/// Read exactly `len` bytes from `offset` into `buf`. `buf` must be at
281/// least `len` bytes. Returns an `FsCoreErrorCode`.
282#[unsafe(no_mangle)]
283pub unsafe extern "C" fn fs_core_device_read_at(
284    handle: *const FsCoreDevice,
285    offset: u64,
286    buf: *mut u8,
287    len: usize,
288) -> FsCoreErrorCode {
289    // A null buffer is refused whatever the length. `from_raw_parts_mut`
290    // requires a non-null, aligned pointer even for a zero-length slice,
291    // so `(NULL, 0)` was undefined behaviour rather than the no-op it
292    // looks like -- in a crate that otherwise denies
293    // `unsafe_op_in_unsafe_fn`.
294    if handle.is_null() || buf.is_null() {
295        return FsCoreErrorCode::NullArg;
296    }
297    ffi_guard(|| {
298        let slice_buf = unsafe { slice::from_raw_parts_mut(buf, len) };
299        unsafe { (*handle).inner.read_at(offset, slice_buf) }
300    })
301}
302
303/// Write exactly `len` bytes from `buf` to `offset`. Returns `ReadOnly`
304/// for read-only devices.
305#[unsafe(no_mangle)]
306pub unsafe extern "C" fn fs_core_device_write_at(
307    handle: *const FsCoreDevice,
308    offset: u64,
309    buf: *const u8,
310    len: usize,
311) -> FsCoreErrorCode {
312    // Null is refused whatever the length; see `fs_core_device_read_at`.
313    if handle.is_null() || buf.is_null() {
314        return FsCoreErrorCode::NullArg;
315    }
316    ffi_guard(|| {
317        let slice_buf = unsafe { slice::from_raw_parts(buf, len) };
318        unsafe { (*handle).inner.write_at(offset, slice_buf) }
319    })
320}
321
322/// Flush pending writes to stable storage.
323#[unsafe(no_mangle)]
324pub unsafe extern "C" fn fs_core_device_flush(handle: *const FsCoreDevice) -> FsCoreErrorCode {
325    if handle.is_null() {
326        return FsCoreErrorCode::NullArg;
327    }
328    ffi_guard(|| unsafe { (*handle).inner.flush() })
329}
330
331// ---------------------------------------------------------------------------
332// Convenience: open a regular file as a device. Saves callers the trouble
333// of building a Rust crate just to wrap `FileDevice`.
334// ---------------------------------------------------------------------------
335
336/// Open `path` (NUL-terminated UTF-8) as a `FileDevice` and return a
337/// handle. Pass `writable=true` for RW. On failure returns NULL and the
338/// thread-local last-error has detail.
339#[unsafe(no_mangle)]
340pub unsafe extern "C" fn fs_core_file_open(
341    path: *const c_char,
342    writable: bool,
343) -> *mut FsCoreDevice {
344    if path.is_null() {
345        set_last_error("path is null");
346        return ptr::null_mut();
347    }
348    ffi_guard_or(ptr::null_mut(), || {
349        let cstr = unsafe { std::ffi::CStr::from_ptr(path) };
350        let s = match cstr.to_str() {
351            Ok(s) => s,
352            Err(_) => {
353                set_last_error("path is not valid UTF-8");
354                return ptr::null_mut();
355            }
356        };
357        let dev = if writable {
358            crate::file_device::FileDevice::open_rw(s)
359        } else {
360            crate::file_device::FileDevice::open(s)
361        };
362        match dev {
363            Ok(d) => FsCoreDevice::into_handle(Arc::new(d)),
364            Err(e) => {
365                set_last_error(e.to_string());
366                ptr::null_mut()
367            }
368        }
369    })
370}
371
372// ---------------------------------------------------------------------------
373// Callback-backed device. Used when the caller already owns the underlying
374// resource (FSKit FSBlockDeviceResource, Go file handle, C-side fd) and
375// wants to expose it as an `FsCoreDevice` so it can be stacked under a
376// container reader (qcow2, vhd, ...) before reaching a filesystem driver.
377// ---------------------------------------------------------------------------
378
379/// Read callback. Returns 0 on success, non-zero (errno-like) on failure.
380/// Must fully fill `len` bytes — short reads are treated as I/O errors.
381pub type FsCoreReadCb =
382    Option<unsafe extern "C" fn(ctx: *mut c_void, offset: u64, buf: *mut u8, len: usize) -> c_int>;
383
384/// Write callback. NULL → device is read-only.
385pub type FsCoreWriteCb = Option<
386    unsafe extern "C" fn(ctx: *mut c_void, offset: u64, buf: *const u8, len: usize) -> c_int,
387>;
388
389/// Flush/fsync callback. NULL → flush is a no-op.
390pub type FsCoreFlushCb = Option<unsafe extern "C" fn(ctx: *mut c_void) -> c_int>;
391
392/// Configuration passed to [`fs_core_device_from_callbacks`].
393#[repr(C)]
394pub struct FsCoreCallbackCfg {
395    pub read: FsCoreReadCb,
396    pub write: FsCoreWriteCb,
397    pub flush: FsCoreFlushCb,
398    pub ctx: *mut c_void,
399    pub size: u64,
400}
401
402/// Turn a callback's non-zero return into an `io::Error`.
403fn cb_io_err(rc: c_int, op: &str) -> io::Error {
404    io::Error::other(format!("callback {op} returned {rc}"))
405}
406
407/// The host callback contract, in one place: **zero is success**.
408///
409/// All three adapters below wrapped a call in the same four lines —
410/// invoke, compare against zero, `Ok(())` or `cb_io_err`. Three copies
411/// of a convention is three chances to write `rc != 0` where the others
412/// write `rc == 0`, and a caller would see reads succeed while writes
413/// reported failure on the very same device.
414///
415/// `op` names the operation in the error, which is the only thing the
416/// three genuinely differ in.
417fn cb_result(rc: c_int, op: &'static str) -> io::Result<()> {
418    if rc == 0 {
419        Ok(())
420    } else {
421        Err(cb_io_err(rc, op))
422    }
423}
424
425/// Build an [`FsCoreDevice`] backed by host-provided callbacks. Returns NULL
426/// on failure (config null, read callback null, etc.) and stashes detail in
427/// the thread-local last-error.
428///
429/// `cfg.ctx` is opaque to fs-core; it is passed back verbatim to every
430/// callback invocation. The caller is responsible for ensuring it remains
431/// valid until [`fs_core_device_close`] is called on the returned handle.
432#[unsafe(no_mangle)]
433pub unsafe extern "C" fn fs_core_device_from_callbacks(
434    cfg: *const FsCoreCallbackCfg,
435) -> *mut FsCoreDevice {
436    if cfg.is_null() {
437        set_last_error("cfg is null");
438        return ptr::null_mut();
439    }
440    ffi_guard_or(ptr::null_mut(), || unsafe {
441        let cfg = &*cfg;
442        let read_fn = match cfg.read {
443            Some(f) => f,
444            None => {
445                set_last_error("cfg.read is null");
446                return ptr::null_mut();
447            }
448        };
449        let write_fn = cfg.write;
450        let flush_fn = cfg.flush;
451        // `*mut c_void` is `!Send + !Sync` by default, and `unsafe impl
452        // Send` on a newtype does not propagate cleanly through closure
453        // auto-traits. Round-tripping the pointer through `usize` gives
454        // something that is `Copy + Send + Sync`, and the callback
455        // contract already puts the host on the hook for using `ctx`
456        // safely across threads.
457        let ctx_addr = cfg.ctx as usize;
458        let size = cfg.size;
459
460        let read_cb: crate::callback_device::ReadCb = Box::new(move |off, buf| {
461            let ctx = ctx_addr as *mut c_void;
462            cb_result(read_fn(ctx, off, buf.as_mut_ptr(), buf.len()), "read")
463        });
464        let write_cb: Option<crate::callback_device::WriteCb> = write_fn.map(|f| {
465            Box::new(move |off, buf: &[u8]| {
466                let ctx = ctx_addr as *mut c_void;
467                cb_result(f(ctx, off, buf.as_ptr(), buf.len()), "write")
468            }) as crate::callback_device::WriteCb
469        });
470        let flush_cb: Option<crate::callback_device::FlushCb> = flush_fn.map(|f| {
471            Box::new(move || {
472                let ctx = ctx_addr as *mut c_void;
473                cb_result(f(ctx), "flush")
474            }) as crate::callback_device::FlushCb
475        });
476
477        let dev = CallbackDevice {
478            size,
479            read: read_cb,
480            write: write_cb,
481            flush: flush_cb,
482        };
483        FsCoreDevice::into_handle(Arc::new(dev))
484    })
485}
486
487// ---------------------------------------------------------------------------
488// Slice constructor. Returns a child `FsCoreDevice` whose byte 0 maps to
489// `start` of the parent and whose addressable range is `length` bytes.
490// Useful for partition-table walkers that want to hand one partition to
491// a filesystem driver without copying. The slice keeps an `Arc` to the
492// parent, so closing the parent before the slice is fine.
493// ---------------------------------------------------------------------------
494
495/// Read-only slice. Writes via the returned handle return
496/// `FS_CORE_READ_ONLY` regardless of the parent's writability.
497#[unsafe(no_mangle)]
498pub unsafe extern "C" fn fs_core_device_slice_ro(
499    parent: *const FsCoreDevice,
500    start: u64,
501    length: u64,
502) -> *mut FsCoreDevice {
503    if parent.is_null() {
504        set_last_error("parent is null");
505        return ptr::null_mut();
506    }
507    ffi_guard_or(ptr::null_mut(), || unsafe {
508        let parent_arc = (*parent).inner().clone();
509        // OwnedSlice takes Arc<dyn BlockRead>; trait upcast from
510        // BlockDevice -> BlockRead is supported in the pinned toolchain.
511        let parent_read: Arc<dyn crate::block::BlockRead> = parent_arc;
512        let slice = crate::slice::OwnedSlice::new(parent_read, start, length);
513        FsCoreDevice::into_handle(Arc::new(slice))
514    })
515}
516
517/// Read-write slice. Writes are forwarded to the parent at `start +
518/// offset`; writes outside `[0, length)` return `FS_CORE_OUT_OF_BOUNDS`.
519/// If the parent reports `is_writable() == false`, write attempts return
520/// `FS_CORE_READ_ONLY`.
521#[unsafe(no_mangle)]
522pub unsafe extern "C" fn fs_core_device_slice_rw(
523    parent: *const FsCoreDevice,
524    start: u64,
525    length: u64,
526) -> *mut FsCoreDevice {
527    if parent.is_null() {
528        set_last_error("parent is null");
529        return ptr::null_mut();
530    }
531    ffi_guard_or(ptr::null_mut(), || unsafe {
532        let parent_arc = (*parent).inner().clone();
533        let slice = crate::slice::OwnedRwSlice::new(parent_arc, start, length);
534        FsCoreDevice::into_handle(Arc::new(slice))
535    })
536}
537
538// ---------------------------------------------------------------------------
539// Tests — exercise the FFI surface from Rust. The C side is verified by
540// the consumer crates that use these functions through their own headers.
541// ---------------------------------------------------------------------------
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use std::fs::File;
547    use std::io::Write;
548
549    fn tmp_image(bytes: &[u8]) -> String {
550        use std::sync::atomic::{AtomicU32, Ordering};
551        static C: AtomicU32 = AtomicU32::new(0);
552        let n = C.fetch_add(1, Ordering::Relaxed);
553        let p = std::env::temp_dir()
554            .join(format!("fs_core_ffi_{}_{n}.img", std::process::id()))
555            .to_string_lossy()
556            .into_owned();
557        File::create(&p).unwrap().write_all(bytes).unwrap();
558        p
559    }
560
561    #[test]
562    fn open_read_close_round_trip() {
563        let path = tmp_image(b"hello, fs-core ffi");
564        let cpath = CString::new(path.as_str()).unwrap();
565        let h = unsafe { fs_core_file_open(cpath.as_ptr(), false) };
566        assert!(!h.is_null(), "open failed");
567
568        unsafe {
569            assert_eq!(fs_core_device_size_bytes(h), 18);
570            assert!(!fs_core_device_is_writable(h));
571
572            let mut buf = [0u8; 5];
573            let rc = fs_core_device_read_at(h, 0, buf.as_mut_ptr(), buf.len());
574            assert_eq!(rc, FsCoreErrorCode::Ok);
575            assert_eq!(&buf, b"hello");
576
577            // Write should fail with ReadOnly.
578            let rc = fs_core_device_write_at(h, 0, b"x".as_ptr(), 1);
579            assert_eq!(rc, FsCoreErrorCode::ReadOnly);
580
581            fs_core_device_close(h);
582        }
583        let _ = std::fs::remove_file(&path);
584    }
585
586    #[test]
587    fn null_args_return_null_arg() {
588        let mut buf = [0u8; 4];
589        let rc = unsafe { fs_core_device_read_at(ptr::null(), 0, buf.as_mut_ptr(), buf.len()) };
590        assert_eq!(rc, FsCoreErrorCode::NullArg);
591        let rc = unsafe { fs_core_device_flush(ptr::null()) };
592        assert_eq!(rc, FsCoreErrorCode::NullArg);
593    }
594
595    #[test]
596    fn last_error_populated_on_open_failure() {
597        let cpath = CString::new("/path/that/does/not/exist/we/hope").unwrap();
598        let h = unsafe { fs_core_file_open(cpath.as_ptr(), false) };
599        assert!(h.is_null());
600        let msg = fs_core_last_error_message();
601        assert!(!msg.is_null());
602        let s = unsafe { std::ffi::CStr::from_ptr(msg).to_string_lossy().into_owned() };
603        assert!(!s.is_empty(), "expected an error message");
604    }
605
606    // ---- callback-backed device tests --------------------------------
607
608    use std::sync::{Arc as StdArc, Mutex as StdMutex};
609
610    struct CbState {
611        data: Vec<u8>,
612        flushed: u32,
613    }
614
615    /// Trampoline that pulls a `*mut CbState` out of the opaque ctx.
616    unsafe extern "C" fn t_read(ctx: *mut c_void, offset: u64, buf: *mut u8, len: usize) -> c_int {
617        let st = unsafe { &mut *(ctx as *mut CbState) };
618        let off = offset as usize;
619        if off + len > st.data.len() {
620            return 5; // out of bounds
621        }
622        unsafe {
623            std::ptr::copy_nonoverlapping(st.data.as_ptr().add(off), buf, len);
624        }
625        0
626    }
627    unsafe extern "C" fn t_write(
628        ctx: *mut c_void,
629        offset: u64,
630        buf: *const u8,
631        len: usize,
632    ) -> c_int {
633        let st = unsafe { &mut *(ctx as *mut CbState) };
634        let off = offset as usize;
635        if off + len > st.data.len() {
636            return 5;
637        }
638        unsafe {
639            std::ptr::copy_nonoverlapping(buf, st.data.as_mut_ptr().add(off), len);
640        }
641        0
642    }
643    unsafe extern "C" fn t_flush(ctx: *mut c_void) -> c_int {
644        let st = unsafe { &mut *(ctx as *mut CbState) };
645        st.flushed += 1;
646        0
647    }
648
649    #[test]
650    fn callback_device_round_trip_rw() {
651        let mut st = Box::new(CbState {
652            data: vec![0u8; 32],
653            flushed: 0,
654        });
655        for (i, b) in st.data.iter_mut().enumerate() {
656            *b = i as u8;
657        }
658        let ctx = &mut *st as *mut CbState as *mut c_void;
659
660        let cfg = FsCoreCallbackCfg {
661            read: Some(t_read),
662            write: Some(t_write),
663            flush: Some(t_flush),
664            ctx,
665            size: 32,
666        };
667        let h = unsafe { fs_core_device_from_callbacks(&cfg) };
668        assert!(!h.is_null(), "device_from_callbacks returned NULL");
669
670        unsafe {
671            assert_eq!(fs_core_device_size_bytes(h), 32);
672            assert!(fs_core_device_is_writable(h));
673
674            let mut buf = [0u8; 4];
675            let rc = fs_core_device_read_at(h, 4, buf.as_mut_ptr(), buf.len());
676            assert_eq!(rc, FsCoreErrorCode::Ok);
677            assert_eq!(buf, [4, 5, 6, 7]);
678
679            let payload = [0xDE, 0xAD, 0xBE, 0xEF];
680            let rc = fs_core_device_write_at(h, 8, payload.as_ptr(), payload.len());
681            assert_eq!(rc, FsCoreErrorCode::Ok);
682
683            let rc = fs_core_device_flush(h);
684            assert_eq!(rc, FsCoreErrorCode::Ok);
685
686            let mut readback = [0u8; 4];
687            let rc = fs_core_device_read_at(h, 8, readback.as_mut_ptr(), readback.len());
688            assert_eq!(rc, FsCoreErrorCode::Ok);
689            assert_eq!(readback, payload);
690
691            fs_core_device_close(h);
692        }
693        assert_eq!(st.flushed, 1);
694        assert_eq!(&st.data[8..12], &[0xDE, 0xAD, 0xBE, 0xEF]);
695    }
696
697    #[test]
698    fn callback_device_readonly_when_write_null() {
699        let mut st = Box::new(CbState {
700            data: vec![0xAAu8; 16],
701            flushed: 0,
702        });
703        let ctx = &mut *st as *mut CbState as *mut c_void;
704        let cfg = FsCoreCallbackCfg {
705            read: Some(t_read),
706            write: None,
707            flush: None,
708            ctx,
709            size: 16,
710        };
711        let h = unsafe { fs_core_device_from_callbacks(&cfg) };
712        assert!(!h.is_null());
713        unsafe {
714            assert!(!fs_core_device_is_writable(h));
715            let rc = fs_core_device_write_at(h, 0, [1u8].as_ptr(), 1);
716            assert_eq!(rc, FsCoreErrorCode::ReadOnly);
717            // Flush is a no-op when callback is NULL.
718            assert_eq!(fs_core_device_flush(h), FsCoreErrorCode::Ok);
719            fs_core_device_close(h);
720        }
721        // suppress unused warning
722        let _ = StdArc::new(StdMutex::new(0u8));
723    }
724
725    #[test]
726    fn callback_device_null_cfg_returns_null() {
727        let h = unsafe { fs_core_device_from_callbacks(ptr::null()) };
728        assert!(h.is_null());
729        let msg = fs_core_last_error_message();
730        assert!(!msg.is_null());
731    }
732}
733
734#[cfg(test)]
735mod panic_message_tests {
736    use super::*;
737    use crate::block::{BlockDevice, BlockRead};
738
739    /// A device whose every method panics.
740    ///
741    /// Not a hypothetical: a driver's `size_bytes` computes a geometry
742    /// from on-disk fields, and an arithmetic overflow there panics.
743    /// The FFI boundary is where that has to stop being a panic and
744    /// start being a reportable error.
745    struct Panicking;
746
747    impl BlockRead for Panicking {
748        fn read_at(&self, _offset: u64, _buf: &mut [u8]) -> Result<(), Error> {
749            panic!("read_at exploded")
750        }
751        fn size_bytes(&self) -> u64 {
752            panic!("size_bytes exploded")
753        }
754    }
755    impl BlockDevice for Panicking {
756        fn is_writable(&self) -> bool {
757            panic!("is_writable exploded")
758        }
759    }
760
761    fn handle() -> *mut FsCoreDevice {
762        FsCoreDevice::into_handle(std::sync::Arc::new(Panicking))
763    }
764
765    fn last_error() -> Option<String> {
766        let p = fs_core_last_error_message();
767        if p.is_null() {
768            return None;
769        }
770        Some(
771            unsafe { std::ffi::CStr::from_ptr(p) }
772                .to_string_lossy()
773                .into_owned(),
774        )
775    }
776
777    /// A panic caught at the boundary must leave a message behind.
778    ///
779    /// `fs_core_device_size_bytes` returns 0 on panic — and 0 is also
780    /// what a legitimately empty device returns. Without a message the
781    /// caller cannot tell "this device is empty" from "the driver
782    /// exploded computing its size", which is the whole reason the
783    /// thread-local error slot exists.
784    #[test]
785    fn a_panic_computing_the_size_is_reported_not_just_swallowed() {
786        clear_last_error();
787        let h = handle();
788        let size = unsafe { fs_core_device_size_bytes(h) };
789        assert_eq!(size, 0, "the fallback value is still returned");
790        let msg = last_error().expect("a caught panic must leave a message");
791        assert!(
792            msg.contains("size_bytes exploded"),
793            "the message should carry the panic's own text, got: {msg}"
794        );
795        unsafe { fs_core_device_close(h) };
796    }
797
798    /// Same for the writability probe, whose fallback is `false` — the
799    /// answer a perfectly good read-only device gives.
800    #[test]
801    fn a_panic_probing_writability_is_reported() {
802        clear_last_error();
803        let h = handle();
804        let writable = unsafe { fs_core_device_is_writable(h) };
805        assert!(!writable, "the fallback value is still returned");
806        assert!(
807            last_error().is_some(),
808            "a caught panic must leave a message"
809        );
810        unsafe { fs_core_device_close(h) };
811    }
812
813    /// A call that succeeds must not leave a stale message behind for
814    /// the next one to pick up.
815    #[test]
816    fn a_successful_call_clears_the_previous_error() {
817        let h = handle();
818        let _ = unsafe { fs_core_device_size_bytes(h) };
819        assert!(last_error().is_some(), "setup: an error is recorded");
820        unsafe { fs_core_device_close(h) };
821
822        struct Sixteen;
823        impl BlockRead for Sixteen {
824            fn read_at(&self, _offset: u64, _buf: &mut [u8]) -> Result<(), Error> {
825                Ok(())
826            }
827            fn size_bytes(&self) -> u64 {
828                16
829            }
830        }
831        impl BlockDevice for Sixteen {}
832        let h2 = FsCoreDevice::into_handle(std::sync::Arc::new(Sixteen));
833        assert_eq!(unsafe { fs_core_device_size_bytes(h2) }, 16);
834        assert!(
835            last_error().is_none(),
836            "a call that worked must not leave the previous panic's message in place"
837        );
838        unsafe { fs_core_device_close(h2) };
839    }
840}