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, BlockRead};
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 for a **write** past the end of an RW
62    /// slice, and for a write past the end of a file-backed device --
63    /// both refused up front, nothing transferred. It reaches **reads**
64    /// from sister crates whose container
65    /// declares a virtual size (the `img-*` readers), and from this
66    /// crate's caching / read-only / slice wrappers when they forward
67    /// such a parent's error. A C consumer that only wants to know "the
68    /// read overran the device", and does not control which crate opened
69    /// the handle, should accept this and `FS_CORE_SHORT_READ` alike —
70    /// and should not treat the pair as exhaustive, since a
71    /// callback-backed handle reports its host's refusal as
72    /// `FS_CORE_IO`.
73    OutOfBounds = 4,
74    /// Driver-specific error — message in the thread-local last-error.
75    Custom = 5,
76    /// One of the input pointers was null.
77    NullArg = 6,
78    /// `catch_unwind` caught a panic crossing the FFI boundary.
79    Panic = 7,
80    /// Reserved. Never returned.
81    ///
82    /// It was meant for a path that is not valid UTF-8, but the one
83    /// function that meets that case — `fs_core_file_open` — returns a
84    /// POINTER, not a code, so it reports the failure as NULL plus a
85    /// message and cannot return this. No other entry point takes a
86    /// path.
87    ///
88    /// Kept rather than removed because the numbering is published in
89    /// `include/fs_core.h` and a consumer may already switch on 8;
90    /// renumbering the codes after it would be an ABI break for a
91    /// tidiness gain. A future path-taking function that returns a code
92    /// should use this rather than invent another.
93    BadString = 8,
94}
95
96impl FsCoreErrorCode {
97    fn from_error(e: &Error) -> Self {
98        match e {
99            Error::Io(_) => FsCoreErrorCode::Io,
100            Error::ShortRead { .. } => FsCoreErrorCode::ShortRead,
101            Error::ReadOnly => FsCoreErrorCode::ReadOnly,
102            Error::OutOfBounds { .. } => FsCoreErrorCode::OutOfBounds,
103            Error::Custom(_) => FsCoreErrorCode::Custom,
104        }
105    }
106}
107
108// ---------------------------------------------------------------------------
109// Thread-local last-error — errno-style detail companion.
110// ---------------------------------------------------------------------------
111
112thread_local! {
113    static LAST_ERROR: RefCell<Option<CString>> = const { RefCell::new(None) };
114}
115
116/// Stash a message in the thread-local, replacing any previous one. Public
117/// to sister crates so they can populate it for their own error paths.
118pub fn set_last_error(message: impl Into<String>) {
119    let s = message.into();
120    let cs = CString::new(s.replace('\0', "?")).expect("contains no NUL after replace");
121    LAST_ERROR.with(|slot| {
122        *slot.borrow_mut() = Some(cs);
123    });
124}
125
126fn clear_last_error() {
127    LAST_ERROR.with(|slot| {
128        *slot.borrow_mut() = None;
129    });
130}
131
132/// Return a pointer to the calling thread's most recent error message, or
133/// NULL if there is none. The pointer is owned by the framework and remains
134/// valid until the next FFI call on this thread.
135#[unsafe(no_mangle)]
136pub extern "C" fn fs_core_last_error_message() -> *const c_char {
137    LAST_ERROR.with(|slot| {
138        slot.borrow()
139            .as_ref()
140            .map(|cs| cs.as_ptr())
141            .unwrap_or(ptr::null())
142    })
143}
144
145/// Helper for sister crates: run `body`, catch panics, map errors to codes,
146/// stash the message in the thread-local. Returns the error code.
147pub fn ffi_guard<F>(body: F) -> FsCoreErrorCode
148where
149    F: FnOnce() -> Result<(), Error>,
150{
151    clear_last_error();
152    match std::panic::catch_unwind(AssertUnwindSafe(body)) {
153        Ok(Ok(())) => FsCoreErrorCode::Ok,
154        Ok(Err(e)) => {
155            let code = FsCoreErrorCode::from_error(&e);
156            set_last_error(e.to_string());
157            code
158        }
159        Err(panic) => {
160            set_last_error(panic_message(&panic));
161            FsCoreErrorCode::Panic
162        }
163    }
164}
165
166/// Run `body`, catching a panic and returning `fail` instead — and
167/// recording the panic's message where a caller can read it.
168///
169/// # Why the message matters more than the fallback
170///
171/// Every fallback value here is also a legitimate answer. Zero is what
172/// an empty device reports for its size; `false` is what a read-only
173/// device reports for writability; a null pointer is what a failed open
174/// returns. So a caller that only sees the fallback cannot tell an
175/// ordinary answer from a driver that exploded computing it.
176///
177/// [`fs_core_last_error_message`] is what separates them, and a guard
178/// that returns the fallback without setting it throws away the only
179/// evidence there was.
180///
181/// # Why this is separate from [`ffi_guard`]
182///
183/// `ffi_guard` returns an [`FsCoreErrorCode`] and takes a body that
184/// returns `Result<(), Error>`. That fits an entry point whose whole
185/// answer is a status code, and fits nothing else — which is why the
186/// eight entry points in this file that return a size, a flag or a
187/// pointer each wrote `catch_unwind(AssertUnwindSafe(…)).unwrap_or(…)`
188/// by hand instead, sixty lines below the helper.
189///
190/// Sister crates did the same: eleven of them re-roll one of these two
191/// shapes rather than share either.
192///
193/// The error slot is cleared on entry, like [`ffi_guard`]: a call that
194/// succeeds must not leave the previous call's message in place for a
195/// caller to read and attribute to this one.
196///
197/// `AssertUnwindSafe` is used deliberately. The bodies here touch a
198/// handle the caller owns and a thread-local error slot; a panic can
199/// leave neither in a state another call can observe as inconsistent,
200/// because the handle is not read again on this path and the slot is
201/// overwritten whole.
202pub fn ffi_guard_or<T, F>(fail: T, body: F) -> T
203where
204    F: FnOnce() -> T,
205{
206    clear_last_error();
207    match std::panic::catch_unwind(AssertUnwindSafe(body)) {
208        Ok(value) => value,
209        Err(panic) => {
210            set_last_error(panic_message(&panic));
211            fail
212        }
213    }
214}
215
216/// Run a cleanup body — a `close`, a `free` — catching a panic so it
217/// cannot unwind into C, and **leaving the error slot untouched**.
218///
219/// # Why a third guard rather than one of the two above
220///
221/// The other two own the slot, because they have something to say
222/// through it: a status code to explain, or a fallback value that needs
223/// separating from a legitimate answer. A cleanup function that returns
224/// `void` has neither. Running it through [`ffi_guard_or`] therefore
225/// cleared a slot it could never fill, and destroyed the diagnostic in
226/// the ordinary C shape where the free comes before the log — see
227/// [`fs_core_device_close`].
228///
229/// So the rule this restores is that the slot's lifecycle belongs to the
230/// call that can report through it, rather than to whichever helper
231/// happened to wrap the body.
232///
233/// # A panic here is caught and NOT reported, deliberately
234///
235/// There is nowhere to report it. The return type is `()`, so a caller
236/// learns nothing from the call itself, and the slot is the one thing it
237/// is about to read for the *earlier* failure that sent it down the
238/// cleanup path. Overwriting that with a message about the free would
239/// destroy the very diagnostic this exists to preserve, and it is the
240/// earlier error a caller is looking for. Swallowing the panic is the
241/// lesser loss of the two, and it is a choice rather than an oversight.
242///
243/// `AssertUnwindSafe` for the same reason as [`ffi_guard_or`]: the body
244/// touches a handle the caller owns and is not read again on this path.
245pub fn ffi_guard_cleanup<F>(body: F)
246where
247    F: FnOnce(),
248{
249    let _ = std::panic::catch_unwind(AssertUnwindSafe(body));
250}
251
252/// What a caught panic actually said.
253///
254/// PUBLIC BECAUSE THE OTHER ELEVEN CRATES NEED IT. Each of them guards
255/// its own C entry points with `catch_unwind` and, having no way to
256/// reach this, reports the panic as `"panic in <function>"` -- the name
257/// of the function that was running, which the caller already knew, in
258/// place of the message, which is the only part it did not. An index
259/// out of bounds, a slice out of range, an `expect` with a sentence in
260/// it: all of it was thrown away at the boundary.
261///
262/// The guards themselves are NOT shareable, and that is why this is
263/// what moved rather than [`ffi_guard`]. Each crate's guard records the
264/// message into that crate's own thread-local, which is what its own C
265/// callers read; a guard from here would record into this crate's, and
266/// every panic message would land in a slot nobody reads.
267pub fn panic_message(panic: &Box<dyn std::any::Any + Send>) -> String {
268    if let Some(s) = panic.downcast_ref::<&'static str>() {
269        return (*s).to_string();
270    }
271    if let Some(s) = panic.downcast_ref::<String>() {
272        return s.clone();
273    }
274    "panic in FFI".to_string()
275}
276
277// ---------------------------------------------------------------------------
278// Device handle — opaque to C callers, shared across crates.
279// ---------------------------------------------------------------------------
280
281/// Opaque handle wrapping an `Arc<dyn BlockDevice>`. Allocated by sister
282/// crates' constructors and freed via [`fs_core_device_close`].
283pub struct FsCoreDevice {
284    inner: Arc<dyn BlockDevice>,
285}
286
287impl FsCoreDevice {
288    /// Internal constructor — sister crates use this to wrap their own
289    /// device types (Qcow2Reader, FileDevice, OwnedSlice, etc.) into the
290    /// shared handle type. Returns a `Box::into_raw` pointer ready to hand
291    /// across the FFI boundary.
292    pub fn into_handle(inner: Arc<dyn BlockDevice>) -> *mut FsCoreDevice {
293        Box::into_raw(Box::new(FsCoreDevice { inner }))
294    }
295
296    /// Borrow the inner device. `Arc::clone` it if you want shared
297    /// ownership — e.g. when handing the device to a slice adapter while
298    /// keeping the original handle alive.
299    pub fn inner(&self) -> &Arc<dyn BlockDevice> {
300        &self.inner
301    }
302}
303
304/// Free a device handle. Safe to call with NULL (no-op).
305///
306/// # THIS PRESERVES THE LAST ERROR MESSAGE
307///
308/// It returns `void`, so it can never report anything through the error
309/// slot — and it used to clear the slot anyway, because it went through
310/// [`ffi_guard_or`]. That destroyed the diagnostic in the ordinary C
311/// cleanup shape, where the close comes before the log:
312///
313/// ```c
314/// if (fs_core_device_read_at(h, off, buf, len) != FS_CORE_OK) goto fail;
315/// ...
316/// fail:
317///     fs_core_device_close(h);
318///     log("%s", fs_core_last_error_message());   /* was NULL */
319/// ```
320///
321/// A caller may now close before reading the message. Nothing on this
322/// path reads or writes the slot.
323#[unsafe(no_mangle)]
324pub unsafe extern "C" fn fs_core_device_close(handle: *mut FsCoreDevice) {
325    if handle.is_null() {
326        return;
327    }
328    ffi_guard_cleanup(|| unsafe {
329        drop(Box::from_raw(handle));
330    });
331}
332
333/// Total device size in bytes. Returns 0 if `handle` is NULL.
334#[unsafe(no_mangle)]
335pub unsafe extern "C" fn fs_core_device_size_bytes(handle: *const FsCoreDevice) -> u64 {
336    if handle.is_null() {
337        // 0 is also what an empty device reports, so the message is the
338        // only thing that separates the two -- and leaving the previous
339        // call's message here explained this answer with something that
340        // happened somewhere else.
341        set_last_error("fs_core_device_size_bytes: handle is null");
342        return 0;
343    }
344    ffi_guard_or(0, || unsafe { (*handle).inner.size_bytes() })
345}
346
347/// True if `write_at` is likely to succeed. Returns false on NULL.
348#[unsafe(no_mangle)]
349pub unsafe extern "C" fn fs_core_device_is_writable(handle: *const FsCoreDevice) -> bool {
350    if handle.is_null() {
351        // `false` is also what a perfectly good read-only device reports.
352        set_last_error("fs_core_device_is_writable: handle is null");
353        return false;
354    }
355    ffi_guard_or(false, || unsafe { (*handle).inner.is_writable() })
356}
357
358/// Read exactly `len` bytes from `offset` into `buf`. `buf` must be at
359/// least `len` bytes. Returns an `FsCoreErrorCode`.
360#[unsafe(no_mangle)]
361pub unsafe extern "C" fn fs_core_device_read_at(
362    handle: *const FsCoreDevice,
363    offset: u64,
364    buf: *mut u8,
365    len: usize,
366) -> FsCoreErrorCode {
367    // A null buffer is refused whatever the length. `from_raw_parts_mut`
368    // requires a non-null, aligned pointer even for a zero-length slice,
369    // so `(NULL, 0)` was undefined behaviour rather than the no-op it
370    // looks like -- in a crate that otherwise denies
371    // `unsafe_op_in_unsafe_fn`.
372    if handle.is_null() {
373        set_last_error("fs_core_device_read_at: handle is null");
374        return FsCoreErrorCode::NullArg;
375    }
376    if buf.is_null() {
377        set_last_error("fs_core_device_read_at: buf is null");
378        return FsCoreErrorCode::NullArg;
379    }
380    ffi_guard(|| {
381        let slice_buf = unsafe { slice::from_raw_parts_mut(buf, len) };
382        unsafe { (*handle).inner.read_at(offset, slice_buf) }
383    })
384}
385
386/// Write exactly `len` bytes from `buf` to `offset`. Returns `ReadOnly`
387/// for read-only devices.
388#[unsafe(no_mangle)]
389pub unsafe extern "C" fn fs_core_device_write_at(
390    handle: *const FsCoreDevice,
391    offset: u64,
392    buf: *const u8,
393    len: usize,
394) -> FsCoreErrorCode {
395    // Null is refused whatever the length; see `fs_core_device_read_at`.
396    if handle.is_null() {
397        set_last_error("fs_core_device_write_at: handle is null");
398        return FsCoreErrorCode::NullArg;
399    }
400    if buf.is_null() {
401        set_last_error("fs_core_device_write_at: buf is null");
402        return FsCoreErrorCode::NullArg;
403    }
404    ffi_guard(|| {
405        let slice_buf = unsafe { slice::from_raw_parts(buf, len) };
406        unsafe { (*handle).inner.write_at(offset, slice_buf) }
407    })
408}
409
410/// Flush pending writes to stable storage.
411#[unsafe(no_mangle)]
412pub unsafe extern "C" fn fs_core_device_flush(handle: *const FsCoreDevice) -> FsCoreErrorCode {
413    if handle.is_null() {
414        set_last_error("fs_core_device_flush: handle is null");
415        return FsCoreErrorCode::NullArg;
416    }
417    ffi_guard(|| unsafe { (*handle).inner.flush() })
418}
419
420// ---------------------------------------------------------------------------
421// Convenience: open a regular file as a device. Saves callers the trouble
422// of building a Rust crate just to wrap `FileDevice`.
423// ---------------------------------------------------------------------------
424
425/// Open `path` (NUL-terminated UTF-8) as a `FileDevice` and return a
426/// handle. Pass `writable=true` for RW. On failure returns NULL and the
427/// thread-local last-error has detail.
428#[cfg(any(unix, windows))]
429#[unsafe(no_mangle)]
430pub unsafe extern "C" fn fs_core_file_open(
431    path: *const c_char,
432    writable: bool,
433) -> *mut FsCoreDevice {
434    if path.is_null() {
435        set_last_error("path is null");
436        return ptr::null_mut();
437    }
438    ffi_guard_or(ptr::null_mut(), || {
439        let cstr = unsafe { std::ffi::CStr::from_ptr(path) };
440        let s = match cstr.to_str() {
441            Ok(s) => s,
442            Err(_) => {
443                set_last_error("path is not valid UTF-8");
444                return ptr::null_mut();
445            }
446        };
447        let dev = if writable {
448            crate::file_device::FileDevice::open_rw(s)
449        } else {
450            crate::file_device::FileDevice::open(s)
451        };
452        match dev {
453            Ok(d) => FsCoreDevice::into_handle(Arc::new(d)),
454            Err(e) => {
455                set_last_error(e.to_string());
456                ptr::null_mut()
457            }
458        }
459    })
460}
461
462// ---------------------------------------------------------------------------
463// Callback-backed device. Used when the caller already owns the underlying
464// resource (FSKit FSBlockDeviceResource, Go file handle, C-side fd) and
465// wants to expose it as an `FsCoreDevice` so it can be stacked under a
466// container reader (qcow2, vhd, ...) before reaching a filesystem driver.
467// ---------------------------------------------------------------------------
468
469/// Read callback. Returns 0 on success, non-zero (errno-like) on failure.
470/// Must fully fill `len` bytes — short reads are treated as I/O errors.
471pub type FsCoreReadCb =
472    Option<unsafe extern "C" fn(ctx: *mut c_void, offset: u64, buf: *mut u8, len: usize) -> c_int>;
473
474/// Write callback. NULL → device is read-only.
475pub type FsCoreWriteCb = Option<
476    unsafe extern "C" fn(ctx: *mut c_void, offset: u64, buf: *const u8, len: usize) -> c_int,
477>;
478
479/// Flush/fsync callback. NULL → flush is a no-op.
480pub type FsCoreFlushCb = Option<unsafe extern "C" fn(ctx: *mut c_void) -> c_int>;
481
482/// Configuration passed to [`fs_core_device_from_callbacks`].
483#[repr(C)]
484pub struct FsCoreCallbackCfg {
485    pub read: FsCoreReadCb,
486    pub write: FsCoreWriteCb,
487    pub flush: FsCoreFlushCb,
488    pub ctx: *mut c_void,
489    pub size: u64,
490}
491
492/// Turn a callback's non-zero return into an `io::Error`.
493fn cb_io_err(rc: c_int, op: &str) -> io::Error {
494    io::Error::other(format!("callback {op} returned {rc}"))
495}
496
497/// The host callback contract, in one place: **zero is success**.
498///
499/// All three adapters below wrapped a call in the same four lines —
500/// invoke, compare against zero, `Ok(())` or `cb_io_err`. Three copies
501/// of a convention is three chances to write `rc != 0` where the others
502/// write `rc == 0`, and a caller would see reads succeed while writes
503/// reported failure on the very same device.
504///
505/// `op` names the operation in the error, which is the only thing the
506/// three genuinely differ in.
507fn cb_result(rc: c_int, op: &'static str) -> io::Result<()> {
508    if rc == 0 {
509        Ok(())
510    } else {
511        Err(cb_io_err(rc, op))
512    }
513}
514
515/// Build an [`FsCoreDevice`] backed by host-provided callbacks. Returns NULL
516/// on failure (config null, read callback null, etc.) and stashes detail in
517/// the thread-local last-error.
518///
519/// `cfg.ctx` is opaque to fs-core; it is passed back verbatim to every
520/// callback invocation. The caller is responsible for ensuring it remains
521/// valid until [`fs_core_device_close`] is called on the returned handle.
522#[unsafe(no_mangle)]
523pub unsafe extern "C" fn fs_core_device_from_callbacks(
524    cfg: *const FsCoreCallbackCfg,
525) -> *mut FsCoreDevice {
526    if cfg.is_null() {
527        set_last_error("cfg is null");
528        return ptr::null_mut();
529    }
530    ffi_guard_or(ptr::null_mut(), || unsafe {
531        let cfg = &*cfg;
532        let read_fn = match cfg.read {
533            Some(f) => f,
534            None => {
535                set_last_error("cfg.read is null");
536                return ptr::null_mut();
537            }
538        };
539        let write_fn = cfg.write;
540        let flush_fn = cfg.flush;
541        // `*mut c_void` is `!Send + !Sync` by default, and `unsafe impl
542        // Send` on a newtype does not propagate cleanly through closure
543        // auto-traits. Round-tripping the pointer through `usize` gives
544        // something that is `Copy + Send + Sync`, and the callback
545        // contract already puts the host on the hook for using `ctx`
546        // safely across threads.
547        let ctx_addr = cfg.ctx as usize;
548        let size = cfg.size;
549
550        let read_cb: crate::callback_device::ReadCb = Box::new(move |off, buf| {
551            let ctx = ctx_addr as *mut c_void;
552            cb_result(read_fn(ctx, off, buf.as_mut_ptr(), buf.len()), "read")
553        });
554        let write_cb: Option<crate::callback_device::WriteCb> = write_fn.map(|f| {
555            Box::new(move |off, buf: &[u8]| {
556                let ctx = ctx_addr as *mut c_void;
557                cb_result(f(ctx, off, buf.as_ptr(), buf.len()), "write")
558            }) as crate::callback_device::WriteCb
559        });
560        let flush_cb: Option<crate::callback_device::FlushCb> = flush_fn.map(|f| {
561            Box::new(move || {
562                let ctx = ctx_addr as *mut c_void;
563                cb_result(f(ctx), "flush")
564            }) as crate::callback_device::FlushCb
565        });
566
567        let dev = CallbackDevice {
568            size,
569            read: read_cb,
570            write: write_cb,
571            flush: flush_cb,
572        };
573        FsCoreDevice::into_handle(Arc::new(dev))
574    })
575}
576
577// ---------------------------------------------------------------------------
578// Slice constructor. Returns a child `FsCoreDevice` whose byte 0 maps to
579// `start` of the parent and whose addressable range is `length` bytes.
580// Useful for partition-table walkers that want to hand one partition to
581// a filesystem driver without copying. The slice keeps an `Arc` to the
582// parent, so closing the parent before the slice is fine.
583// ---------------------------------------------------------------------------
584
585/// Read-only slice. Writes via the returned handle return
586/// `FS_CORE_READ_ONLY` regardless of the parent's writability.
587///
588/// `length` is clamped to what the parent can back, and a `start` at or
589/// past the parent's end returns NULL with a message — see
590/// [`crate::slice::window_on_parent`]. Read `fs_core_device_size_bytes`
591/// on the returned handle rather than assuming it is `length`.
592#[unsafe(no_mangle)]
593pub unsafe extern "C" fn fs_core_device_slice_ro(
594    parent: *const FsCoreDevice,
595    start: u64,
596    length: u64,
597) -> *mut FsCoreDevice {
598    if parent.is_null() {
599        set_last_error("parent is null");
600        return ptr::null_mut();
601    }
602    ffi_guard_or(ptr::null_mut(), || unsafe {
603        let parent_arc = (*parent).inner().clone();
604        let Some(length) = slice_window(parent_arc.size_bytes(), start, length, "slice_ro") else {
605            return ptr::null_mut();
606        };
607        // OwnedSlice takes Arc<dyn BlockRead>; trait upcast from
608        // BlockDevice -> BlockRead is supported in the pinned toolchain.
609        let parent_read: Arc<dyn crate::block::BlockRead> = parent_arc;
610        let slice = crate::slice::OwnedSlice::new(parent_read, start, length);
611        FsCoreDevice::into_handle(Arc::new(slice))
612    })
613}
614
615/// Read-write slice. Writes are forwarded to the parent at `start +
616/// offset`; writes outside `[0, length)` return `FS_CORE_OUT_OF_BOUNDS`.
617/// If the parent reports `is_writable() == false`, write attempts return
618/// `FS_CORE_READ_ONLY`.
619///
620/// `length` is clamped to what the parent can back, and a `start` at or
621/// past the parent's end returns NULL with a message — see
622/// [`crate::slice::window_on_parent`]. Read `fs_core_device_size_bytes`
623/// on the returned handle rather than assuming it is `length`.
624#[unsafe(no_mangle)]
625pub unsafe extern "C" fn fs_core_device_slice_rw(
626    parent: *const FsCoreDevice,
627    start: u64,
628    length: u64,
629) -> *mut FsCoreDevice {
630    if parent.is_null() {
631        set_last_error("parent is null");
632        return ptr::null_mut();
633    }
634    ffi_guard_or(ptr::null_mut(), || unsafe {
635        let parent_arc = (*parent).inner().clone();
636        let Some(length) = slice_window(parent_arc.size_bytes(), start, length, "slice_rw") else {
637            return ptr::null_mut();
638        };
639        let slice = crate::slice::OwnedRwSlice::new(parent_arc, start, length);
640        FsCoreDevice::into_handle(Arc::new(slice))
641    })
642}
643
644/// The slice window both C constructors take, or `None` with the error
645/// slot already set.
646///
647/// The clamp itself lives in [`crate::slice::window_on_parent`] and is
648/// applied again inside the slice constructors, so calling it here is
649/// not the check — it is how the C ABI learns that there was nothing to
650/// slice, which is the one outcome a `*mut` return can express and an
651/// infallible Rust constructor cannot. A zero-byte handle would be
652/// technically honest and useless to debug: the mount that follows fails
653/// on its superblock read with no hint that the window was the problem.
654fn slice_window(parent_size: u64, start: u64, length: u64, what: &str) -> Option<u64> {
655    match crate::slice::window_on_parent(parent_size, start, length) {
656        Some(clamped) => Some(clamped),
657        None => {
658            set_last_error(format!(
659                "fs_core_device_{what}: start {start} is at or past the end of the \
660                 parent device ({parent_size} bytes), so there is nothing to slice"
661            ));
662            None
663        }
664    }
665}
666
667// ---------------------------------------------------------------------------
668// Tests — exercise the FFI surface from Rust. The C side is verified by
669// the consumer crates that use these functions through their own headers.
670// ---------------------------------------------------------------------------
671
672#[cfg(test)]
673mod tests {
674    /// THE MESSAGE, not the fact that something panicked.
675    ///
676    /// Both shapes a panic payload takes: `panic!("literal")` gives a
677    /// `&'static str`, and `panic!("{x}")` or an out-of-bounds index
678    /// gives a `String`. A guard that reports neither tells its caller
679    /// only what it already knew.
680    #[test]
681    fn a_caught_panic_reports_what_it_said() {
682        let literal =
683            std::panic::catch_unwind(|| panic!("a literal message")).expect_err("it panicked");
684        assert_eq!(panic_message(&literal), "a literal message");
685
686        let owned = std::panic::catch_unwind(|| {
687            let v: Vec<u8> = Vec::new();
688            let _ = v[3];
689        })
690        .expect_err("it panicked");
691        assert!(
692            panic_message(&owned).contains("index out of bounds"),
693            "the index panic's own words should survive: {}",
694            panic_message(&owned)
695        );
696
697        // Anything else says so rather than pretending to a message.
698        let odd =
699            std::panic::catch_unwind(|| std::panic::panic_any(42u8)).expect_err("it panicked");
700        assert_eq!(panic_message(&odd), "panic in FFI");
701    }
702
703    use super::*;
704    use std::fs::File;
705    use std::io::Write;
706
707    fn tmp_image(bytes: &[u8]) -> String {
708        use std::sync::atomic::{AtomicU32, Ordering};
709        static C: AtomicU32 = AtomicU32::new(0);
710        let n = C.fetch_add(1, Ordering::Relaxed);
711        let p = std::env::temp_dir()
712            .join(format!("fs_core_ffi_{}_{n}.img", std::process::id()))
713            .to_string_lossy()
714            .into_owned();
715        File::create(&p).unwrap().write_all(bytes).unwrap();
716        p
717    }
718
719    #[test]
720    fn open_read_close_round_trip() {
721        let path = tmp_image(b"hello, fs-core ffi");
722        let cpath = CString::new(path.as_str()).unwrap();
723        let h = unsafe { fs_core_file_open(cpath.as_ptr(), false) };
724        assert!(!h.is_null(), "open failed");
725
726        unsafe {
727            assert_eq!(fs_core_device_size_bytes(h), 18);
728            assert!(!fs_core_device_is_writable(h));
729
730            let mut buf = [0u8; 5];
731            let rc = fs_core_device_read_at(h, 0, buf.as_mut_ptr(), buf.len());
732            assert_eq!(rc, FsCoreErrorCode::Ok);
733            assert_eq!(&buf, b"hello");
734
735            // Write should fail with ReadOnly.
736            let rc = fs_core_device_write_at(h, 0, b"x".as_ptr(), 1);
737            assert_eq!(rc, FsCoreErrorCode::ReadOnly);
738
739            fs_core_device_close(h);
740        }
741        let _ = std::fs::remove_file(&path);
742    }
743
744    #[test]
745    fn null_args_return_null_arg() {
746        let mut buf = [0u8; 4];
747        let rc = unsafe { fs_core_device_read_at(ptr::null(), 0, buf.as_mut_ptr(), buf.len()) };
748        assert_eq!(rc, FsCoreErrorCode::NullArg);
749        let rc = unsafe { fs_core_device_flush(ptr::null()) };
750        assert_eq!(rc, FsCoreErrorCode::NullArg);
751    }
752
753    #[test]
754    fn last_error_populated_on_open_failure() {
755        let cpath = CString::new("/path/that/does/not/exist/we/hope").unwrap();
756        let h = unsafe { fs_core_file_open(cpath.as_ptr(), false) };
757        assert!(h.is_null());
758        let msg = fs_core_last_error_message();
759        assert!(!msg.is_null());
760        let s = unsafe { std::ffi::CStr::from_ptr(msg).to_string_lossy().into_owned() };
761        assert!(!s.is_empty(), "expected an error message");
762    }
763
764    // ---- callback-backed device tests --------------------------------
765
766    use std::sync::{Arc as StdArc, Mutex as StdMutex};
767
768    struct CbState {
769        data: Vec<u8>,
770        flushed: u32,
771    }
772
773    /// Trampoline that pulls a `*mut CbState` out of the opaque ctx.
774    unsafe extern "C" fn t_read(ctx: *mut c_void, offset: u64, buf: *mut u8, len: usize) -> c_int {
775        let st = unsafe { &mut *(ctx as *mut CbState) };
776        // `off + len` was computed BEFORE the bounds check that exists
777        // to refuse a past-end range, so a wild offset panicked here
778        // instead of returning 5 -- the same pair of lines, and the
779        // same defect, as the doubles in `test_device`.
780        let Ok((off, _)) = crate::test_device::range_within(st.data.len(), offset, len) else {
781            return 5; // out of bounds
782        };
783        unsafe {
784            std::ptr::copy_nonoverlapping(st.data.as_ptr().add(off), buf, len);
785        }
786        0
787    }
788    unsafe extern "C" fn t_write(
789        ctx: *mut c_void,
790        offset: u64,
791        buf: *const u8,
792        len: usize,
793    ) -> c_int {
794        let st = unsafe { &mut *(ctx as *mut CbState) };
795        let Ok((off, _)) = crate::test_device::range_within(st.data.len(), offset, len) else {
796            return 5;
797        };
798        unsafe {
799            std::ptr::copy_nonoverlapping(buf, st.data.as_mut_ptr().add(off), len);
800        }
801        0
802    }
803    unsafe extern "C" fn t_flush(ctx: *mut c_void) -> c_int {
804        let st = unsafe { &mut *(ctx as *mut CbState) };
805        st.flushed += 1;
806        0
807    }
808
809    #[test]
810    fn callback_device_round_trip_rw() {
811        let mut st = Box::new(CbState {
812            data: vec![0u8; 32],
813            flushed: 0,
814        });
815        for (i, b) in st.data.iter_mut().enumerate() {
816            *b = i as u8;
817        }
818        let ctx = &mut *st as *mut CbState as *mut c_void;
819
820        let cfg = FsCoreCallbackCfg {
821            read: Some(t_read),
822            write: Some(t_write),
823            flush: Some(t_flush),
824            ctx,
825            size: 32,
826        };
827        let h = unsafe { fs_core_device_from_callbacks(&cfg) };
828        assert!(!h.is_null(), "device_from_callbacks returned NULL");
829
830        unsafe {
831            assert_eq!(fs_core_device_size_bytes(h), 32);
832            assert!(fs_core_device_is_writable(h));
833
834            let mut buf = [0u8; 4];
835            let rc = fs_core_device_read_at(h, 4, buf.as_mut_ptr(), buf.len());
836            assert_eq!(rc, FsCoreErrorCode::Ok);
837            assert_eq!(buf, [4, 5, 6, 7]);
838
839            let payload = [0xDE, 0xAD, 0xBE, 0xEF];
840            let rc = fs_core_device_write_at(h, 8, payload.as_ptr(), payload.len());
841            assert_eq!(rc, FsCoreErrorCode::Ok);
842
843            let rc = fs_core_device_flush(h);
844            assert_eq!(rc, FsCoreErrorCode::Ok);
845
846            let mut readback = [0u8; 4];
847            let rc = fs_core_device_read_at(h, 8, readback.as_mut_ptr(), readback.len());
848            assert_eq!(rc, FsCoreErrorCode::Ok);
849            assert_eq!(readback, payload);
850
851            fs_core_device_close(h);
852        }
853        assert_eq!(st.flushed, 1);
854        assert_eq!(&st.data[8..12], &[0xDE, 0xAD, 0xBE, 0xEF]);
855    }
856
857    #[test]
858    fn callback_device_readonly_when_write_null() {
859        let mut st = Box::new(CbState {
860            data: vec![0xAAu8; 16],
861            flushed: 0,
862        });
863        let ctx = &mut *st as *mut CbState as *mut c_void;
864        let cfg = FsCoreCallbackCfg {
865            read: Some(t_read),
866            write: None,
867            flush: None,
868            ctx,
869            size: 16,
870        };
871        let h = unsafe { fs_core_device_from_callbacks(&cfg) };
872        assert!(!h.is_null());
873        unsafe {
874            assert!(!fs_core_device_is_writable(h));
875            let rc = fs_core_device_write_at(h, 0, [1u8].as_ptr(), 1);
876            assert_eq!(rc, FsCoreErrorCode::ReadOnly);
877            // Flush is a no-op when callback is NULL.
878            assert_eq!(fs_core_device_flush(h), FsCoreErrorCode::Ok);
879            fs_core_device_close(h);
880        }
881        // suppress unused warning
882        let _ = StdArc::new(StdMutex::new(0u8));
883    }
884
885    /// The last error as text, or `None`. Read directly rather than
886    /// through `panic_message_tests::last_error`, which is a different
887    /// module.
888    fn cb_last_error() -> Option<String> {
889        let p = fs_core_last_error_message();
890        if p.is_null() {
891            return None;
892        }
893        Some(
894            unsafe { std::ffi::CStr::from_ptr(p) }
895                .to_string_lossy()
896                .into_owned(),
897        )
898    }
899
900    /// THE THIRD COPY OF THE BOUNDS RULE, AND THE ONE NOTHING HELD.
901    ///
902    /// `tests/common/mod.rs` states the standard this crate works to:
903    /// the rule is written twice, so both copies carry a test that pins
904    /// it. #84 fixed a THIRD copy -- these two trampolines -- and gave
905    /// it none. Reverting both to the pre-fix arithmetic left
906    /// `cargo test --locked --lib` at 92 passed, 0 failed, and `--lib`
907    /// is the complete check: they are `#[cfg(test)]` items in the lib
908    /// target, so no integration test can reach them.
909    ///
910    /// # What the reverted arithmetic actually does, measured
911    ///
912    /// Not what I first wrote here. `ffi_guard` wraps the call in
913    /// `catch_unwind`, so the obvious expectation is that an overflow
914    /// panic returns `FsCoreErrorCode::Panic`. It does not: a
915    /// trampoline is `extern "C"`, panicking out of one is
916    /// non-unwinding, and the process aborts before any code is
917    /// returned. With `t_read` reverted, `cargo test --locked --lib`
918    /// gives
919    ///
920    /// ```text
921    /// thread caused non-unwinding panic. aborting.
922    /// process didn't exit successfully: ... (signal: 6, SIGABRT)
923    /// EXIT=101, and NO `... FAILED` line for any test
924    /// ```
925    ///
926    /// So the control produces a crash rather than a failure, which is
927    /// why the exit status is the thing to read: counting `test
928    /// result:` lines cannot see an aborted binary.
929    ///
930    /// # Why the assertion is still about the MESSAGE
931    ///
932    /// The abort makes the revert impossible to miss, but it is not
933    /// what these assertions are for. `ffi_guard` turns any refusal
934    /// into a non-`Ok` code, so "not Ok" alone would also be satisfied
935    /// by the wrapper refusing before the trampoline was ever called --
936    /// a test that passes without exercising the copy it exists to
937    /// pin. `callback read returned 5` is the trampoline's own
938    /// out-of-bounds path and nothing else produces it. `Panic` is
939    /// excluded too, for the case where a future edit makes the
940    /// unwinding reachable.
941    #[test]
942    fn a_callback_read_at_a_wild_offset_is_refused_rather_than_panicking() {
943        let mut st = Box::new(CbState {
944            data: vec![0x5Au8; 32],
945            flushed: 0,
946        });
947        let ctx = &mut *st as *mut CbState as *mut c_void;
948        let cfg = FsCoreCallbackCfg {
949            read: Some(t_read),
950            write: Some(t_write),
951            flush: Some(t_flush),
952            ctx,
953            size: 32,
954        };
955        let h = unsafe { fs_core_device_from_callbacks(&cfg) };
956        assert!(!h.is_null());
957
958        // Three shapes, and the first two are the ones the arithmetic
959        // used to get wrong: an offset that is itself past every
960        // addressable byte, and an offset whose sum with the length
961        // wraps. The third is the ordinary past-end read, which the
962        // pre-fix code also handled -- it is here so a guard that
963        // refused everything would not look like a pass.
964        for (what, offset, want) in [
965            ("the very top of the address space", u64::MAX, 8usize),
966            ("an offset whose sum with len wraps", u64::MAX - 2, 8usize),
967            ("an ordinary past-end read", 64u64, 8usize),
968        ] {
969            let mut buf = [0u8; 8];
970            let rc = unsafe { fs_core_device_read_at(h, offset, buf.as_mut_ptr(), want) };
971            assert_ne!(
972                rc,
973                FsCoreErrorCode::Panic,
974                "{what}: the trampoline panicked instead of refusing; \
975                 last error was {:?}",
976                cb_last_error()
977            );
978            assert_ne!(rc, FsCoreErrorCode::Ok, "{what}: must not succeed");
979            let msg = cb_last_error().unwrap_or_default();
980            assert!(
981                msg.contains("callback read returned 5"),
982                "{what}: the refusal must come from the trampoline's own \
983                 out-of-bounds path, not from a panic caught by ffi_guard. \
984                 last error was {msg:?}"
985            );
986        }
987        unsafe { fs_core_device_close(h) };
988    }
989
990    /// The write half. `t_write` is the copy most easily forgotten, and
991    /// the one that would corrupt rather than merely panic.
992    #[test]
993    fn a_callback_write_at_a_wild_offset_is_refused_rather_than_panicking() {
994        let mut st = Box::new(CbState {
995            data: vec![0x5Au8; 32],
996            flushed: 0,
997        });
998        let ctx = &mut *st as *mut CbState as *mut c_void;
999        let cfg = FsCoreCallbackCfg {
1000            read: Some(t_read),
1001            write: Some(t_write),
1002            flush: Some(t_flush),
1003            ctx,
1004            size: 32,
1005        };
1006        let h = unsafe { fs_core_device_from_callbacks(&cfg) };
1007        assert!(!h.is_null());
1008
1009        let payload = [0xEEu8; 8];
1010        for (what, offset) in [
1011            ("the very top of the address space", u64::MAX),
1012            ("an offset whose sum with len wraps", u64::MAX - 2),
1013            ("an ordinary past-end write", 64u64),
1014        ] {
1015            let rc = unsafe { fs_core_device_write_at(h, offset, payload.as_ptr(), payload.len()) };
1016            assert_ne!(
1017                rc,
1018                FsCoreErrorCode::Panic,
1019                "{what}: the trampoline panicked instead of refusing; \
1020                 last error was {:?}",
1021                cb_last_error()
1022            );
1023            assert_ne!(rc, FsCoreErrorCode::Ok, "{what}: must not succeed");
1024            let msg = cb_last_error().unwrap_or_default();
1025            assert!(
1026                msg.contains("callback write returned 5"),
1027                "{what}: the refusal must come from the trampoline's own \
1028                 out-of-bounds path, not from a panic caught by ffi_guard. \
1029                 last error was {msg:?}"
1030            );
1031        }
1032        unsafe { fs_core_device_close(h) };
1033        // Nothing was written anywhere: a refused write must not have
1034        // narrowed a wild offset into a plausible one on the way out.
1035        assert!(
1036            st.data.iter().all(|b| *b == 0x5A),
1037            "a refused write modified the backing buffer"
1038        );
1039    }
1040
1041    #[test]
1042    fn callback_device_null_cfg_returns_null() {
1043        let h = unsafe { fs_core_device_from_callbacks(ptr::null()) };
1044        assert!(h.is_null());
1045        let msg = fs_core_last_error_message();
1046        assert!(!msg.is_null());
1047    }
1048}
1049
1050#[cfg(test)]
1051mod panic_message_tests {
1052    use super::*;
1053    use crate::block::{BlockDevice, BlockRead};
1054
1055    /// A device whose every method panics.
1056    ///
1057    /// Not a hypothetical: a driver's `size_bytes` computes a geometry
1058    /// from on-disk fields, and an arithmetic overflow there panics.
1059    /// The FFI boundary is where that has to stop being a panic and
1060    /// start being a reportable error.
1061    struct Panicking;
1062
1063    impl BlockRead for Panicking {
1064        fn read_at(&self, _offset: u64, _buf: &mut [u8]) -> Result<(), Error> {
1065            panic!("read_at exploded")
1066        }
1067        fn size_bytes(&self) -> u64 {
1068            panic!("size_bytes exploded")
1069        }
1070    }
1071    impl BlockDevice for Panicking {
1072        fn is_writable(&self) -> bool {
1073            panic!("is_writable exploded")
1074        }
1075    }
1076
1077    fn handle() -> *mut FsCoreDevice {
1078        FsCoreDevice::into_handle(std::sync::Arc::new(Panicking))
1079    }
1080
1081    fn last_error() -> Option<String> {
1082        let p = fs_core_last_error_message();
1083        if p.is_null() {
1084            return None;
1085        }
1086        Some(
1087            unsafe { std::ffi::CStr::from_ptr(p) }
1088                .to_string_lossy()
1089                .into_owned(),
1090        )
1091    }
1092
1093    /// A panic caught at the boundary must leave a message behind.
1094    ///
1095    /// `fs_core_device_size_bytes` returns 0 on panic — and 0 is also
1096    /// what a legitimately empty device returns. Without a message the
1097    /// caller cannot tell "this device is empty" from "the driver
1098    /// exploded computing its size", which is the whole reason the
1099    /// thread-local error slot exists.
1100    #[test]
1101    fn a_panic_computing_the_size_is_reported_not_just_swallowed() {
1102        clear_last_error();
1103        let h = handle();
1104        let size = unsafe { fs_core_device_size_bytes(h) };
1105        assert_eq!(size, 0, "the fallback value is still returned");
1106        let msg = last_error().expect("a caught panic must leave a message");
1107        assert!(
1108            msg.contains("size_bytes exploded"),
1109            "the message should carry the panic's own text, got: {msg}"
1110        );
1111        unsafe { fs_core_device_close(h) };
1112    }
1113
1114    /// Same for the writability probe, whose fallback is `false` — the
1115    /// answer a perfectly good read-only device gives.
1116    #[test]
1117    fn a_panic_probing_writability_is_reported() {
1118        clear_last_error();
1119        let h = handle();
1120        let writable = unsafe { fs_core_device_is_writable(h) };
1121        assert!(!writable, "the fallback value is still returned");
1122        assert!(
1123            last_error().is_some(),
1124            "a caught panic must leave a message"
1125        );
1126        unsafe { fs_core_device_close(h) };
1127    }
1128
1129    /// A call that succeeds must not leave a stale message behind for
1130    /// the next one to pick up.
1131    #[test]
1132    fn a_successful_call_clears_the_previous_error() {
1133        let h = handle();
1134        let _ = unsafe { fs_core_device_size_bytes(h) };
1135        assert!(last_error().is_some(), "setup: an error is recorded");
1136        unsafe { fs_core_device_close(h) };
1137
1138        struct Sixteen;
1139        impl BlockRead for Sixteen {
1140            fn read_at(&self, _offset: u64, _buf: &mut [u8]) -> Result<(), Error> {
1141                Ok(())
1142            }
1143            fn size_bytes(&self) -> u64 {
1144                16
1145            }
1146        }
1147        impl BlockDevice for Sixteen {}
1148        let h2 = FsCoreDevice::into_handle(std::sync::Arc::new(Sixteen));
1149        assert_eq!(unsafe { fs_core_device_size_bytes(h2) }, 16);
1150        assert!(
1151            last_error().is_none(),
1152            "a call that worked must not leave the previous panic's message in place"
1153        );
1154        unsafe { fs_core_device_close(h2) };
1155    }
1156
1157    // ---------------------------------------------------------------------
1158    // A null argument explains itself, rather than inheriting whatever the
1159    // previous call left behind.
1160    //
1161    // Every null check returned ABOVE the guard, and the guard is the only
1162    // thing that touches the error slot -- so a null-argument call left the
1163    // previous call's message readable and a caller attributed something
1164    // that happened elsewhere to this call.
1165    //
1166    // Worst for `size_bytes` and `is_writable`, whose fallbacks are both
1167    // legitimate answers: the caller got `0` or `false` AND a confident
1168    // explanation of it belonging to a different operation. That is not
1169    // "the evidence was thrown away", which this file already argues
1170    // against -- it is evidence about something else, substituted.
1171    //
1172    // The slot is seeded with `set_last_error` rather than by provoking a
1173    // real failure. That is exactly what a failed call does to it --
1174    // `ffi_guard` sets it the same way -- and it makes "not the earlier
1175    // message" an exact comparison rather than a fuzzy one.
1176    // ---------------------------------------------------------------------
1177
1178    /// Distinctive enough that finding it in a message is unambiguous.
1179    const SEEDED: &str = "SEEDED-earlier-failure-belonging-to-another-call";
1180
1181    /// Assert the slot names a null argument and has lost the seed.
1182    fn assert_named_null(msg: Option<String>, expect: &str) {
1183        let msg = msg.expect("a null argument must leave a message of its own");
1184        assert!(
1185            msg.contains(expect),
1186            "the message must name the null argument ({expect}), got: {msg}"
1187        );
1188        assert!(
1189            !msg.contains(SEEDED),
1190            "the previous call's message must not survive to explain this one, got: {msg}"
1191        );
1192    }
1193
1194    #[test]
1195    fn a_null_handle_to_size_bytes_names_the_argument() {
1196        set_last_error(SEEDED);
1197        let size = unsafe { fs_core_device_size_bytes(std::ptr::null()) };
1198        assert_eq!(size, 0, "the fallback value is still returned");
1199        assert_named_null(last_error(), "fs_core_device_size_bytes: handle is null");
1200    }
1201
1202    #[test]
1203    fn a_null_handle_to_is_writable_names_the_argument() {
1204        set_last_error(SEEDED);
1205        let writable = unsafe { fs_core_device_is_writable(std::ptr::null()) };
1206        assert!(!writable, "the fallback value is still returned");
1207        assert_named_null(last_error(), "fs_core_device_is_writable: handle is null");
1208    }
1209
1210    #[test]
1211    fn a_null_handle_to_flush_names_the_argument() {
1212        set_last_error(SEEDED);
1213        let rc = unsafe { fs_core_device_flush(std::ptr::null()) };
1214        assert_eq!(rc, FsCoreErrorCode::NullArg, "the code is still NullArg");
1215        assert_named_null(last_error(), "fs_core_device_flush: handle is null");
1216    }
1217
1218    /// `read_at` has two null arguments, and the message says which.
1219    ///
1220    /// A code of `NullArg` is honest but says nothing about *what* was
1221    /// null, and `fs_core.h` promises a human-readable message for every
1222    /// fallible call. Two arms, so neither can pass on the other's back.
1223    #[test]
1224    fn a_null_argument_to_read_at_names_which_one() {
1225        let mut buf = [0u8; 8];
1226
1227        set_last_error(SEEDED);
1228        let rc = unsafe { fs_core_device_read_at(std::ptr::null(), 0, buf.as_mut_ptr(), 8) };
1229        assert_eq!(rc, FsCoreErrorCode::NullArg);
1230        assert_named_null(last_error(), "fs_core_device_read_at: handle is null");
1231
1232        // A real handle, a null buffer: the other arm.
1233        set_last_error(SEEDED);
1234        let h = handle();
1235        let rc = unsafe { fs_core_device_read_at(h, 0, std::ptr::null_mut(), 8) };
1236        assert_eq!(rc, FsCoreErrorCode::NullArg);
1237        assert_named_null(last_error(), "fs_core_device_read_at: buf is null");
1238        unsafe { fs_core_device_close(h) };
1239    }
1240
1241    /// Same for `write_at`.
1242    #[test]
1243    fn a_null_argument_to_write_at_names_which_one() {
1244        let buf = [0u8; 8];
1245
1246        set_last_error(SEEDED);
1247        let rc = unsafe { fs_core_device_write_at(std::ptr::null(), 0, buf.as_ptr(), 8) };
1248        assert_eq!(rc, FsCoreErrorCode::NullArg);
1249        assert_named_null(last_error(), "fs_core_device_write_at: handle is null");
1250
1251        set_last_error(SEEDED);
1252        let h = handle();
1253        let rc = unsafe { fs_core_device_write_at(h, 0, std::ptr::null(), 8) };
1254        assert_eq!(rc, FsCoreErrorCode::NullArg);
1255        assert_named_null(last_error(), "fs_core_device_write_at: buf is null");
1256        unsafe { fs_core_device_close(h) };
1257    }
1258
1259    /// CLOSE MUST NOT DESTROY THE MESSAGE A CALLER IS ABOUT TO READ.
1260    ///
1261    /// `close` returns `void`, so it can never fill the error slot — and it
1262    /// used to clear it anyway, by going through the guard that owns the
1263    /// slot for calls that *can* report. That breaks the ordinary C cleanup
1264    /// shape, where the free comes before the log:
1265    ///
1266    /// ```c
1267    /// fail:
1268    ///     fs_core_device_close(h);
1269    ///     log("%s", fs_core_last_error_message());   /* was NULL */
1270    /// ```
1271    #[test]
1272    fn close_preserves_the_message_a_caller_is_about_to_read() {
1273        set_last_error(SEEDED);
1274        let h = handle();
1275        unsafe { fs_core_device_close(h) };
1276        let msg = last_error().expect("close must not destroy the last error");
1277        assert!(
1278            msg.contains(SEEDED),
1279            "the diagnostic a caller closes before reading must survive, got: {msg}"
1280        );
1281    }
1282
1283    /// The control for the one above: closing NULL is a no-op and always
1284    /// preserved the slot, because it returns before any guard. It passes
1285    /// before and after the fix, and it is here so that
1286    /// `close_preserves_...` failing points at the guard rather than at
1287    /// something about handles.
1288    #[test]
1289    fn closing_null_also_preserves_the_message() {
1290        set_last_error(SEEDED);
1291        unsafe { fs_core_device_close(std::ptr::null_mut()) };
1292        let msg = last_error().expect("a no-op must not clear the slot");
1293        assert!(msg.contains(SEEDED));
1294    }
1295}
1296
1297// ---------------------------------------------------------------------------
1298// The published error numbering, pinned on both sides.
1299//
1300// `FsCoreErrorCode` and the `FsCoreErrorCode` enum in
1301// `include/fs_core.h` are two hand-written copies of one ABI, of which
1302// the header says "Stable: do not renumber." Nothing compiles the
1303// header, and every other test in this file compares codes symbolically
1304// — `assert_eq!(rc, FsCoreErrorCode::ShortRead)` — which is invariant
1305// under precisely the change that breaks the ABI: a variant inserted
1306// into the middle of one copy, or added to one copy and not the other.
1307//
1308// Two checks, because neither sees what the other does. Comparing the
1309// two files as text catches a variant that reached only one of them.
1310// Pinning the discriminants against literals catches a renumbering
1311// applied tidily to both, which is the change the header forbids and
1312// the one the text comparison would call agreement.
1313// ---------------------------------------------------------------------------
1314
1315#[cfg(test)]
1316mod error_code_abi_tests {
1317    use super::FsCoreErrorCode;
1318
1319    /// The C spelling of a Rust variant name: `Ok` is `FS_CORE_OK`,
1320    /// `OutOfBounds` is `FS_CORE_OUT_OF_BOUNDS`.
1321    fn c_name(rust_name: &str) -> String {
1322        let mut out = String::from("FS_CORE_");
1323        for (i, ch) in rust_name.chars().enumerate() {
1324            if i != 0 && ch.is_ascii_uppercase() {
1325                out.push('_');
1326            }
1327            out.extend(ch.to_uppercase());
1328        }
1329        out
1330    }
1331
1332    /// The body of an enum block: everything between an opening marker
1333    /// that must occur exactly once — so a second enum added to either
1334    /// file cannot quietly redirect the parse — and the first brace at
1335    /// column 0 after it, which must be followed by `closes_with`.
1336    fn enum_body<'a>(src: &'a str, opens_with: &str, closes_with: &str) -> &'a str {
1337        assert_eq!(
1338            src.matches(opens_with).count(),
1339            1,
1340            "{opens_with:?} must appear exactly once or this parses the wrong enum"
1341        );
1342        let start = src.find(opens_with).unwrap() + opens_with.len();
1343        let end = start
1344            + src[start..]
1345                .find("\n}")
1346                .expect("the enum block is closed by a brace at column 0");
1347        let after = &src[end + 2..];
1348        assert!(
1349            after.starts_with(closes_with),
1350            "the closing brace should be followed by {closes_with:?}, not {:?} — \
1351             the parse stopped somewhere other than the end of the enum",
1352            &after[..closes_with.len().min(after.len())]
1353        );
1354        &src[start..end]
1355    }
1356
1357    /// The error codes as `src/ffi.rs` declares them, in declaration
1358    /// order, spelled the way C spells them.
1359    ///
1360    /// Reads this very file rather than listing the variants, so a
1361    /// variant added to the enum cannot be absent from what is compared
1362    /// against the header — that absence being the drift under guard,
1363    /// and a hand-maintained list here would share it.
1364    ///
1365    /// It refuses to guess. A line in the enum body that is neither
1366    /// blank, a comment, an attribute nor `Name = <integer>,` fails the
1367    /// test, because a parser that silently matches nothing agrees with
1368    /// every header.
1369    fn codes_declared_in_rust() -> Vec<(String, i32)> {
1370        // NORMALISED FIRST. A Windows checkout has CRLF, so every marker
1371        // below would carry a `\r` the source does not, and the
1372        // uniqueness assertion fails rather than parsing the wrong enum
1373        // -- which is the guard working, but it fails a correct file.
1374        // The caller owns the normalised copy because `enum_body`
1375        // borrows from it.
1376        let src = include_str!("ffi.rs").replace("\r\n", "\n");
1377        let body = enum_body(
1378            &src,
1379            "pub enum FsCoreErrorCode {\n",
1380            "\n\nimpl FsCoreErrorCode {",
1381        );
1382        assert!(
1383            !body.contains('{'),
1384            "the extracted Rust enum body should hold no nested braces: {body:?}"
1385        );
1386        body.lines()
1387            .map(str::trim)
1388            .filter(|l| !(l.is_empty() || l.starts_with("//") || l.starts_with("#[")))
1389            .map(|l| {
1390                let (name, value) = l
1391                    .strip_suffix(',')
1392                    .and_then(|entry| entry.split_once('='))
1393                    .unwrap_or_else(|| panic!("unparsed line in the Rust enum body: {l:?}"));
1394                let value = value.trim().parse().unwrap_or_else(|e| {
1395                    panic!("{name:?} has no literal discriminant ({e}): {l:?}")
1396                });
1397                (c_name(name.trim()), value)
1398            })
1399            .collect()
1400    }
1401
1402    /// The same list as `include/fs_core.h` declares it, with the same
1403    /// refusal to guess: an entry it cannot read fails the test.
1404    fn codes_declared_in_c() -> Vec<(String, i32)> {
1405        let src = include_str!("../include/fs_core.h").replace("\r\n", "\n");
1406        let body = enum_body(&src, "typedef enum {\n", " FsCoreErrorCode;");
1407
1408        // A C comment spans lines and sits between entries, so it goes
1409        // before the split on commas rather than after it.
1410        let mut stripped = String::new();
1411        let mut rest = body;
1412        while let Some(open) = rest.find("/*") {
1413            stripped.push_str(&rest[..open]);
1414            let tail = &rest[open + 2..];
1415            let close = tail
1416                .find("*/")
1417                .expect("an unterminated comment in the header's enum");
1418            rest = &tail[close + 2..];
1419        }
1420        stripped.push_str(rest);
1421
1422        stripped
1423            .split(',')
1424            .map(str::trim)
1425            .filter(|entry| !entry.is_empty())
1426            .map(|entry| {
1427                let (name, value) = entry
1428                    .split_once('=')
1429                    .unwrap_or_else(|| panic!("unparsed entry in the C enum body: {entry:?}"));
1430                let value = value
1431                    .trim()
1432                    .parse()
1433                    .unwrap_or_else(|e| panic!("{name:?} has no literal value ({e}): {entry:?}"));
1434                (name.trim().to_owned(), value)
1435            })
1436            .collect()
1437    }
1438
1439    /// Name for name and number for number, in the same order.
1440    ///
1441    /// This is the check a ninth code added to one file and not the
1442    /// other fails. The crate has no other defence against it: the
1443    /// build succeeds and every symbolic comparison still passes.
1444    #[test]
1445    fn the_header_and_the_rust_enum_publish_the_same_error_codes() {
1446        let rust = codes_declared_in_rust();
1447        let c = codes_declared_in_c();
1448
1449        // Nine codes are published and a published code is never
1450        // withdrawn, so a parse returning fewer read less than the
1451        // enum — whatever it then agreed with.
1452        assert!(
1453            rust.len() >= 9 && c.len() >= 9,
1454            "both parses should reach every published code, got {} from Rust and {} from C",
1455            rust.len(),
1456            c.len()
1457        );
1458        assert_eq!(rust, c, "the two copies of the error ABI have drifted");
1459    }
1460
1461    /// What the compiler assigns, against the numbers the header
1462    /// publishes as unchangeable.
1463    ///
1464    /// The text comparison above cannot see a renumbering applied to
1465    /// both files, and `BadString` is carried as a deliberately dead
1466    /// variant precisely so that 8 keeps its meaning for a consumer
1467    /// already switching on it.
1468    #[test]
1469    fn the_error_codes_still_have_the_numbers_they_were_published_with() {
1470        assert_eq!(FsCoreErrorCode::Ok as i32, 0);
1471        assert_eq!(FsCoreErrorCode::Io as i32, 1);
1472        assert_eq!(FsCoreErrorCode::ShortRead as i32, 2);
1473        assert_eq!(FsCoreErrorCode::ReadOnly as i32, 3);
1474        assert_eq!(FsCoreErrorCode::OutOfBounds as i32, 4);
1475        assert_eq!(FsCoreErrorCode::Custom as i32, 5);
1476        assert_eq!(FsCoreErrorCode::NullArg as i32, 6);
1477        assert_eq!(FsCoreErrorCode::Panic as i32, 7);
1478        assert_eq!(FsCoreErrorCode::BadString as i32, 8);
1479    }
1480}