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
214/// What a caught panic actually said.
215///
216/// PUBLIC BECAUSE THE OTHER ELEVEN CRATES NEED IT. Each of them guards
217/// its own C entry points with `catch_unwind` and, having no way to
218/// reach this, reports the panic as `"panic in <function>"` -- the name
219/// of the function that was running, which the caller already knew, in
220/// place of the message, which is the only part it did not. An index
221/// out of bounds, a slice out of range, an `expect` with a sentence in
222/// it: all of it was thrown away at the boundary.
223///
224/// The guards themselves are NOT shareable, and that is why this is
225/// what moved rather than [`ffi_guard`]. Each crate's guard records the
226/// message into that crate's own thread-local, which is what its own C
227/// callers read; a guard from here would record into this crate's, and
228/// every panic message would land in a slot nobody reads.
229pub fn panic_message(panic: &Box<dyn std::any::Any + Send>) -> String {
230    if let Some(s) = panic.downcast_ref::<&'static str>() {
231        return (*s).to_string();
232    }
233    if let Some(s) = panic.downcast_ref::<String>() {
234        return s.clone();
235    }
236    "panic in FFI".to_string()
237}
238
239// ---------------------------------------------------------------------------
240// Device handle — opaque to C callers, shared across crates.
241// ---------------------------------------------------------------------------
242
243/// Opaque handle wrapping an `Arc<dyn BlockDevice>`. Allocated by sister
244/// crates' constructors and freed via [`fs_core_device_close`].
245pub struct FsCoreDevice {
246    inner: Arc<dyn BlockDevice>,
247}
248
249impl FsCoreDevice {
250    /// Internal constructor — sister crates use this to wrap their own
251    /// device types (Qcow2Reader, FileDevice, OwnedSlice, etc.) into the
252    /// shared handle type. Returns a `Box::into_raw` pointer ready to hand
253    /// across the FFI boundary.
254    pub fn into_handle(inner: Arc<dyn BlockDevice>) -> *mut FsCoreDevice {
255        Box::into_raw(Box::new(FsCoreDevice { inner }))
256    }
257
258    /// Borrow the inner device. `Arc::clone` it if you want shared
259    /// ownership — e.g. when handing the device to a slice adapter while
260    /// keeping the original handle alive.
261    pub fn inner(&self) -> &Arc<dyn BlockDevice> {
262        &self.inner
263    }
264}
265
266/// Free a device handle. Safe to call with NULL (no-op).
267#[unsafe(no_mangle)]
268pub unsafe extern "C" fn fs_core_device_close(handle: *mut FsCoreDevice) {
269    if handle.is_null() {
270        return;
271    }
272    ffi_guard_or((), || unsafe {
273        drop(Box::from_raw(handle));
274    });
275}
276
277/// Total device size in bytes. Returns 0 if `handle` is NULL.
278#[unsafe(no_mangle)]
279pub unsafe extern "C" fn fs_core_device_size_bytes(handle: *const FsCoreDevice) -> u64 {
280    if handle.is_null() {
281        return 0;
282    }
283    ffi_guard_or(0, || unsafe { (*handle).inner.size_bytes() })
284}
285
286/// True if `write_at` is likely to succeed. Returns false on NULL.
287#[unsafe(no_mangle)]
288pub unsafe extern "C" fn fs_core_device_is_writable(handle: *const FsCoreDevice) -> bool {
289    if handle.is_null() {
290        return false;
291    }
292    ffi_guard_or(false, || unsafe { (*handle).inner.is_writable() })
293}
294
295/// Read exactly `len` bytes from `offset` into `buf`. `buf` must be at
296/// least `len` bytes. Returns an `FsCoreErrorCode`.
297#[unsafe(no_mangle)]
298pub unsafe extern "C" fn fs_core_device_read_at(
299    handle: *const FsCoreDevice,
300    offset: u64,
301    buf: *mut u8,
302    len: usize,
303) -> FsCoreErrorCode {
304    // A null buffer is refused whatever the length. `from_raw_parts_mut`
305    // requires a non-null, aligned pointer even for a zero-length slice,
306    // so `(NULL, 0)` was undefined behaviour rather than the no-op it
307    // looks like -- in a crate that otherwise denies
308    // `unsafe_op_in_unsafe_fn`.
309    if handle.is_null() || buf.is_null() {
310        return FsCoreErrorCode::NullArg;
311    }
312    ffi_guard(|| {
313        let slice_buf = unsafe { slice::from_raw_parts_mut(buf, len) };
314        unsafe { (*handle).inner.read_at(offset, slice_buf) }
315    })
316}
317
318/// Write exactly `len` bytes from `buf` to `offset`. Returns `ReadOnly`
319/// for read-only devices.
320#[unsafe(no_mangle)]
321pub unsafe extern "C" fn fs_core_device_write_at(
322    handle: *const FsCoreDevice,
323    offset: u64,
324    buf: *const u8,
325    len: usize,
326) -> FsCoreErrorCode {
327    // Null is refused whatever the length; see `fs_core_device_read_at`.
328    if handle.is_null() || buf.is_null() {
329        return FsCoreErrorCode::NullArg;
330    }
331    ffi_guard(|| {
332        let slice_buf = unsafe { slice::from_raw_parts(buf, len) };
333        unsafe { (*handle).inner.write_at(offset, slice_buf) }
334    })
335}
336
337/// Flush pending writes to stable storage.
338#[unsafe(no_mangle)]
339pub unsafe extern "C" fn fs_core_device_flush(handle: *const FsCoreDevice) -> FsCoreErrorCode {
340    if handle.is_null() {
341        return FsCoreErrorCode::NullArg;
342    }
343    ffi_guard(|| unsafe { (*handle).inner.flush() })
344}
345
346// ---------------------------------------------------------------------------
347// Convenience: open a regular file as a device. Saves callers the trouble
348// of building a Rust crate just to wrap `FileDevice`.
349// ---------------------------------------------------------------------------
350
351/// Open `path` (NUL-terminated UTF-8) as a `FileDevice` and return a
352/// handle. Pass `writable=true` for RW. On failure returns NULL and the
353/// thread-local last-error has detail.
354#[unsafe(no_mangle)]
355pub unsafe extern "C" fn fs_core_file_open(
356    path: *const c_char,
357    writable: bool,
358) -> *mut FsCoreDevice {
359    if path.is_null() {
360        set_last_error("path is null");
361        return ptr::null_mut();
362    }
363    ffi_guard_or(ptr::null_mut(), || {
364        let cstr = unsafe { std::ffi::CStr::from_ptr(path) };
365        let s = match cstr.to_str() {
366            Ok(s) => s,
367            Err(_) => {
368                set_last_error("path is not valid UTF-8");
369                return ptr::null_mut();
370            }
371        };
372        let dev = if writable {
373            crate::file_device::FileDevice::open_rw(s)
374        } else {
375            crate::file_device::FileDevice::open(s)
376        };
377        match dev {
378            Ok(d) => FsCoreDevice::into_handle(Arc::new(d)),
379            Err(e) => {
380                set_last_error(e.to_string());
381                ptr::null_mut()
382            }
383        }
384    })
385}
386
387// ---------------------------------------------------------------------------
388// Callback-backed device. Used when the caller already owns the underlying
389// resource (FSKit FSBlockDeviceResource, Go file handle, C-side fd) and
390// wants to expose it as an `FsCoreDevice` so it can be stacked under a
391// container reader (qcow2, vhd, ...) before reaching a filesystem driver.
392// ---------------------------------------------------------------------------
393
394/// Read callback. Returns 0 on success, non-zero (errno-like) on failure.
395/// Must fully fill `len` bytes — short reads are treated as I/O errors.
396pub type FsCoreReadCb =
397    Option<unsafe extern "C" fn(ctx: *mut c_void, offset: u64, buf: *mut u8, len: usize) -> c_int>;
398
399/// Write callback. NULL → device is read-only.
400pub type FsCoreWriteCb = Option<
401    unsafe extern "C" fn(ctx: *mut c_void, offset: u64, buf: *const u8, len: usize) -> c_int,
402>;
403
404/// Flush/fsync callback. NULL → flush is a no-op.
405pub type FsCoreFlushCb = Option<unsafe extern "C" fn(ctx: *mut c_void) -> c_int>;
406
407/// Configuration passed to [`fs_core_device_from_callbacks`].
408#[repr(C)]
409pub struct FsCoreCallbackCfg {
410    pub read: FsCoreReadCb,
411    pub write: FsCoreWriteCb,
412    pub flush: FsCoreFlushCb,
413    pub ctx: *mut c_void,
414    pub size: u64,
415}
416
417/// Turn a callback's non-zero return into an `io::Error`.
418fn cb_io_err(rc: c_int, op: &str) -> io::Error {
419    io::Error::other(format!("callback {op} returned {rc}"))
420}
421
422/// The host callback contract, in one place: **zero is success**.
423///
424/// All three adapters below wrapped a call in the same four lines —
425/// invoke, compare against zero, `Ok(())` or `cb_io_err`. Three copies
426/// of a convention is three chances to write `rc != 0` where the others
427/// write `rc == 0`, and a caller would see reads succeed while writes
428/// reported failure on the very same device.
429///
430/// `op` names the operation in the error, which is the only thing the
431/// three genuinely differ in.
432fn cb_result(rc: c_int, op: &'static str) -> io::Result<()> {
433    if rc == 0 {
434        Ok(())
435    } else {
436        Err(cb_io_err(rc, op))
437    }
438}
439
440/// Build an [`FsCoreDevice`] backed by host-provided callbacks. Returns NULL
441/// on failure (config null, read callback null, etc.) and stashes detail in
442/// the thread-local last-error.
443///
444/// `cfg.ctx` is opaque to fs-core; it is passed back verbatim to every
445/// callback invocation. The caller is responsible for ensuring it remains
446/// valid until [`fs_core_device_close`] is called on the returned handle.
447#[unsafe(no_mangle)]
448pub unsafe extern "C" fn fs_core_device_from_callbacks(
449    cfg: *const FsCoreCallbackCfg,
450) -> *mut FsCoreDevice {
451    if cfg.is_null() {
452        set_last_error("cfg is null");
453        return ptr::null_mut();
454    }
455    ffi_guard_or(ptr::null_mut(), || unsafe {
456        let cfg = &*cfg;
457        let read_fn = match cfg.read {
458            Some(f) => f,
459            None => {
460                set_last_error("cfg.read is null");
461                return ptr::null_mut();
462            }
463        };
464        let write_fn = cfg.write;
465        let flush_fn = cfg.flush;
466        // `*mut c_void` is `!Send + !Sync` by default, and `unsafe impl
467        // Send` on a newtype does not propagate cleanly through closure
468        // auto-traits. Round-tripping the pointer through `usize` gives
469        // something that is `Copy + Send + Sync`, and the callback
470        // contract already puts the host on the hook for using `ctx`
471        // safely across threads.
472        let ctx_addr = cfg.ctx as usize;
473        let size = cfg.size;
474
475        let read_cb: crate::callback_device::ReadCb = Box::new(move |off, buf| {
476            let ctx = ctx_addr as *mut c_void;
477            cb_result(read_fn(ctx, off, buf.as_mut_ptr(), buf.len()), "read")
478        });
479        let write_cb: Option<crate::callback_device::WriteCb> = write_fn.map(|f| {
480            Box::new(move |off, buf: &[u8]| {
481                let ctx = ctx_addr as *mut c_void;
482                cb_result(f(ctx, off, buf.as_ptr(), buf.len()), "write")
483            }) as crate::callback_device::WriteCb
484        });
485        let flush_cb: Option<crate::callback_device::FlushCb> = flush_fn.map(|f| {
486            Box::new(move || {
487                let ctx = ctx_addr as *mut c_void;
488                cb_result(f(ctx), "flush")
489            }) as crate::callback_device::FlushCb
490        });
491
492        let dev = CallbackDevice {
493            size,
494            read: read_cb,
495            write: write_cb,
496            flush: flush_cb,
497        };
498        FsCoreDevice::into_handle(Arc::new(dev))
499    })
500}
501
502// ---------------------------------------------------------------------------
503// Slice constructor. Returns a child `FsCoreDevice` whose byte 0 maps to
504// `start` of the parent and whose addressable range is `length` bytes.
505// Useful for partition-table walkers that want to hand one partition to
506// a filesystem driver without copying. The slice keeps an `Arc` to the
507// parent, so closing the parent before the slice is fine.
508// ---------------------------------------------------------------------------
509
510/// Read-only slice. Writes via the returned handle return
511/// `FS_CORE_READ_ONLY` regardless of the parent's writability.
512#[unsafe(no_mangle)]
513pub unsafe extern "C" fn fs_core_device_slice_ro(
514    parent: *const FsCoreDevice,
515    start: u64,
516    length: u64,
517) -> *mut FsCoreDevice {
518    if parent.is_null() {
519        set_last_error("parent is null");
520        return ptr::null_mut();
521    }
522    ffi_guard_or(ptr::null_mut(), || unsafe {
523        let parent_arc = (*parent).inner().clone();
524        // OwnedSlice takes Arc<dyn BlockRead>; trait upcast from
525        // BlockDevice -> BlockRead is supported in the pinned toolchain.
526        let parent_read: Arc<dyn crate::block::BlockRead> = parent_arc;
527        let slice = crate::slice::OwnedSlice::new(parent_read, start, length);
528        FsCoreDevice::into_handle(Arc::new(slice))
529    })
530}
531
532/// Read-write slice. Writes are forwarded to the parent at `start +
533/// offset`; writes outside `[0, length)` return `FS_CORE_OUT_OF_BOUNDS`.
534/// If the parent reports `is_writable() == false`, write attempts return
535/// `FS_CORE_READ_ONLY`.
536#[unsafe(no_mangle)]
537pub unsafe extern "C" fn fs_core_device_slice_rw(
538    parent: *const FsCoreDevice,
539    start: u64,
540    length: u64,
541) -> *mut FsCoreDevice {
542    if parent.is_null() {
543        set_last_error("parent is null");
544        return ptr::null_mut();
545    }
546    ffi_guard_or(ptr::null_mut(), || unsafe {
547        let parent_arc = (*parent).inner().clone();
548        let slice = crate::slice::OwnedRwSlice::new(parent_arc, start, length);
549        FsCoreDevice::into_handle(Arc::new(slice))
550    })
551}
552
553// ---------------------------------------------------------------------------
554// Tests — exercise the FFI surface from Rust. The C side is verified by
555// the consumer crates that use these functions through their own headers.
556// ---------------------------------------------------------------------------
557
558#[cfg(test)]
559mod tests {
560    /// THE MESSAGE, not the fact that something panicked.
561    ///
562    /// Both shapes a panic payload takes: `panic!("literal")` gives a
563    /// `&'static str`, and `panic!("{x}")` or an out-of-bounds index
564    /// gives a `String`. A guard that reports neither tells its caller
565    /// only what it already knew.
566    #[test]
567    fn a_caught_panic_reports_what_it_said() {
568        let literal =
569            std::panic::catch_unwind(|| panic!("a literal message")).expect_err("it panicked");
570        assert_eq!(panic_message(&literal), "a literal message");
571
572        let owned = std::panic::catch_unwind(|| {
573            let v: Vec<u8> = Vec::new();
574            let _ = v[3];
575        })
576        .expect_err("it panicked");
577        assert!(
578            panic_message(&owned).contains("index out of bounds"),
579            "the index panic's own words should survive: {}",
580            panic_message(&owned)
581        );
582
583        // Anything else says so rather than pretending to a message.
584        let odd =
585            std::panic::catch_unwind(|| std::panic::panic_any(42u8)).expect_err("it panicked");
586        assert_eq!(panic_message(&odd), "panic in FFI");
587    }
588
589    use super::*;
590    use std::fs::File;
591    use std::io::Write;
592
593    fn tmp_image(bytes: &[u8]) -> String {
594        use std::sync::atomic::{AtomicU32, Ordering};
595        static C: AtomicU32 = AtomicU32::new(0);
596        let n = C.fetch_add(1, Ordering::Relaxed);
597        let p = std::env::temp_dir()
598            .join(format!("fs_core_ffi_{}_{n}.img", std::process::id()))
599            .to_string_lossy()
600            .into_owned();
601        File::create(&p).unwrap().write_all(bytes).unwrap();
602        p
603    }
604
605    #[test]
606    fn open_read_close_round_trip() {
607        let path = tmp_image(b"hello, fs-core ffi");
608        let cpath = CString::new(path.as_str()).unwrap();
609        let h = unsafe { fs_core_file_open(cpath.as_ptr(), false) };
610        assert!(!h.is_null(), "open failed");
611
612        unsafe {
613            assert_eq!(fs_core_device_size_bytes(h), 18);
614            assert!(!fs_core_device_is_writable(h));
615
616            let mut buf = [0u8; 5];
617            let rc = fs_core_device_read_at(h, 0, buf.as_mut_ptr(), buf.len());
618            assert_eq!(rc, FsCoreErrorCode::Ok);
619            assert_eq!(&buf, b"hello");
620
621            // Write should fail with ReadOnly.
622            let rc = fs_core_device_write_at(h, 0, b"x".as_ptr(), 1);
623            assert_eq!(rc, FsCoreErrorCode::ReadOnly);
624
625            fs_core_device_close(h);
626        }
627        let _ = std::fs::remove_file(&path);
628    }
629
630    #[test]
631    fn null_args_return_null_arg() {
632        let mut buf = [0u8; 4];
633        let rc = unsafe { fs_core_device_read_at(ptr::null(), 0, buf.as_mut_ptr(), buf.len()) };
634        assert_eq!(rc, FsCoreErrorCode::NullArg);
635        let rc = unsafe { fs_core_device_flush(ptr::null()) };
636        assert_eq!(rc, FsCoreErrorCode::NullArg);
637    }
638
639    #[test]
640    fn last_error_populated_on_open_failure() {
641        let cpath = CString::new("/path/that/does/not/exist/we/hope").unwrap();
642        let h = unsafe { fs_core_file_open(cpath.as_ptr(), false) };
643        assert!(h.is_null());
644        let msg = fs_core_last_error_message();
645        assert!(!msg.is_null());
646        let s = unsafe { std::ffi::CStr::from_ptr(msg).to_string_lossy().into_owned() };
647        assert!(!s.is_empty(), "expected an error message");
648    }
649
650    // ---- callback-backed device tests --------------------------------
651
652    use std::sync::{Arc as StdArc, Mutex as StdMutex};
653
654    struct CbState {
655        data: Vec<u8>,
656        flushed: u32,
657    }
658
659    /// Trampoline that pulls a `*mut CbState` out of the opaque ctx.
660    unsafe extern "C" fn t_read(ctx: *mut c_void, offset: u64, buf: *mut u8, len: usize) -> c_int {
661        let st = unsafe { &mut *(ctx as *mut CbState) };
662        let off = offset as usize;
663        if off + len > st.data.len() {
664            return 5; // out of bounds
665        }
666        unsafe {
667            std::ptr::copy_nonoverlapping(st.data.as_ptr().add(off), buf, len);
668        }
669        0
670    }
671    unsafe extern "C" fn t_write(
672        ctx: *mut c_void,
673        offset: u64,
674        buf: *const u8,
675        len: usize,
676    ) -> c_int {
677        let st = unsafe { &mut *(ctx as *mut CbState) };
678        let off = offset as usize;
679        if off + len > st.data.len() {
680            return 5;
681        }
682        unsafe {
683            std::ptr::copy_nonoverlapping(buf, st.data.as_mut_ptr().add(off), len);
684        }
685        0
686    }
687    unsafe extern "C" fn t_flush(ctx: *mut c_void) -> c_int {
688        let st = unsafe { &mut *(ctx as *mut CbState) };
689        st.flushed += 1;
690        0
691    }
692
693    #[test]
694    fn callback_device_round_trip_rw() {
695        let mut st = Box::new(CbState {
696            data: vec![0u8; 32],
697            flushed: 0,
698        });
699        for (i, b) in st.data.iter_mut().enumerate() {
700            *b = i as u8;
701        }
702        let ctx = &mut *st as *mut CbState as *mut c_void;
703
704        let cfg = FsCoreCallbackCfg {
705            read: Some(t_read),
706            write: Some(t_write),
707            flush: Some(t_flush),
708            ctx,
709            size: 32,
710        };
711        let h = unsafe { fs_core_device_from_callbacks(&cfg) };
712        assert!(!h.is_null(), "device_from_callbacks returned NULL");
713
714        unsafe {
715            assert_eq!(fs_core_device_size_bytes(h), 32);
716            assert!(fs_core_device_is_writable(h));
717
718            let mut buf = [0u8; 4];
719            let rc = fs_core_device_read_at(h, 4, buf.as_mut_ptr(), buf.len());
720            assert_eq!(rc, FsCoreErrorCode::Ok);
721            assert_eq!(buf, [4, 5, 6, 7]);
722
723            let payload = [0xDE, 0xAD, 0xBE, 0xEF];
724            let rc = fs_core_device_write_at(h, 8, payload.as_ptr(), payload.len());
725            assert_eq!(rc, FsCoreErrorCode::Ok);
726
727            let rc = fs_core_device_flush(h);
728            assert_eq!(rc, FsCoreErrorCode::Ok);
729
730            let mut readback = [0u8; 4];
731            let rc = fs_core_device_read_at(h, 8, readback.as_mut_ptr(), readback.len());
732            assert_eq!(rc, FsCoreErrorCode::Ok);
733            assert_eq!(readback, payload);
734
735            fs_core_device_close(h);
736        }
737        assert_eq!(st.flushed, 1);
738        assert_eq!(&st.data[8..12], &[0xDE, 0xAD, 0xBE, 0xEF]);
739    }
740
741    #[test]
742    fn callback_device_readonly_when_write_null() {
743        let mut st = Box::new(CbState {
744            data: vec![0xAAu8; 16],
745            flushed: 0,
746        });
747        let ctx = &mut *st as *mut CbState as *mut c_void;
748        let cfg = FsCoreCallbackCfg {
749            read: Some(t_read),
750            write: None,
751            flush: None,
752            ctx,
753            size: 16,
754        };
755        let h = unsafe { fs_core_device_from_callbacks(&cfg) };
756        assert!(!h.is_null());
757        unsafe {
758            assert!(!fs_core_device_is_writable(h));
759            let rc = fs_core_device_write_at(h, 0, [1u8].as_ptr(), 1);
760            assert_eq!(rc, FsCoreErrorCode::ReadOnly);
761            // Flush is a no-op when callback is NULL.
762            assert_eq!(fs_core_device_flush(h), FsCoreErrorCode::Ok);
763            fs_core_device_close(h);
764        }
765        // suppress unused warning
766        let _ = StdArc::new(StdMutex::new(0u8));
767    }
768
769    #[test]
770    fn callback_device_null_cfg_returns_null() {
771        let h = unsafe { fs_core_device_from_callbacks(ptr::null()) };
772        assert!(h.is_null());
773        let msg = fs_core_last_error_message();
774        assert!(!msg.is_null());
775    }
776}
777
778#[cfg(test)]
779mod panic_message_tests {
780    use super::*;
781    use crate::block::{BlockDevice, BlockRead};
782
783    /// A device whose every method panics.
784    ///
785    /// Not a hypothetical: a driver's `size_bytes` computes a geometry
786    /// from on-disk fields, and an arithmetic overflow there panics.
787    /// The FFI boundary is where that has to stop being a panic and
788    /// start being a reportable error.
789    struct Panicking;
790
791    impl BlockRead for Panicking {
792        fn read_at(&self, _offset: u64, _buf: &mut [u8]) -> Result<(), Error> {
793            panic!("read_at exploded")
794        }
795        fn size_bytes(&self) -> u64 {
796            panic!("size_bytes exploded")
797        }
798    }
799    impl BlockDevice for Panicking {
800        fn is_writable(&self) -> bool {
801            panic!("is_writable exploded")
802        }
803    }
804
805    fn handle() -> *mut FsCoreDevice {
806        FsCoreDevice::into_handle(std::sync::Arc::new(Panicking))
807    }
808
809    fn last_error() -> Option<String> {
810        let p = fs_core_last_error_message();
811        if p.is_null() {
812            return None;
813        }
814        Some(
815            unsafe { std::ffi::CStr::from_ptr(p) }
816                .to_string_lossy()
817                .into_owned(),
818        )
819    }
820
821    /// A panic caught at the boundary must leave a message behind.
822    ///
823    /// `fs_core_device_size_bytes` returns 0 on panic — and 0 is also
824    /// what a legitimately empty device returns. Without a message the
825    /// caller cannot tell "this device is empty" from "the driver
826    /// exploded computing its size", which is the whole reason the
827    /// thread-local error slot exists.
828    #[test]
829    fn a_panic_computing_the_size_is_reported_not_just_swallowed() {
830        clear_last_error();
831        let h = handle();
832        let size = unsafe { fs_core_device_size_bytes(h) };
833        assert_eq!(size, 0, "the fallback value is still returned");
834        let msg = last_error().expect("a caught panic must leave a message");
835        assert!(
836            msg.contains("size_bytes exploded"),
837            "the message should carry the panic's own text, got: {msg}"
838        );
839        unsafe { fs_core_device_close(h) };
840    }
841
842    /// Same for the writability probe, whose fallback is `false` — the
843    /// answer a perfectly good read-only device gives.
844    #[test]
845    fn a_panic_probing_writability_is_reported() {
846        clear_last_error();
847        let h = handle();
848        let writable = unsafe { fs_core_device_is_writable(h) };
849        assert!(!writable, "the fallback value is still returned");
850        assert!(
851            last_error().is_some(),
852            "a caught panic must leave a message"
853        );
854        unsafe { fs_core_device_close(h) };
855    }
856
857    /// A call that succeeds must not leave a stale message behind for
858    /// the next one to pick up.
859    #[test]
860    fn a_successful_call_clears_the_previous_error() {
861        let h = handle();
862        let _ = unsafe { fs_core_device_size_bytes(h) };
863        assert!(last_error().is_some(), "setup: an error is recorded");
864        unsafe { fs_core_device_close(h) };
865
866        struct Sixteen;
867        impl BlockRead for Sixteen {
868            fn read_at(&self, _offset: u64, _buf: &mut [u8]) -> Result<(), Error> {
869                Ok(())
870            }
871            fn size_bytes(&self) -> u64 {
872                16
873            }
874        }
875        impl BlockDevice for Sixteen {}
876        let h2 = FsCoreDevice::into_handle(std::sync::Arc::new(Sixteen));
877        assert_eq!(unsafe { fs_core_device_size_bytes(h2) }, 16);
878        assert!(
879            last_error().is_none(),
880            "a call that worked must not leave the previous panic's message in place"
881        );
882        unsafe { fs_core_device_close(h2) };
883    }
884}