Skip to main content

fs_ext4/
capi.rs

1//! C ABI exports — MUST match `include/fs_ext4.h` exactly. Consumers
2//! link `libfs_ext4.a` and #include that header; any signature change
3//! here requires the header to change in lockstep.
4//!
5//! Phase 1 (read-only) surface:
6//! - fs_ext4_mount(device_path) -> *mut fs_ext4_fs_t
7//! - fs_ext4_mount_with_callbacks(cfg) -> *mut fs_ext4_fs_t
8//! - fs_ext4_umount(fs)
9//! - fs_ext4_flush(fs) -> int / fs_ext4_fresh_read(fs) -> int
10//! - fs_ext4_get_volume_info(fs, info) -> int
11//! - fs_ext4_stat(fs, path, attr) -> int
12//! - fs_ext4_dir_open(fs, path) -> *mut iter
13//! - fs_ext4_dir_next(iter) -> *const dirent
14//! - fs_ext4_dir_close(iter)
15//! - fs_ext4_read_file(fs, ...) -> i64 (extents + inline_data)
16//! - fs_ext4_readlink(fs, path, buf, bufsize) -> int (target length, or -1)
17//! - fs_ext4_listxattr(fs, path, buf, bufsize) -> i64
18//! - fs_ext4_getxattr(fs, path, name, buf, bufsize) -> i64
19//! - fs_ext4_last_error() -> *const c_char
20//! - fs_ext4_last_errno() -> c_int          (POSIX errno companion to last_error)
21//!
22//! Phase 4 (write path, in progress):
23//! - fs_ext4_mount_rw(device_path) -> *mut fs_ext4_fs_t
24//! - fs_ext4_mount_rw_with_callbacks(cfg) -> *mut fs_ext4_fs_t  (RW via FSKit-style read+write callbacks)
25//! - fs_ext4_truncate(fs, path, new_size) -> int (shrink + sparse grow)
26//! - fs_ext4_symlink(fs, target, linkpath) -> u32 inode (fast + slow path)
27//! - fs_ext4_chmod(fs, path, mode) -> int
28//! - fs_ext4_chown(fs, path, uid, gid) -> int
29//! - fs_ext4_utimens(fs, path, atime_sec, atime_nsec, mtime_sec, mtime_nsec) -> int
30//! - fs_ext4_unlink(fs, path) -> int
31//! - fs_ext4_write_file(fs, path, data, len) -> i64 (save-as replace body)
32//! - fs_ext4_set_volume_label(fs, label) -> int
33//!
34//! Memory ownership rules (from ntfsbridge precedent, documented in docs/ext4-rs-capi.md):
35//! - `fs_ext4_fs_t*` is owned by the caller. Freed via `fs_ext4_umount`
36//!   (use for both `mount`, `mount_with_callbacks`, and `mount_rw` handles).
37//! - `fs_ext4_dir_iter_t*` is owned by the caller. Freed via `fs_ext4_dir_close`.
38//! - `fs_ext4_dir_next` returns a pointer into the iterator's internal buffer;
39//!   valid until the next `fs_ext4_dir_next` or `fs_ext4_dir_close` call.
40//! - `fs_ext4_last_error` / `fs_ext4_last_errno` read thread-local
41//!   storage; valid until the next FFI call on the same thread.
42
43#![allow(non_camel_case_types)]
44// Module-level docs (above) cover the FFI memory-ownership contract for
45// every exported unsafe fn; per-function `# Safety` sections would be
46// near-duplicates.
47#![allow(clippy::missing_safety_doc)]
48
49use crate::block_io::{AlignedDevice, BlockDevice, CallbackDevice, FileDevice};
50use crate::dir::{self, DirEntryType};
51use crate::error::errno::{EINVAL, EISDIR, ENAMETOOLONG, ENOENT, ENOSYS, ERANGE};
52use crate::error::{Error, Result};
53use crate::fs::{Filesystem, InodeRef};
54use crate::inode::{Inode, S_IFBLK, S_IFCHR, S_IFDIR, S_IFIFO, S_IFLNK, S_IFMT, S_IFREG, S_IFSOCK};
55use crate::xattr;
56use std::cell::RefCell;
57use std::ffi::{CStr, CString};
58use std::os::raw::{c_char, c_int, c_void};
59use std::panic::AssertUnwindSafe;
60use std::sync::Arc;
61
62// ===========================================================================
63// Thread-local last error
64// ===========================================================================
65
66thread_local! {
67    static LAST_ERROR: RefCell<CString> = RefCell::new(CString::new("").unwrap());
68    static LAST_ERRNO: RefCell<c_int> = const { RefCell::new(0) };
69}
70
71fn set_last_error<E: std::fmt::Display>(e: E) {
72    let msg = format!("{e}");
73    LAST_ERROR.with(|cell| {
74        *cell.borrow_mut() =
75            CString::new(msg).unwrap_or_else(|_| CString::new("unknown error").unwrap());
76    });
77}
78
79fn set_last_errno(errno: c_int) {
80    LAST_ERRNO.with(|cell| *cell.borrow_mut() = errno);
81}
82
83/// Record both the error string (with context) and the POSIX errno.
84/// Call this instead of `set_last_error` whenever the source is an `Error`.
85fn set_err_from(err: &Error, context: &str) {
86    set_last_error(format!("{context}: {err}"));
87    set_last_errno(err.to_errno());
88}
89
90/// Record a string message and an explicit errno. Use for validation failures
91/// (null args, wrong file type) where there is no underlying `Error`.
92fn set_err_msg(msg: &str, errno: c_int) {
93    set_last_error(msg);
94    set_last_errno(errno);
95}
96
97fn clear_last_error() {
98    LAST_ERROR.with(|cell| {
99        *cell.borrow_mut() = CString::new("").unwrap();
100    });
101    LAST_ERRNO.with(|cell| *cell.borrow_mut() = 0);
102}
103
104/// Wrap an FFI body in `catch_unwind`. If the body panics, record the panic
105/// message in `last_error` and return `fail`. This prevents unwinding across
106/// the C ABI boundary (undefined behaviour).
107fn ffi_guard<T>(fail: T, body: impl FnOnce() -> T + std::panic::UnwindSafe) -> T {
108    match std::panic::catch_unwind(body) {
109        Ok(v) => v,
110        Err(panic) => {
111            let msg = if let Some(s) = panic.downcast_ref::<&'static str>() {
112                format!("panic: {s}")
113            } else if let Some(s) = panic.downcast_ref::<String>() {
114                format!("panic: {s}")
115            } else {
116                "panic: (non-string payload)".to_string()
117            };
118            set_err_msg(&msg, crate::error::errno::EIO);
119            fail
120        }
121    }
122}
123
124/// Get the last error message for the current thread.
125/// Returns a pointer valid until the next FFI call on this thread.
126#[no_mangle]
127pub extern "C" fn fs_ext4_last_error() -> *const c_char {
128    LAST_ERROR.with(|cell| cell.borrow().as_ptr())
129}
130
131/// Get the POSIX errno for the last failed FFI call on this thread.
132/// Returns 0 if the last call succeeded (or no call has been made yet).
133/// Codes: ENOENT (2), EIO (5), ENOTDIR (20), EINVAL (22), ENOTSUP (45),
134/// or any errno surfaced by the underlying I/O layer.
135#[no_mangle]
136pub extern "C" fn fs_ext4_last_errno() -> c_int {
137    LAST_ERRNO.with(|cell| *cell.borrow())
138}
139
140// ===========================================================================
141// ABI types — MUST match include/fs_ext4.h
142// ===========================================================================
143
144/// File type (matches `fs_ext4_file_type_t` in the header).
145#[repr(C)]
146#[derive(Copy, Clone, Debug)]
147pub enum fs_ext4_file_type_t {
148    Unknown = 0,
149    RegFile = 1,
150    Dir = 2,
151    ChrDev = 3,
152    BlkDev = 4,
153    Fifo = 5,
154    Sock = 6,
155    Symlink = 7,
156}
157
158/// File/directory attributes (matches `fs_ext4_attr_t`).
159#[repr(C)]
160pub struct fs_ext4_attr_t {
161    pub inode: u32,
162    pub mode: u16,
163    pub uid: u32,
164    pub gid: u32,
165    pub size: u64,
166    /// Seconds since the Unix epoch, signed and 64-bit.
167    ///
168    /// Widened from `u32` in 0.6.0. ext4's on-disk base field is a
169    /// SIGNED 32-bit value which the matching `*_extra` field extends
170    /// by two further bits, so the real range is roughly 1901..2446.
171    /// A `u32` here truncated everything past 2038 and turned every
172    /// pre-1970 date into a far-future one.
173    pub atime: i64,
174    pub mtime: i64,
175    pub ctime: i64,
176    pub crtime: i64,
177    pub link_count: u16,
178    pub file_type: fs_ext4_file_type_t,
179    /// Sub-second nanoseconds for atime/mtime/ctime/crtime (0 for old inodes).
180    pub atime_nsec: u32,
181    pub mtime_nsec: u32,
182    pub ctime_nsec: u32,
183    pub crtime_nsec: u32,
184    /// On-disk `i_flags` (e2_flags / FS_IOC_GETFLAGS convention).
185    pub inode_flags: u32,
186    /// `i_generation` — NFS stale-handle counter.
187    pub generation: u32,
188    /// `i_blocks` in 512-byte units (matches `st_blocks` from POSIX stat).
189    pub blocks_512: u64,
190}
191
192/// Directory entry (matches `fs_ext4_dirent_t`).
193#[repr(C)]
194pub struct fs_ext4_dirent_t {
195    pub inode: u32,
196    pub file_type: u8,
197    pub name_len: u8,
198    pub name: [c_char; 256],
199}
200
201/// Volume info (matches `fs_ext4_volume_info_t`). Layout is part of
202/// the FFI ABI; new fields land at the end so existing zero-init
203/// memcpy callers stay valid. Anything that derives from the on-disk
204/// superblock can be added here — long-term goal is to give callers
205/// everything they could possibly want to surface in a UI without
206/// having to re-parse the superblock themselves.
207#[repr(C)]
208pub struct fs_ext4_volume_info_t {
209    /* ----- Identity ----- */
210    /// The label and a terminating NUL: 17 bytes, because a label that
211    /// fills `s_volume_name`'s 16 has no terminator on disk (#463).
212    pub volume_name: [c_char; 17],
213    /// Raw 16-byte UUID. Caller formats as 8-4-4-4-12 hyphenated hex
214    /// to match comparable inspection tools.
215    pub uuid: [u8; 16],
216    /// Last mount path the kernel wrote into `s_last_mounted` (64
217    /// bytes, NUL-terminated). Empty on a freshly mkfs'd FS.
218    pub last_mounted: [c_char; 64],
219
220    /* ----- Sizing ----- */
221    pub block_size: u32,
222    pub total_blocks: u64,
223    pub free_blocks: u64,
224    /// `s_r_blocks_count` — blocks reserved for the superuser (the
225    /// "root reserve", typically ~5%). Explains the gap between
226    /// `free_blocks` and what `df` reports as available to a regular
227    /// user.
228    pub reserved_blocks: u64,
229    pub total_inodes: u32,
230    pub free_inodes: u32,
231    pub inode_size: u16,
232    /// First non-reserved inode (s_first_ino, dynamic-rev only;
233    /// 11 on legacy filesystems).
234    pub first_inode: u32,
235    pub blocks_per_group: u32,
236    pub inodes_per_group: u32,
237
238    /* ----- Provenance + capabilities ----- */
239    /// 0=Linux, 1=Hurd, 2=Masix, 3=FreeBSD, 4=Lites.
240    pub creator_os: u32,
241    pub rev_level: u32,
242    pub minor_rev_level: u16,
243    pub feature_compat: u32,
244    pub feature_incompat: u32,
245    pub feature_ro_compat: u32,
246    /// Block-group descriptor size: 32 or 64 (64 when 64BIT incompat
247    /// feature is set).
248    pub desc_size: u16,
249    pub default_hash_version: u8,
250
251    /* ----- Lifecycle / health ----- */
252    /// `s_state`. Bit 0 (EXT4_VALID_FS) = cleanly unmounted; bit 1
253    /// (EXT4_ERROR_FS) = errors detected; bit 2 (EXT4_ORPHAN_FS) =
254    /// orphans pending recovery.
255    pub state: u16,
256    /// `s_errors`. Kernel error policy: 1=continue, 2=remount-ro,
257    /// 3=panic.
258    pub errors_behavior: u16,
259    /// `s_mtime` — last mount time (unix epoch seconds).
260    pub last_mount_time: u32,
261    /// `s_wtime` — last write time (unix epoch seconds).
262    pub last_write_time: u32,
263    /// `s_lastcheck` — last fsck pass (unix epoch seconds).
264    pub last_check_time: u32,
265    /// `s_checkinterval` — seconds between forced fscks; 0 disables
266    /// time-based forced fsck.
267    pub check_interval: u32,
268    /// `s_mnt_count` — mounts since last fsck.
269    pub mount_count: u16,
270    /// `s_max_mnt_count` — forced fsck after this many mounts; 0 =
271    /// unlimited.
272    pub max_mount_count: u16,
273    pub def_resuid: u16,
274    pub def_resgid: u16,
275
276    /// `1` if the filesystem was NOT cleanly unmounted last time it was
277    /// used (dirty) — the caller should surface this to the user and
278    /// run fsck / journal replay before permitting writes. `0` if the
279    /// filesystem is clean. Derived from `state` for caller convenience.
280    pub mounted_dirty: u8,
281}
282
283/// Block device read callback (matches `fs_ext4_read_fn`).
284pub type fs_ext4_read_fn = Option<
285    unsafe extern "C" fn(context: *mut c_void, buf: *mut c_void, offset: u64, length: u64) -> c_int,
286>;
287
288/// Block device write callback (matches `fs_ext4_write_fn`). NULL when
289/// mounting read-only. NEW in v0.1.3.
290pub type fs_ext4_write_fn = Option<
291    unsafe extern "C" fn(
292        context: *mut c_void,
293        buf: *const c_void,
294        offset: u64,
295        length: u64,
296    ) -> c_int,
297>;
298
299/// Optional flush/fsync callback (matches `fs_ext4_flush_fn`). NULL means
300/// the driver treats `flush()` as a no-op. NEW in v0.1.3.
301pub type fs_ext4_flush_fn = Option<unsafe extern "C" fn(context: *mut c_void) -> c_int>;
302
303/// Callback-based mount config (matches `fs_ext4_blockdev_cfg_t`).
304///
305/// `write` / `flush` were appended in v0.1.3 — `fs_ext4_mount_with_callbacks`
306/// ignores them (still RO), `fs_ext4_mount_rw_with_callbacks` requires
307/// `write` to be set.
308#[repr(C)]
309pub struct fs_ext4_blockdev_cfg_t {
310    pub read: fs_ext4_read_fn,
311    pub context: *mut c_void,
312    pub size_bytes: u64,
313    /// On the mount entry points: the device's sector size. When it is
314    /// greater than 1 the callbacks receive only requests whose offset and
315    /// length are multiples of it (see [`callback_mount_device`]); 0 or 1
316    /// passes the engine's byte-granular requests through unchanged. On
317    /// `fs_ext4_mkfs` it is the filesystem block size to format with.
318    pub block_size: u32,
319    pub write: fs_ext4_write_fn,
320    pub flush: fs_ext4_flush_fn,
321}
322
323// ===========================================================================
324// Opaque handle types
325// ===========================================================================
326
327/// Opaque mounted filesystem handle. The caller treats this as `fs_ext4_fs_t*`.
328pub struct fs_ext4_fs_t {
329    fs: Filesystem,
330}
331
332/// Opaque directory iterator handle. The caller treats this as `fs_ext4_dir_iter_t*`.
333pub struct fs_ext4_dir_iter_t {
334    /// Pre-collected entries (Phase 1 simplicity — streaming can come later).
335    entries: Vec<fs_ext4_dirent_t>,
336    /// Current position in `entries`.
337    position: usize,
338    /// Last returned entry — backing storage for the pointer returned from `_dir_next`.
339    current: fs_ext4_dirent_t,
340}
341
342// ===========================================================================
343// Helpers
344// ===========================================================================
345
346/// Hard upper bound on accepted path/string length from FFI callers,
347/// in bytes (NUL excluded). Matches Linux `PATH_MAX`. A longer path is
348/// refused with ENAMETOOLONG before anything walks it.
349pub(crate) const FFI_PATH_MAX: usize = 4096;
350
351/// A NUL-terminated in-image path, as the BYTES before the NUL, or a
352/// refusal: EINVAL for a null pointer, ENAMETOOLONG past [`FFI_PATH_MAX`].
353///
354/// NEVER DECODED. ext4 directory entry names are raw bytes — no NUL, no
355/// `/`, and no encoding rule; the format has no field that could say what
356/// encoding a name is in — so a path is compared byte for byte against
357/// them, and a name that is not UTF-8 is reachable by exactly its bytes.
358/// Source-compatible for every caller passing UTF-8, because UTF-8 is a
359/// byte string too. A path naming no file is ENOENT (#418).
360///
361/// What this replaced, and why it mattered here more than in the
362/// read-only drivers: `cstr_to_str` decoded the path and answered `""` for
363/// one that did not decode or was too long, and THE EMPTY STRING IS A REAL
364/// PATH HERE. `""`, `"/"` and `"//"` all mean the root by design —
365/// `tests/capi_paths.rs` pins that — so such a path was silently turned
366/// into the one input that means the root. `fs_ext4_stat` filled the
367/// attribute struct with the root's and returned 0, and a caller composing
368/// a path from a name `fs_ext4_dir_next` had just listed walked in a
369/// circle.
370unsafe fn cstr_to_path<'a>(p: *const c_char, what: &str) -> Option<&'a [u8]> {
371    if p.is_null() {
372        set_err_msg(&format!("null {what}"), EINVAL);
373        return None;
374    }
375    let bytes = unsafe { CStr::from_ptr(p) }.to_bytes();
376    if bytes.len() > FFI_PATH_MAX {
377        set_err_msg(
378            &format!("{what} is longer than {FFI_PATH_MAX} bytes"),
379            ENAMETOOLONG,
380        );
381        return None;
382    }
383    Some(bytes)
384}
385
386/// An extended-attribute name, which is not a path: it is matched against
387/// namespace prefixes (`user.`, `trusted.`, ...) as text, so one that is
388/// not UTF-8 is refused with EINVAL — never turned into `""`.
389unsafe fn cstr_to_name<'a>(p: *const c_char, what: &str) -> Option<&'a str> {
390    let bytes = unsafe { cstr_to_path(p, what) }?;
391    match std::str::from_utf8(bytes) {
392        Ok(s) => Some(s),
393        Err(_) => {
394            set_err_msg(
395                &format!("{what} is not valid UTF-8: {:?}", shown(bytes)),
396                EINVAL,
397            );
398            None
399        }
400    }
401}
402
403/// A path as it appears in an error message. Lossy, and for messages
404/// only: the bytes themselves are what is resolved.
405fn shown(path: &[u8]) -> std::borrow::Cow<'_, str> {
406    String::from_utf8_lossy(path)
407}
408
409/// Convert a `*const c_char` to a Rust string. Returns empty string on
410/// null, on lengths exceeding [`FFI_PATH_MAX`], or on invalid UTF-8.
411///
412/// FOR HOST-SIDE STRINGS ONLY — a device path, a volume label — whose
413/// callers refuse `""` themselves. Never for an in-image path, where `""`
414/// means the root: those go through [`cstr_to_path`].
415unsafe fn cstr_to_str<'a>(p: *const c_char) -> &'a str {
416    if p.is_null() {
417        return "";
418    }
419    let cstr = CStr::from_ptr(p);
420    if cstr.to_bytes().len() > FFI_PATH_MAX {
421        return "";
422    }
423    cstr.to_str().unwrap_or("")
424}
425
426/// Strict variant of [`cstr_to_str`]. Returns `Err` for null pointers,
427/// strings longer than [`FFI_PATH_MAX`], or invalid UTF-8 — useful for
428/// callers that want a distinct EILSEQ/EINVAL/ENOENT response instead
429/// of the legacy "treat as ENOENT" behavior. Currently unused but
430/// available for new entry points that want to be explicit.
431#[allow(dead_code)]
432unsafe fn cstr_to_str_strict<'a>(p: *const c_char) -> std::result::Result<&'a str, &'static str> {
433    if p.is_null() {
434        return Err("null pointer");
435    }
436    let cstr = CStr::from_ptr(p);
437    if cstr.to_bytes().len() > FFI_PATH_MAX {
438        return Err("string exceeds FFI_PATH_MAX");
439    }
440    cstr.to_str().map_err(|_| "invalid UTF-8")
441}
442
443/// Convert POSIX mode bits to `fs_ext4_file_type_t`.
444fn mode_to_file_type(mode: u16) -> fs_ext4_file_type_t {
445    match mode & S_IFMT {
446        S_IFREG => fs_ext4_file_type_t::RegFile,
447        S_IFDIR => fs_ext4_file_type_t::Dir,
448        S_IFLNK => fs_ext4_file_type_t::Symlink,
449        S_IFCHR => fs_ext4_file_type_t::ChrDev,
450        S_IFBLK => fs_ext4_file_type_t::BlkDev,
451        S_IFIFO => fs_ext4_file_type_t::Fifo,
452        S_IFSOCK => fs_ext4_file_type_t::Sock,
453        _ => fs_ext4_file_type_t::Unknown,
454    }
455}
456
457/// Fill an `fs_ext4_attr_t` from an inode.
458fn fill_attr(out: &mut fs_ext4_attr_t, ino: u32, inode: &Inode) {
459    out.inode = ino;
460    out.mode = inode.mode & 0x0FFF; // keep permission bits
461    out.uid = inode.uid;
462    out.gid = inode.gid;
463    out.size = inode.size;
464    out.atime = inode.atime;
465    out.mtime = inode.mtime;
466    out.ctime = inode.ctime;
467    out.crtime = inode.crtime;
468    out.link_count = inode.links_count;
469    out.file_type = mode_to_file_type(inode.mode);
470    out.atime_nsec = inode.atime_nsec;
471    out.mtime_nsec = inode.mtime_nsec;
472    out.ctime_nsec = inode.ctime_nsec;
473    out.crtime_nsec = inode.crtime_nsec;
474    out.inode_flags = inode.flags;
475    out.generation = inode.generation;
476    out.blocks_512 = inode.blocks;
477}
478
479// ===========================================================================
480// Lifecycle
481// ===========================================================================
482
483/// Mount an ext4 filesystem from a device path. Returns NULL on failure.
484#[no_mangle]
485pub unsafe extern "C" fn fs_ext4_mount(device_path: *const c_char) -> *mut fs_ext4_fs_t {
486    ffi_guard(
487        std::ptr::null_mut(),
488        AssertUnwindSafe(|| {
489            clear_last_error();
490            let path = cstr_to_str(device_path);
491            if path.is_empty() {
492                set_err_msg("null or empty device_path", EINVAL);
493                return std::ptr::null_mut();
494            }
495
496            let dev = match FileDevice::open(path) {
497                Ok(d) => Arc::new(d) as Arc<dyn BlockDevice>,
498                Err(e) => {
499                    set_err_from(&e, &format!("open {path}"));
500                    return std::ptr::null_mut();
501                }
502            };
503
504            match Filesystem::mount(dev) {
505                Ok(fs) => Box::into_raw(Box::new(fs_ext4_fs_t { fs })),
506                Err(e) => {
507                    set_err_from(&e, &format!("mount {path}"));
508                    std::ptr::null_mut()
509                }
510            }
511        }),
512    )
513}
514
515/// Largest sector size a callback mount accepts: ext4's largest block
516/// size, since a sector may not be larger than a filesystem block.
517const MAX_SECTOR_SIZE: u32 = 65536;
518
519/// The device a callback mount runs on: `dev` itself when `cfg.block_size`
520/// is 0 or 1, otherwise `dev` behind an [`AlignedDevice`] so the callbacks
521/// only ever see `block_size`-aligned requests (#373).
522///
523/// `block_size` must be a power of two, divide `size_bytes`, and be no
524/// larger than the filesystem's block size (read from the superblock
525/// through the aligned device, before anything is mounted). Every refusal
526/// is `EINVAL`; the message says which rule it broke.
527fn callback_mount_device(
528    dev: CallbackDevice,
529    cfg: &fs_ext4_blockdev_cfg_t,
530) -> std::result::Result<Arc<dyn BlockDevice>, String> {
531    let sector = cfg.block_size;
532    if sector <= 1 {
533        return Ok(Arc::new(dev));
534    }
535    if !sector.is_power_of_two() || sector > MAX_SECTOR_SIZE {
536        return Err(format!(
537            "cfg.block_size {sector} is not a power of two in 2..={MAX_SECTOR_SIZE}"
538        ));
539    }
540    if !cfg.size_bytes.is_multiple_of(sector as u64) {
541        return Err(format!(
542            "cfg.block_size {sector} does not divide cfg.size_bytes {}",
543            cfg.size_bytes
544        ));
545    }
546    let dev = AlignedDevice::new(dev, sector);
547    // s_magic at +0x38, s_log_block_size at +0x18 of the superblock at
548    // byte 1024. A read that fails or finds no ext4 magic is left for the
549    // mount to report in its own words.
550    let mut sb = [0u8; 1024];
551    if dev.read_at(1024, &mut sb).is_ok() && u16::from_le_bytes([sb[0x38], sb[0x39]]) == 0xEF53 {
552        let log = u32::from_le_bytes([sb[0x18], sb[0x19], sb[0x1A], sb[0x1B]]);
553        if let Some(fs_block) = 1024u32.checked_shl(log).filter(|_| log < 22) {
554            if sector > fs_block {
555                return Err(format!(
556                    "cfg.block_size {sector} is larger than the filesystem block size {fs_block}"
557                ));
558            }
559        }
560    }
561    Ok(Arc::new(dev))
562}
563
564/// Mount via a caller-supplied read callback.
565#[no_mangle]
566pub unsafe extern "C" fn fs_ext4_mount_with_callbacks(
567    cfg: *const fs_ext4_blockdev_cfg_t,
568) -> *mut fs_ext4_fs_t {
569    ffi_guard(
570        std::ptr::null_mut(),
571        AssertUnwindSafe(|| mount_with_callbacks_inner(cfg)),
572    )
573}
574
575unsafe fn mount_with_callbacks_inner(cfg: *const fs_ext4_blockdev_cfg_t) -> *mut fs_ext4_fs_t {
576    clear_last_error();
577    if cfg.is_null() {
578        set_err_msg("null cfg", EINVAL);
579        return std::ptr::null_mut();
580    }
581    let cfg = &*cfg;
582    let Some(read_fn) = cfg.read else {
583        set_err_msg("cfg.read is null", EINVAL);
584        return std::ptr::null_mut();
585    };
586
587    // Wrap the C context + callback in a thread-safe closure.
588    // The caller is responsible for context lifetime ≥ fs lifetime.
589    // We store context as usize to make the closure Send+Sync; Swift/C side
590    // is expected to keep the context pointer valid (FSKit guarantees serial
591    // access from the extension's queue).
592    let ctx_addr = cfg.context as usize;
593    let size = cfg.size_bytes;
594
595    let dev = CallbackDevice {
596        size,
597        read: Box::new(move |offset, buf| {
598            let rc = unsafe {
599                read_fn(
600                    ctx_addr as *mut c_void,
601                    buf.as_mut_ptr() as *mut c_void,
602                    offset,
603                    buf.len() as u64,
604                )
605            };
606            if rc != 0 {
607                Err(std::io::Error::other(format!("callback returned {rc}")))
608            } else {
609                Ok(())
610            }
611        }),
612        // Swift-side callback mount is read-only for now (no write callback
613        // plumbed through the C struct). Phase 4 writes go through a
614        // different C entry point that accepts a write_fn.
615        write: None,
616        flush: None,
617    };
618
619    let dev = match callback_mount_device(dev, cfg) {
620        Ok(dev) => dev,
621        Err(msg) => {
622            set_err_msg(&msg, EINVAL);
623            return std::ptr::null_mut();
624        }
625    };
626    match Filesystem::mount(dev) {
627        Ok(fs) => Box::into_raw(Box::new(fs_ext4_fs_t { fs })),
628        Err(e) => {
629            set_err_from(&e, "mount (callback)");
630            std::ptr::null_mut()
631        }
632    }
633}
634
635/// Mount via an `FsCoreDevice` handle from a sister crate (`qcow2_open`,
636/// `partitions_open_slice`, `fs_core_file_open`, …). Single entry point —
637/// the inner device's `is_writable()` decides RO vs RW, so callers don't
638/// need a `_rw` variant.
639///
640/// The handle's reference count is incremented; closing this mount via
641/// `fs_ext4_umount` drops that reference. The C caller still owns its
642/// own `*mut FsCoreDevice` and frees it independently via
643/// `fs_core_device_close`.
644///
645/// Returns NULL on failure; consult `fs_ext4_last_error()` for detail.
646#[no_mangle]
647pub unsafe extern "C" fn fs_ext4_mount_with_fs_core_device(
648    handle: *mut fs_core::ffi::FsCoreDevice,
649) -> *mut fs_ext4_fs_t {
650    mount_fs_core_handle(handle, false)
651}
652
653/// Same as [`fs_ext4_mount_with_fs_core_device`] but defers journal replay
654/// until the caller invokes [`fs_ext4_replay_journal_if_dirty`]. Use this
655/// from FSKit `loadResource` paths where replaying mid-load can hang the
656/// mount call — replay can run safely once the volume is fully active.
657#[no_mangle]
658pub unsafe extern "C" fn fs_ext4_mount_with_fs_core_device_lazy(
659    handle: *mut fs_core::ffi::FsCoreDevice,
660) -> *mut fs_ext4_fs_t {
661    mount_fs_core_handle(handle, true)
662}
663
664/// The body of both `fs_core` mount entry points, which differ only in
665/// whether the journal is replayed before the mount returns (`lazy` defers
666/// it to [`fs_ext4_replay_journal_if_dirty`]).
667///
668/// # Safety
669/// `handle` is null or a live `FsCoreDevice` from a sister crate.
670unsafe fn mount_fs_core_handle(
671    handle: *mut fs_core::ffi::FsCoreDevice,
672    lazy: bool,
673) -> *mut fs_ext4_fs_t {
674    ffi_guard(
675        std::ptr::null_mut(),
676        AssertUnwindSafe(|| {
677            clear_last_error();
678            if handle.is_null() {
679                set_err_msg("null fs_core handle", EINVAL);
680                return std::ptr::null_mut();
681            }
682            // Borrow the handle's Arc<dyn fs_core::BlockDevice> and clone it
683            // so the mount holds its own reference. The caller is free to
684            // close their handle whenever they want.
685            let inner = (*handle).inner().clone();
686            let adapter = crate::fs_core_bridge::CoreDevice::new(inner);
687            let dev: Arc<dyn BlockDevice> = Arc::new(adapter);
688
689            let (mounted, what) = if lazy {
690                (Filesystem::mount_lazy(dev), "mount_lazy via fs_core handle")
691            } else {
692                (Filesystem::mount(dev), "mount via fs_core handle")
693            };
694            match mounted {
695                Ok(fs) => Box::into_raw(Box::new(fs_ext4_fs_t { fs })),
696                Err(e) => {
697                    set_err_from(&e, what);
698                    std::ptr::null_mut()
699                }
700            }
701        }),
702    )
703}
704
705/// Mount read-write via caller-supplied read+write callbacks. Companion to
706/// `fs_ext4_mount_rw` for sandboxed consumers (FSKit, etc.) that own a
707/// block-device resource but cannot open `/dev/diskN`. Both `cfg.read`
708/// and `cfg.write` must be set; `cfg.flush` is optional. Returns NULL on
709/// failure with errno set (EINVAL when callbacks / cfg are missing).
710///
711/// A successful mount replays a dirty journal before returning, just like
712/// `fs_ext4_mount_rw` — the underlying `BlockDevice::is_writable()` is
713/// `true` because a write callback is attached.
714#[no_mangle]
715pub unsafe extern "C" fn fs_ext4_mount_rw_with_callbacks(
716    cfg: *const fs_ext4_blockdev_cfg_t,
717) -> *mut fs_ext4_fs_t {
718    ffi_guard(
719        std::ptr::null_mut(),
720        AssertUnwindSafe(|| mount_rw_with_callbacks_inner(cfg)),
721    )
722}
723
724unsafe fn mount_rw_with_callbacks_inner(cfg: *const fs_ext4_blockdev_cfg_t) -> *mut fs_ext4_fs_t {
725    clear_last_error();
726    if cfg.is_null() {
727        set_err_msg("null cfg", EINVAL);
728        return std::ptr::null_mut();
729    }
730    let cfg = &*cfg;
731    let Some(read_fn) = cfg.read else {
732        set_err_msg("cfg.read is null", EINVAL);
733        return std::ptr::null_mut();
734    };
735    let Some(write_fn) = cfg.write else {
736        set_err_msg("cfg.write is null (required for RW callback mount)", EINVAL);
737        return std::ptr::null_mut();
738    };
739    let flush_fn = cfg.flush; // Option — None is fine, treated as no-op.
740
741    // Stash the C context as usize so the closures are Send+Sync. Caller
742    // owns the context lifetime; FSKit guarantees serial access from the
743    // extension's queue, which matches the synchronisation model the
744    // rest of the driver assumes.
745    let ctx_addr = cfg.context as usize;
746    let size = cfg.size_bytes;
747
748    let read_closure = move |offset: u64, buf: &mut [u8]| -> std::io::Result<()> {
749        let rc = unsafe {
750            read_fn(
751                ctx_addr as *mut c_void,
752                buf.as_mut_ptr() as *mut c_void,
753                offset,
754                buf.len() as u64,
755            )
756        };
757        if rc != 0 {
758            Err(std::io::Error::other(format!(
759                "read callback returned {rc}"
760            )))
761        } else {
762            Ok(())
763        }
764    };
765    let write_closure = move |offset: u64, buf: &[u8]| -> std::io::Result<()> {
766        let rc = unsafe {
767            write_fn(
768                ctx_addr as *mut c_void,
769                buf.as_ptr() as *const c_void,
770                offset,
771                buf.len() as u64,
772            )
773        };
774        if rc != 0 {
775            Err(std::io::Error::other(format!(
776                "write callback returned {rc}"
777            )))
778        } else {
779            Ok(())
780        }
781    };
782    let flush_closure: Option<crate::block_io::FlushCb> = flush_fn.map(|f| {
783        let cb: crate::block_io::FlushCb = Box::new(move || -> std::io::Result<()> {
784            let rc = unsafe { f(ctx_addr as *mut c_void) };
785            if rc != 0 {
786                Err(std::io::Error::other(format!(
787                    "flush callback returned {rc}"
788                )))
789            } else {
790                Ok(())
791            }
792        });
793        cb
794    });
795
796    let dev = CallbackDevice {
797        size,
798        read: Box::new(read_closure),
799        write: Some(Box::new(write_closure)),
800        flush: flush_closure,
801    };
802
803    let dev = match callback_mount_device(dev, cfg) {
804        Ok(dev) => dev,
805        Err(msg) => {
806            set_err_msg(&msg, EINVAL);
807            return std::ptr::null_mut();
808        }
809    };
810    match Filesystem::mount(dev) {
811        Ok(fs) => Box::into_raw(Box::new(fs_ext4_fs_t { fs })),
812        Err(e) => {
813            set_err_from(&e, "mount_rw (callback)");
814            std::ptr::null_mut()
815        }
816    }
817}
818
819/// Mount RW via callbacks WITHOUT performing journal replay automatically.
820/// Same semantics as `fs_ext4_mount_rw_with_callbacks` except a dirty
821/// journal is recorded but NOT replayed during this call. Use this when
822/// the consumer is in a context where its write callback can't service
823/// writes yet (e.g. inside FSKit's `loadResource`, before the kernel opens
824/// the writable FD on `FSBlockDeviceResource`).
825///
826/// After mount, call `fs_ext4_replay_journal_if_dirty(fs)` once the
827/// consumer's write path is ready.
828#[no_mangle]
829pub unsafe extern "C" fn fs_ext4_mount_rw_with_callbacks_lazy(
830    cfg: *const fs_ext4_blockdev_cfg_t,
831) -> *mut fs_ext4_fs_t {
832    ffi_guard(
833        std::ptr::null_mut(),
834        AssertUnwindSafe(|| mount_rw_with_callbacks_lazy_inner(cfg)),
835    )
836}
837
838unsafe fn mount_rw_with_callbacks_lazy_inner(
839    cfg: *const fs_ext4_blockdev_cfg_t,
840) -> *mut fs_ext4_fs_t {
841    clear_last_error();
842    if cfg.is_null() {
843        set_err_msg("null cfg", EINVAL);
844        return std::ptr::null_mut();
845    }
846    let cfg = &*cfg;
847    let Some(read_fn) = cfg.read else {
848        set_err_msg("cfg.read is null", EINVAL);
849        return std::ptr::null_mut();
850    };
851    let Some(write_fn) = cfg.write else {
852        set_err_msg("cfg.write is null (required for RW callback mount)", EINVAL);
853        return std::ptr::null_mut();
854    };
855    let flush_fn = cfg.flush;
856
857    let ctx_addr = cfg.context as usize;
858    let size = cfg.size_bytes;
859
860    let read_closure = move |offset: u64, buf: &mut [u8]| -> std::io::Result<()> {
861        let rc = unsafe {
862            read_fn(
863                ctx_addr as *mut c_void,
864                buf.as_mut_ptr() as *mut c_void,
865                offset,
866                buf.len() as u64,
867            )
868        };
869        if rc != 0 {
870            Err(std::io::Error::other(format!(
871                "read callback returned {rc}"
872            )))
873        } else {
874            Ok(())
875        }
876    };
877    let write_closure = move |offset: u64, buf: &[u8]| -> std::io::Result<()> {
878        let rc = unsafe {
879            write_fn(
880                ctx_addr as *mut c_void,
881                buf.as_ptr() as *const c_void,
882                offset,
883                buf.len() as u64,
884            )
885        };
886        if rc != 0 {
887            Err(std::io::Error::other(format!(
888                "write callback returned {rc}"
889            )))
890        } else {
891            Ok(())
892        }
893    };
894    let flush_closure: Option<crate::block_io::FlushCb> = flush_fn.map(|f| {
895        let cb: crate::block_io::FlushCb = Box::new(move || -> std::io::Result<()> {
896            let rc = unsafe { f(ctx_addr as *mut c_void) };
897            if rc != 0 {
898                Err(std::io::Error::other(format!(
899                    "flush callback returned {rc}"
900                )))
901            } else {
902                Ok(())
903            }
904        });
905        cb
906    });
907
908    let dev = CallbackDevice {
909        size,
910        read: Box::new(read_closure),
911        write: Some(Box::new(write_closure)),
912        flush: flush_closure,
913    };
914
915    let dev = match callback_mount_device(dev, cfg) {
916        Ok(dev) => dev,
917        Err(msg) => {
918            set_err_msg(&msg, EINVAL);
919            return std::ptr::null_mut();
920        }
921    };
922    match Filesystem::mount_lazy(dev) {
923        Ok(fs) => Box::into_raw(Box::new(fs_ext4_fs_t { fs })),
924        Err(e) => {
925            set_err_from(&e, "mount_rw_lazy (callback)");
926            std::ptr::null_mut()
927        }
928    }
929}
930
931/// Replay the JBD2 journal on `fs` now if it is dirty. Idempotent — safe to
932/// call on a clean volume (returns 0, performs no writes). Returns 0 on
933/// success or already-clean, -1 on failure (call `fs_ext4_last_error` /
934/// `fs_ext4_last_errno` for details). Pairs with
935/// `fs_ext4_mount_rw_with_callbacks_lazy`; calling this on a handle that
936/// was eager-mounted is a no-op (journal already clean) and returns 0.
937#[no_mangle]
938pub unsafe extern "C" fn fs_ext4_replay_journal_if_dirty(fs: *mut fs_ext4_fs_t) -> c_int {
939    ffi_guard(
940        -1,
941        AssertUnwindSafe(|| {
942            clear_last_error();
943            if fs.is_null() {
944                set_err_msg("null fs handle", EINVAL);
945                return -1;
946            }
947            let fs_ref = &mut (*fs).fs;
948            match fs_ref.replay_journal_if_dirty() {
949                Ok(_) => 0,
950                Err(e) => {
951                    set_err_from(&e, "replay_journal_if_dirty");
952                    -1
953                }
954            }
955        }),
956    )
957}
958
959/// Durability barrier for a live mount: flush the device and check that
960/// nothing is left in flight, without releasing the handle. Returns 0 on
961/// success, -1 with `fs_ext4_last_errno` set on failure: `EIO` when an
962/// earlier mutation's device write or journal operation failed (the mount
963/// must then be retired), or whatever errno the host's flush callback
964/// surfaced. Safe to call while other calls on the same handle run.
965#[no_mangle]
966pub unsafe extern "C" fn fs_ext4_flush(fs: *mut fs_ext4_fs_t) -> c_int {
967    ffi_guard(
968        -1,
969        AssertUnwindSafe(|| {
970            clear_last_error();
971            if fs.is_null() {
972                set_err_msg("null fs handle", EINVAL);
973                return -1;
974            }
975            match (*fs).fs.flush() {
976                Ok(()) => 0,
977                Err(e) => {
978                    set_err_from(&e, "flush");
979                    -1
980                }
981            }
982        }),
983    )
984}
985
986/// Flush, then drop the mount's read caches and re-read its metadata from
987/// the device, for a host that changed the device underneath. Returns 0 on
988/// success, -1 with `fs_ext4_last_errno` set on failure. The caller must
989/// serialise: no other call on this handle may be in flight.
990#[no_mangle]
991pub unsafe extern "C" fn fs_ext4_fresh_read(fs: *mut fs_ext4_fs_t) -> c_int {
992    ffi_guard(
993        -1,
994        AssertUnwindSafe(|| {
995            clear_last_error();
996            if fs.is_null() {
997                set_err_msg("null fs handle", EINVAL);
998                return -1;
999            }
1000            match (*fs).fs.fresh_read() {
1001                Ok(()) => 0,
1002                Err(e) => {
1003                    set_err_from(&e, "fresh_read");
1004                    -1
1005                }
1006            }
1007        }),
1008    )
1009}
1010
1011/// Unmount and free the filesystem handle.
1012#[no_mangle]
1013pub unsafe extern "C" fn fs_ext4_umount(fs: *mut fs_ext4_fs_t) {
1014    ffi_guard(
1015        (),
1016        AssertUnwindSafe(|| {
1017            if !fs.is_null() {
1018                drop(Box::from_raw(fs));
1019            }
1020        }),
1021    )
1022}
1023
1024// ===========================================================================
1025// Volume info
1026// ===========================================================================
1027
1028/// Fill `info` with volume statistics. Returns 0 on success, -1 on failure.
1029#[no_mangle]
1030pub unsafe extern "C" fn fs_ext4_get_volume_info(
1031    fs: *mut fs_ext4_fs_t,
1032    info: *mut fs_ext4_volume_info_t,
1033) -> c_int {
1034    ffi_guard(
1035        -1,
1036        AssertUnwindSafe(|| {
1037            clear_last_error();
1038            if fs.is_null() || info.is_null() {
1039                set_err_msg("null fs or info", EINVAL);
1040                return -1;
1041            }
1042            let fs = &(*fs).fs;
1043            let info = &mut *info;
1044
1045            // Zero the struct first so any field we don't explicitly
1046            // populate (e.g. `last_mounted` when the FS was never
1047            // mounted) reads as all-zero / NUL-terminated empty.
1048            std::ptr::write_bytes(info as *mut fs_ext4_volume_info_t, 0, 1);
1049
1050            // ----- Identity -----
1051            // Volume name: up to 16 bytes, then the NUL the struct's
1052            // 17th byte always has room for.
1053            let name_bytes = fs.sb.volume_name.as_bytes();
1054            let copy_len = name_bytes.len().min(16);
1055            for (i, &b) in name_bytes[..copy_len].iter().enumerate() {
1056                info.volume_name[i] = b as c_char;
1057            }
1058            info.volume_name[copy_len] = 0;
1059            info.uuid = fs.sb.uuid;
1060            // last_mounted (up to 64 bytes incl. NUL). Already
1061            // truncated by the parser to a printable substring.
1062            let lm_bytes = fs.sb.last_mounted.as_bytes();
1063            let lm_copy = lm_bytes.len().min(63);
1064            for (i, &b) in lm_bytes[..lm_copy].iter().enumerate() {
1065                info.last_mounted[i] = b as c_char;
1066            }
1067            info.last_mounted[lm_copy] = 0;
1068
1069            // ----- Sizing -----
1070            info.block_size = fs.sb.block_size();
1071            info.total_blocks = fs.sb.blocks_count;
1072            info.free_blocks = fs.sb.free_blocks_count;
1073            info.reserved_blocks = fs.sb.r_blocks_count;
1074            info.total_inodes = fs.sb.inodes_count;
1075            info.free_inodes = fs.sb.free_inodes_count;
1076            info.inode_size = fs.sb.inode_size;
1077            info.first_inode = fs.sb.first_inode;
1078            info.blocks_per_group = fs.sb.blocks_per_group;
1079            info.inodes_per_group = fs.sb.inodes_per_group;
1080
1081            // ----- Provenance + capabilities -----
1082            info.creator_os = fs.sb.creator_os;
1083            info.rev_level = fs.sb.rev_level;
1084            info.minor_rev_level = fs.sb.minor_rev_level;
1085            info.feature_compat = fs.sb.feature_compat;
1086            info.feature_incompat = fs.sb.feature_incompat;
1087            info.feature_ro_compat = fs.sb.feature_ro_compat;
1088            info.desc_size = fs.sb.desc_size;
1089            info.default_hash_version = fs.sb.default_hash_version;
1090
1091            // ----- Lifecycle / health -----
1092            info.state = fs.sb.state;
1093            info.errors_behavior = fs.sb.errors_behavior;
1094            info.last_mount_time = fs.sb.mtime;
1095            info.last_write_time = fs.sb.wtime;
1096            info.last_check_time = fs.sb.lastcheck;
1097            info.check_interval = fs.sb.checkinterval;
1098            info.mount_count = fs.sb.mnt_count;
1099            info.max_mount_count = fs.sb.max_mnt_count;
1100            info.def_resuid = fs.sb.def_resuid;
1101            info.def_resgid = fs.sb.def_resgid;
1102
1103            info.mounted_dirty = if fs.sb.is_clean() { 0 } else { 1 };
1104
1105            0
1106        }),
1107    )
1108}
1109
1110/// Set the volume label to the NUL-terminated `label`: at most 16 bytes,
1111/// written to the primary superblock and every backup
1112/// (`Filesystem::set_volume_label`). An empty string clears it. Returns 0 on
1113/// success, -1 with `fs_ext4_last_errno` set on failure: EINVAL for a label
1114/// longer than 16 bytes, EROFS on a read-only mount. The caller must
1115/// serialise: no other call on this handle may be in flight.
1116#[no_mangle]
1117pub unsafe extern "C" fn fs_ext4_set_volume_label(
1118    fs: *mut fs_ext4_fs_t,
1119    label: *const c_char,
1120) -> c_int {
1121    ffi_guard(
1122        -1,
1123        AssertUnwindSafe(|| {
1124            clear_last_error();
1125            if fs.is_null() || label.is_null() {
1126                set_err_msg("null fs or label", EINVAL);
1127                return -1;
1128            }
1129            let bytes = CStr::from_ptr(label).to_bytes();
1130            match (*fs).fs.set_volume_label(bytes) {
1131                Ok(()) => 0,
1132                Err(e) => {
1133                    set_err_from(&e, "set_volume_label");
1134                    -1
1135                }
1136            }
1137        }),
1138    )
1139}
1140
1141// ===========================================================================
1142// Stat / readdir / read — STUBBED until dir.rs + extent.rs land
1143// ===========================================================================
1144
1145/// Resolve a path to an inode number via `path::lookup`.
1146///
1147/// Each intermediate inode read goes through `Filesystem::read_inode_verified`
1148/// so the path-walk surfaces `Error::BadChecksum` if any directory inode is
1149/// corrupt (when `RO_COMPAT_METADATA_CSUM` is enabled).
1150fn resolve_path(fs: &Filesystem, path: &[u8]) -> Result<u32> {
1151    let ino = fs.lookup_path_bytes(path)?;
1152
1153    // POSIX: a trailing slash implies the caller expects a directory. If the
1154    // resolved target is not a directory, surface ENOTDIR. `path::lookup`
1155    // drops trailing empty components so this has to be re-checked here.
1156    // Root (`/`) short-circuits trivially since inode 2 is always a dir.
1157    if path.ends_with(b"/") && path != b"/" {
1158        let (inode, _raw) = fs.read_inode_verified(ino)?;
1159        if !inode.is_dir() {
1160            return Err(Error::NotADirectory);
1161        }
1162    }
1163    Ok(ino)
1164}
1165
1166/// Stat a path. Returns 0 on success, -1 on failure.
1167#[no_mangle]
1168pub unsafe extern "C" fn fs_ext4_stat(
1169    fs: *mut fs_ext4_fs_t,
1170    path: *const c_char,
1171    attr: *mut fs_ext4_attr_t,
1172) -> c_int {
1173    ffi_guard(
1174        -1,
1175        AssertUnwindSafe(|| {
1176            clear_last_error();
1177            if fs.is_null() || path.is_null() || attr.is_null() {
1178                set_err_msg("null fs, path, or attr", EINVAL);
1179                return -1;
1180            }
1181            let fs = &(*fs).fs;
1182            let Some(path_b) = (unsafe { cstr_to_path(path, "path") }) else {
1183                return -1;
1184            };
1185            let path = shown(path_b);
1186            let attr = &mut *attr;
1187
1188            match resolve_path(fs, path_b).and_then(|ino| stat_into(fs, ino.into(), attr)) {
1189                Ok(()) => 0,
1190                Err(e) => {
1191                    set_err_from(&e, &format!("stat {path}"));
1192                    -1
1193                }
1194            }
1195        }),
1196    )
1197}
1198
1199/// Open a directory for iteration. Returns NULL on failure.
1200#[no_mangle]
1201pub unsafe extern "C" fn fs_ext4_dir_open(
1202    fs: *mut fs_ext4_fs_t,
1203    path: *const c_char,
1204) -> *mut fs_ext4_dir_iter_t {
1205    ffi_guard(
1206        std::ptr::null_mut(),
1207        AssertUnwindSafe(|| {
1208            clear_last_error();
1209            if fs.is_null() || path.is_null() {
1210                set_err_msg("null fs or path", EINVAL);
1211                return std::ptr::null_mut();
1212            }
1213            let fs_ref = &(*fs).fs;
1214            let Some(path_b) = (unsafe { cstr_to_path(path, "path") }) else {
1215                return std::ptr::null_mut();
1216            };
1217            let path_str = shown(path_b);
1218
1219            match resolve_path(fs_ref, path_b).and_then(|ino| dir_iter(fs_ref, ino.into())) {
1220                Ok(iter) => iter,
1221                Err(e) => {
1222                    set_err_from(&e, &format!("dir_open {path_str}"));
1223                    std::ptr::null_mut()
1224                }
1225            }
1226        }),
1227    )
1228}
1229
1230/// A directory iterator over every entry of the directory `dir`. The one
1231/// implementation behind `fs_ext4_dir_open` and `fs_ext4_dir_open_ino`.
1232fn dir_iter(fs: &Filesystem, dir: InodeRef) -> Result<*mut fs_ext4_dir_iter_t> {
1233    let entries = fs
1234        .read_dir_ino(dir)?
1235        .iter()
1236        .map(dir_entry_to_bridge)
1237        .collect();
1238    Ok(Box::into_raw(Box::new(fs_ext4_dir_iter_t {
1239        entries,
1240        position: 0,
1241        // SAFETY: a plain-data struct of integers and a byte array.
1242        current: unsafe { std::mem::zeroed() },
1243    })))
1244}
1245
1246/// Fill `attr` from the inode `r` names. The one implementation behind
1247/// `fs_ext4_stat`, `fs_ext4_stat_ino` and every `attr` out-parameter.
1248fn stat_into(fs: &Filesystem, r: InodeRef, attr: &mut fs_ext4_attr_t) -> Result<()> {
1249    let inode = fs.stat_ino(r)?;
1250    fill_attr(attr, r.ino, &inode);
1251    Ok(())
1252}
1253
1254/// Read up to `length` bytes of the regular file `r` names into `buf`.
1255/// The one implementation behind `fs_ext4_read_file` and
1256/// `fs_ext4_pread_ino`.
1257unsafe fn read_into(
1258    fs: &Filesystem,
1259    r: InodeRef,
1260    buf: *mut c_void,
1261    offset: u64,
1262    length: u64,
1263) -> Result<i64> {
1264    let inode = fs.stat_ino(r)?;
1265    if !inode.is_file() {
1266        return Err(Error::InvalidArgument("not a regular file"));
1267    }
1268    // Cap `length` against the file's actual size so a caller passing
1269    // `u64::MAX` doesn't fabricate an absurd output slice. The slice
1270    // descriptor is built from caller-controlled bytes — undefined
1271    // behaviour if the caller's `buf` is smaller than `length`. Bounding
1272    // here keeps the slice within the file and within `usize`.
1273    let length = length.min(inode.size).min(usize::MAX as u64);
1274    let out = std::slice::from_raw_parts_mut(buf as *mut u8, length as usize);
1275    Ok(fs.read_ino(r, offset, out)? as i64)
1276}
1277
1278/// Copy a symlink target into `buf` under the `fs_ext4_readlink` contract:
1279/// NUL-terminated, the length returned, ERANGE and nothing written when
1280/// `buf` cannot hold the target and its NUL.
1281unsafe fn readlink_into(target: &[u8], buf: *mut c_char, bufsize: usize, what: &str) -> c_int {
1282    let needed = target.len() + 1;
1283    if bufsize < needed {
1284        set_err_msg(
1285            &format!(
1286                "readlink {what}: buffer of {bufsize} bytes is too small, \
1287                 {needed} needed (target and NUL)"
1288            ),
1289            ERANGE,
1290        );
1291        return -1;
1292    }
1293    let out = std::slice::from_raw_parts_mut(buf.cast::<u8>(), needed);
1294    out[..target.len()].copy_from_slice(target);
1295    out[target.len()] = 0;
1296    // The target is at most PATH_MAX bytes, so the length fits a c_int.
1297    target.len() as c_int
1298}
1299
1300/// Convert a parsed DirEntry to the C ABI dirent struct.
1301fn dir_entry_to_bridge(e: &dir::DirEntry) -> fs_ext4_dirent_t {
1302    let mut name = [0 as c_char; 256];
1303    let copy_len = e.name.len().min(255);
1304    for (i, &b) in e.name[..copy_len].iter().enumerate() {
1305        name[i] = b as c_char;
1306    }
1307    name[copy_len] = 0;
1308
1309    let file_type = match e.file_type {
1310        DirEntryType::RegFile => 1u8,
1311        DirEntryType::Directory => 2,
1312        DirEntryType::CharDev => 3,
1313        DirEntryType::BlockDev => 4,
1314        DirEntryType::Fifo => 5,
1315        DirEntryType::Socket => 6,
1316        DirEntryType::Symlink => 7,
1317        DirEntryType::Unknown => 0,
1318    };
1319
1320    fs_ext4_dirent_t {
1321        inode: e.inode,
1322        file_type,
1323        name_len: copy_len as u8,
1324        name,
1325    }
1326}
1327
1328/// Get the next dir entry. Returns NULL at end or on error.
1329#[no_mangle]
1330pub unsafe extern "C" fn fs_ext4_dir_next(
1331    iter: *mut fs_ext4_dir_iter_t,
1332) -> *const fs_ext4_dirent_t {
1333    ffi_guard(
1334        std::ptr::null(),
1335        AssertUnwindSafe(|| {
1336            if iter.is_null() {
1337                return std::ptr::null();
1338            }
1339            let iter = &mut *iter;
1340            if iter.position >= iter.entries.len() {
1341                return std::ptr::null();
1342            }
1343            // Copy into the iterator's `current` buffer so the returned pointer
1344            // remains valid until the next _dir_next / _dir_close call.
1345            iter.current = fs_ext4_dirent_t {
1346                inode: iter.entries[iter.position].inode,
1347                file_type: iter.entries[iter.position].file_type,
1348                name_len: iter.entries[iter.position].name_len,
1349                name: iter.entries[iter.position].name,
1350            };
1351            iter.position += 1;
1352            &iter.current
1353        }),
1354    )
1355}
1356
1357/// Close a directory iterator.
1358#[no_mangle]
1359pub unsafe extern "C" fn fs_ext4_dir_close(iter: *mut fs_ext4_dir_iter_t) {
1360    ffi_guard(
1361        (),
1362        AssertUnwindSafe(|| {
1363            if !iter.is_null() {
1364                drop(Box::from_raw(iter));
1365            }
1366        }),
1367    )
1368}
1369
1370/// Read bytes from a file. Returns bytes read, or -1 on failure.
1371#[no_mangle]
1372pub unsafe extern "C" fn fs_ext4_read_file(
1373    fs: *mut fs_ext4_fs_t,
1374    path: *const c_char,
1375    buf: *mut c_void,
1376    offset: u64,
1377    length: u64,
1378) -> i64 {
1379    ffi_guard(
1380        -1,
1381        AssertUnwindSafe(|| {
1382            clear_last_error();
1383            if fs.is_null() || path.is_null() || buf.is_null() {
1384                set_err_msg("null fs, path, or buf", EINVAL);
1385                return -1;
1386            }
1387            let fs_ref = &(*fs).fs;
1388            let Some(path_b) = (unsafe { cstr_to_path(path, "path") }) else {
1389                return -1;
1390            };
1391            let path_str = shown(path_b);
1392
1393            let read = resolve_path(fs_ref, path_b)
1394                .and_then(|ino| read_into(fs_ref, ino.into(), buf, offset, length));
1395            match read {
1396                Ok(n) => n,
1397                Err(e) => {
1398                    set_err_from(&e, &format!("read_file {path_str}"));
1399                    -1
1400                }
1401            }
1402        }),
1403    )
1404}
1405
1406/// Read a symlink target into `buf`, NUL-terminated. Returns the target's
1407/// length in bytes (not counting the NUL), as `readlink(2)` does, or -1 on
1408/// failure with the errno in `fs_ext4_last_errno`. A buffer without room
1409/// for the target and its NUL fails with ERANGE and nothing is written:
1410/// never a silent truncation.
1411/// Handles both fast symlinks (target stored inline in i_block, size < 60 bytes)
1412/// and long symlinks (target stored in data blocks); see
1413/// [`Filesystem::read_link`].
1414#[no_mangle]
1415pub unsafe extern "C" fn fs_ext4_readlink(
1416    fs: *mut fs_ext4_fs_t,
1417    path: *const c_char,
1418    buf: *mut c_char,
1419    bufsize: usize,
1420) -> c_int {
1421    ffi_guard(
1422        -1,
1423        AssertUnwindSafe(|| {
1424            clear_last_error();
1425            // A zero bufsize is not an invalid argument, only a buffer too
1426            // small for any target: it reaches the ERANGE check below.
1427            if fs.is_null() || path.is_null() || buf.is_null() {
1428                set_err_msg("null fs/path/buf", EINVAL);
1429                return -1;
1430            }
1431            let fs_ref = &(*fs).fs;
1432            let Some(path_b) = (unsafe { cstr_to_path(path, "path") }) else {
1433                return -1;
1434            };
1435            let path_str = shown(path_b);
1436
1437            let target = match resolve_path(fs_ref, path_b).and_then(|ino| fs_ref.read_link(ino)) {
1438                Ok(t) => t,
1439                Err(e) => {
1440                    set_err_from(&e, &format!("readlink {path_str}"));
1441                    return -1;
1442                }
1443            };
1444
1445            readlink_into(&target, buf, bufsize, &path_str)
1446        }),
1447    )
1448}
1449
1450// ===========================================================================
1451// Extended attributes
1452// ===========================================================================
1453
1454/// List xattr names for a path. NUL-separated, fully-qualified.
1455/// Returns required total bytes (so callers can probe with NULL/0).
1456#[no_mangle]
1457pub unsafe extern "C" fn fs_ext4_listxattr(
1458    fs: *mut fs_ext4_fs_t,
1459    path: *const c_char,
1460    buf: *mut c_char,
1461    bufsize: usize,
1462) -> i64 {
1463    ffi_guard(
1464        -1,
1465        AssertUnwindSafe(|| {
1466            clear_last_error();
1467            if fs.is_null() || path.is_null() {
1468                set_err_msg("null fs or path", EINVAL);
1469                return -1;
1470            }
1471            let fs_ref = &(*fs).fs;
1472            let Some(path_b) = (unsafe { cstr_to_path(path, "path") }) else {
1473                return -1;
1474            };
1475            let path_str = shown(path_b);
1476
1477            let ino = match resolve_path(fs_ref, path_b) {
1478                Ok(n) => n,
1479                Err(e) => {
1480                    set_err_from(&e, &format!("listxattr {path_str}"));
1481                    return -1;
1482                }
1483            };
1484            let (inode, inode_raw) = match fs_ref.read_inode_verified(ino) {
1485                Ok(p) => p,
1486                Err(e) => {
1487                    set_err_from(&e, &format!("read inode {ino}"));
1488                    return -1;
1489                }
1490            };
1491
1492            // Names only: resolving EA-inode values here read every one and
1493            // failed the listing on any unreadable one (#122).
1494            let names = match xattr::list_names(fs_ref, &inode, &inode_raw) {
1495                Ok(v) => v,
1496                Err(e) => {
1497                    set_err_from(&e, &format!("listxattr {path_str}"));
1498                    return -1;
1499                }
1500            };
1501
1502            let required: usize = names.iter().map(|n| n.len() + 1).sum();
1503
1504            if !buf.is_null() && bufsize > 0 {
1505                let out = std::slice::from_raw_parts_mut(buf.cast::<u8>(), bufsize);
1506                let mut pos = 0;
1507                for name in &names {
1508                    let name_bytes = name.as_bytes();
1509                    let needed = name_bytes.len() + 1;
1510                    if pos + needed > bufsize {
1511                        break;
1512                    }
1513                    out[pos..pos + name_bytes.len()].copy_from_slice(name_bytes);
1514                    out[pos + name_bytes.len()] = 0;
1515                    pos += needed;
1516                }
1517            }
1518
1519            required as i64
1520        }),
1521    )
1522}
1523
1524/// Get a single xattr value by fully-qualified name.
1525/// Returns value size (so callers can probe with NULL/0), or -1 if missing / error.
1526#[no_mangle]
1527pub unsafe extern "C" fn fs_ext4_getxattr(
1528    fs: *mut fs_ext4_fs_t,
1529    path: *const c_char,
1530    name: *const c_char,
1531    buf: *mut c_void,
1532    bufsize: usize,
1533) -> i64 {
1534    ffi_guard(
1535        -1,
1536        AssertUnwindSafe(|| {
1537            clear_last_error();
1538            if fs.is_null() || path.is_null() || name.is_null() {
1539                set_err_msg("null fs, path, or name", EINVAL);
1540                return -1;
1541            }
1542            let fs_ref = &(*fs).fs;
1543            let Some(path_b) = (unsafe { cstr_to_path(path, "path") }) else {
1544                return -1;
1545            };
1546            let path_str = shown(path_b);
1547            let Some(name_str) = (unsafe { cstr_to_name(name, "name") }) else {
1548                return -1;
1549            };
1550
1551            let ino = match resolve_path(fs_ref, path_b) {
1552                Ok(n) => n,
1553                Err(e) => {
1554                    set_err_from(&e, &format!("getxattr {path_str}"));
1555                    return -1;
1556                }
1557            };
1558            let (inode, inode_raw) = match fs_ref.read_inode_verified(ino) {
1559                Ok(p) => p,
1560                Err(e) => {
1561                    set_err_from(&e, &format!("read inode {ino}"));
1562                    return -1;
1563                }
1564            };
1565
1566            let value = match xattr::get_resolved(fs_ref, &inode, &inode_raw, name_str) {
1567                Ok(Some(v)) => v,
1568                Ok(None) => {
1569                    set_err_msg(
1570                        &format!("getxattr {path_str}: {name_str} not found"),
1571                        ENOENT,
1572                    );
1573                    return -1;
1574                }
1575                Err(e) => {
1576                    set_err_from(&e, &format!("getxattr {path_str} {name_str}"));
1577                    return -1;
1578                }
1579            };
1580
1581            if !buf.is_null() && bufsize > 0 {
1582                let copy_len = value.len().min(bufsize);
1583                let out = std::slice::from_raw_parts_mut(buf as *mut u8, bufsize);
1584                out[..copy_len].copy_from_slice(&value[..copy_len]);
1585            }
1586
1587            value.len() as i64
1588        }),
1589    )
1590}
1591
1592// ===========================================================================
1593// Write path — Phase 4 surface. Currently only truncate-shrink is wired;
1594// create/unlink/write_file follow as the write path matures.
1595// ===========================================================================
1596
1597/// Truncate a file to `new_size`. Only valid when the device was mounted
1598/// R/W (e.g. via `fs_ext4_mount_rw`). Returns 0 on success, -1 on failure
1599/// with details in `fs_ext4_last_error`.
1600///
1601/// Both directions are supported:
1602/// - **Shrink** (`new_size < inode.size`): frees the dropped extents,
1603///   updates block-bitmap + BGD + SB counters, patches i_size +
1604///   i_blocks + inode csum.
1605/// - **Sparse grow** (`new_size >= inode.size`): pure metadata update;
1606///   ext4's extent tree treats unmapped logical blocks as zero-filled
1607///   holes, so no block allocation happens. Only i_size, i_mtime,
1608///   i_ctime, and the inode checksum change.
1609///
1610/// Refuses directories (POSIX EISDIR); refuses symlinks and special
1611/// files (EINVAL).
1612#[no_mangle]
1613pub unsafe extern "C" fn fs_ext4_truncate(
1614    fs: *mut fs_ext4_fs_t,
1615    path: *const c_char,
1616    new_size: u64,
1617) -> c_int {
1618    ffi_guard(
1619        -1,
1620        AssertUnwindSafe(|| {
1621            clear_last_error();
1622            if fs.is_null() || path.is_null() {
1623                set_err_msg("null fs/path", EINVAL);
1624                return -1;
1625            }
1626            let fs_ref = &(*fs).fs;
1627            let Some(path_b) = (unsafe { cstr_to_path(path, "path") }) else {
1628                return -1;
1629            };
1630            let path_str = shown(path_b);
1631            // The type guard (EISDIR for a directory, EINVAL for anything
1632            // else that is not a regular file) is in `apply_truncate_ino`.
1633            let res = resolve_path(fs_ref, path_b)
1634                .and_then(|ino| fs_ref.apply_truncate_ino(ino, new_size));
1635            match res {
1636                Ok(()) => 0,
1637                Err(e) => {
1638                    set_err_from(&e, &format!("truncate {path_str} -> {new_size}"));
1639                    -1
1640                }
1641            }
1642        }),
1643    )
1644}
1645
1646/// Linux `fallocate(2)` mode flags. KEEP_SIZE, PUNCH_HOLE, and
1647/// ZERO_RANGE are implemented; COLLAPSE_RANGE / INSERT_RANGE return
1648/// ENOSYS.
1649pub const FS_EXT4_FALLOC_FL_KEEP_SIZE: c_int = 0x01;
1650pub const FS_EXT4_FALLOC_FL_PUNCH_HOLE: c_int = 0x02;
1651pub const FS_EXT4_FALLOC_FL_ZERO_RANGE: c_int = 0x10;
1652
1653/// `fallocate(path, offset, len, flags)` — preallocate blocks, punch a
1654/// hole, or zero a range without writing actual data.
1655///
1656/// Supported `flags` combinations:
1657/// - `KEEP_SIZE` alone — preallocate blocks as uninitialized extents
1658///   (reads return zeros) without changing `i_size`.
1659/// - `PUNCH_HOLE | KEEP_SIZE` — free the data blocks underlying the
1660///   range; reads return zeros (sparse hole) thereafter. Linux
1661///   requires both bits; we follow the same convention.
1662/// - `ZERO_RANGE` (with or without `KEEP_SIZE`) — punch then re-allocate
1663///   as uninitialized extents in one call. `i_size` is preserved.
1664///
1665/// Anything else returns ENOSYS (78). v1 limits documented per
1666/// `Filesystem::apply_fallocate_punch_hole` and `apply_fallocate_keep_size`.
1667#[no_mangle]
1668pub unsafe extern "C" fn fs_ext4_fallocate(
1669    fs: *mut fs_ext4_fs_t,
1670    path: *const c_char,
1671    offset: u64,
1672    len: u64,
1673    flags: c_int,
1674) -> c_int {
1675    ffi_guard(
1676        -1,
1677        AssertUnwindSafe(|| {
1678            clear_last_error();
1679            if fs.is_null() || path.is_null() {
1680                set_err_msg("null fs/path", EINVAL);
1681                return -1;
1682            }
1683            let fs_ref = &(*fs).fs;
1684            let Some(path_b) = (unsafe { cstr_to_path(path, "path") }) else {
1685                return -1;
1686            };
1687            let path_str = shown(path_b);
1688            let ino = match resolve_path(fs_ref, path_b) {
1689                Ok(n) => n,
1690                Err(e) => {
1691                    set_err_from(&e, &format!("fallocate {path_str}"));
1692                    return -1;
1693                }
1694            };
1695            // Flag dispatch. PUNCH_HOLE always implies KEEP_SIZE (Linux
1696            // requires both); we accept either spelling.
1697            let is_punch = flags & FS_EXT4_FALLOC_FL_PUNCH_HOLE != 0;
1698            let is_zero = flags & FS_EXT4_FALLOC_FL_ZERO_RANGE != 0;
1699            let is_keep = flags & FS_EXT4_FALLOC_FL_KEEP_SIZE != 0;
1700            let result = if is_zero {
1701                fs_ref.apply_fallocate_zero_range(ino, offset, len)
1702            } else if is_punch {
1703                if !is_keep {
1704                    set_err_msg("fallocate: PUNCH_HOLE requires KEEP_SIZE", EINVAL);
1705                    return -1;
1706                }
1707                fs_ref.apply_fallocate_punch_hole(ino, offset, len)
1708            } else if flags == FS_EXT4_FALLOC_FL_KEEP_SIZE {
1709                fs_ref.apply_fallocate_keep_size(ino, offset, len)
1710            } else {
1711                set_err_msg(
1712                    "fallocate: unsupported flag combination (KEEP_SIZE / PUNCH_HOLE+KEEP_SIZE / ZERO_RANGE only)",
1713                    ENOSYS,
1714                );
1715                return -1;
1716            };
1717            match result {
1718                Ok(()) => 0,
1719                Err(e) => {
1720                    set_err_from(&e, &format!("fallocate {path_str} @{offset}+{len}"));
1721                    -1
1722                }
1723            }
1724        }),
1725    )
1726}
1727
1728/// Remove a file entry at `path`. Requires a R/W mount.
1729///
1730/// Refuses directories (caller should use a future `fs_ext4_rmdir`).
1731/// Decrements `i_links_count`; if the count reaches zero the inode's data
1732/// blocks are freed, its bitmap slot is cleared, and the inode body is
1733/// zeroed with `i_dtime = now` (matching the kernel's unlink convention).
1734///
1735/// Returns 0 on success, -1 on failure with details in
1736/// `fs_ext4_last_error`.
1737#[no_mangle]
1738pub unsafe extern "C" fn fs_ext4_unlink(fs: *mut fs_ext4_fs_t, path: *const c_char) -> c_int {
1739    ffi_guard(
1740        -1,
1741        AssertUnwindSafe(|| {
1742            clear_last_error();
1743            if fs.is_null() || path.is_null() {
1744                set_err_msg("null fs/path", EINVAL);
1745                return -1;
1746            }
1747            let fs_ref = &(*fs).fs;
1748            let Some(path_b) = (unsafe { cstr_to_path(path, "path") }) else {
1749                return -1;
1750            };
1751            let path_str = shown(path_b);
1752            match fs_ref.apply_unlink_bytes(path_b) {
1753                Ok(()) => 0,
1754                Err(e) => {
1755                    set_err_from(&e, &format!("unlink {path_str}"));
1756                    -1
1757                }
1758            }
1759        }),
1760    )
1761}
1762
1763/// Create a new empty regular file at `path` with permission bits `mode`
1764/// (e.g. 0o644). Parent must exist and be a directory; path must not already
1765/// exist. Returns the allocated inode number on success (> 0), or 0 on failure
1766/// with details in `fs_ext4_last_error`.
1767///
1768/// We return `u32` rather than `c_int` so Swift sees a plain uint32_t —
1769/// matches the convention for other inode-returning exports.
1770#[no_mangle]
1771pub unsafe extern "C" fn fs_ext4_create(
1772    fs: *mut fs_ext4_fs_t,
1773    path: *const c_char,
1774    mode: u16,
1775) -> u32 {
1776    ffi_guard(
1777        0u32,
1778        AssertUnwindSafe(|| {
1779            clear_last_error();
1780            if fs.is_null() || path.is_null() {
1781                set_err_msg("null fs/path", EINVAL);
1782                return 0u32;
1783            }
1784            let fs_ref = &(*fs).fs;
1785            let Some(path_b) = (unsafe { cstr_to_path(path, "path") }) else {
1786                return 0u32;
1787            };
1788            let path_str = shown(path_b);
1789            match fs_ref.apply_create_bytes(path_b, mode) {
1790                Ok(ino) => ino,
1791                Err(e) => {
1792                    set_err_from(&e, &format!("create {path_str}"));
1793                    0u32
1794                }
1795            }
1796        }),
1797    )
1798}
1799
1800/// Replace the content of `path` with `len` bytes from `data`. The file
1801/// must already exist. Frees every existing extent, allocates one
1802/// contiguous run for the new bytes, writes the payload (zero-padded in
1803/// the last block), and updates size/mtime. Returns the new size on
1804/// success or -1 on failure.
1805///
1806/// This is the "save-as" path: atomic replacement of a file's body.
1807/// Appends, sparse writes, and partial overwrites are follow-up work.
1808#[no_mangle]
1809pub unsafe extern "C" fn fs_ext4_write_file(
1810    fs: *mut fs_ext4_fs_t,
1811    path: *const c_char,
1812    data: *const c_void,
1813    len: u64,
1814) -> i64 {
1815    ffi_guard(
1816        -1i64,
1817        AssertUnwindSafe(|| {
1818            clear_last_error();
1819            if fs.is_null() || path.is_null() {
1820                set_err_msg("null fs/path", EINVAL);
1821                return -1;
1822            }
1823            if data.is_null() && len > 0 {
1824                set_err_msg("null data with non-zero len", EINVAL);
1825                return -1;
1826            }
1827            // Hard cap to defang a hostile or buggy caller passing
1828            // `len = u64::MAX`. Constructing `&[u8]` from raw parts with
1829            // a length larger than the caller's actual buffer is UB even
1830            // before any read happens. 1 GiB matches the apply_replace
1831            // working assumption (whole payload held in memory) without
1832            // forcing legitimate large writes to chunk.
1833            const MAX_WRITE_LEN: u64 = 1 << 30;
1834            if len > MAX_WRITE_LEN {
1835                set_err_msg(
1836                    &format!("write_file: len {len} exceeds {MAX_WRITE_LEN}"),
1837                    EINVAL,
1838                );
1839                return -1;
1840            }
1841            let fs_ref = &(*fs).fs;
1842            let Some(path_b) = (unsafe { cstr_to_path(path, "path") }) else {
1843                return -1i64;
1844            };
1845            let path_str = shown(path_b);
1846            // Type guard at the capi level — mirrors fs_ext4_truncate so the
1847            // caller gets EISDIR/EINVAL instead of Error::Corrupt → EIO when
1848            // the target is the wrong kind of file.
1849            let ino = match resolve_path(fs_ref, path_b) {
1850                Ok(n) => n,
1851                Err(e) => {
1852                    set_err_from(&e, &format!("write_file {path_str}"));
1853                    return -1;
1854                }
1855            };
1856            let inode = match fs_ref.read_inode_verified(ino) {
1857                Ok((i, _)) => i,
1858                Err(e) => {
1859                    set_err_from(&e, &format!("read inode {ino}"));
1860                    return -1;
1861                }
1862            };
1863            if inode.is_dir() {
1864                set_err_msg(&format!("write_file {path_str}: is a directory"), EISDIR);
1865                return -1;
1866            }
1867            if !inode.is_file() {
1868                set_err_msg(
1869                    &format!("write_file {path_str}: not a regular file"),
1870                    EINVAL,
1871                );
1872                return -1;
1873            }
1874            let slice: &[u8] = if len == 0 {
1875                &[]
1876            } else {
1877                std::slice::from_raw_parts(data as *const u8, len as usize)
1878            };
1879            match fs_ref.apply_replace_file_content_bytes(path_b, slice) {
1880                Ok(new_size) => new_size as i64,
1881                Err(e) => {
1882                    set_err_from(&e, &format!("write_file {path_str} ({len} bytes)"));
1883                    -1
1884                }
1885            }
1886        }),
1887    )
1888}
1889
1890/// Positional write: splice `len` bytes from `data` into `path` at byte
1891/// `offset`. Allocates new physical blocks for any logical blocks not yet
1892/// mapped (sparse holes / past EOF). Existing mapped blocks are read-
1893/// modify-written for partial overlap; full-block writes go in fresh.
1894///
1895/// This is the streaming-write primitive — it costs O(len), not
1896/// O(filesize). The streaming write paths in FUSE / WinFsp / FSKit
1897/// adapters should use this instead of `fs_ext4_write_file` (which is
1898/// the "save-as" / whole-file-replace primitive).
1899///
1900/// Returns the new file size on success, -1 on failure (errno via
1901/// `fs_ext4_last_errno()`, message via `fs_ext4_last_error()`). The file
1902/// must already exist; create-then-pwrite is the standard streaming
1903/// pattern.
1904///
1905/// Hard cap on `len`: 1 GiB per call (matches `fs_ext4_write_file`).
1906/// Streaming writers should chunk above that — typical FS adapters
1907/// dispatch at most ~1 MiB anyway.
1908///
1909/// v1 limitations are documented on `Filesystem::apply_pwrite`.
1910#[no_mangle]
1911pub unsafe extern "C" fn fs_ext4_pwrite(
1912    fs: *mut fs_ext4_fs_t,
1913    path: *const c_char,
1914    data: *const c_void,
1915    len: u64,
1916    offset: u64,
1917) -> i64 {
1918    ffi_guard(
1919        -1i64,
1920        AssertUnwindSafe(|| {
1921            clear_last_error();
1922            if fs.is_null() || path.is_null() {
1923                set_err_msg("null fs/path", EINVAL);
1924                return -1;
1925            }
1926            let slice = match write_arg(data, len, offset) {
1927                Ok(s) => s,
1928                Err(e) => {
1929                    set_err_from(&e, "pwrite");
1930                    return -1;
1931                }
1932            };
1933            let fs_ref = &(*fs).fs;
1934            let Some(path_b) = (unsafe { cstr_to_path(path, "path") }) else {
1935                return -1i64;
1936            };
1937            let path_str = shown(path_b);
1938            match fs_ref.apply_pwrite_bytes(path_b, offset, slice) {
1939                Ok(new_size) => new_size as i64,
1940                Err(e) => {
1941                    set_err_from(&e, &format!("pwrite {path_str} @{offset}+{len}"));
1942                    -1
1943                }
1944            }
1945        }),
1946    )
1947}
1948
1949/// The caller's buffer for a positional write, checked before a slice is
1950/// built from it. Shared by `fs_ext4_pwrite` and `fs_ext4_pwrite_ino`.
1951unsafe fn write_arg<'a>(data: *const c_void, len: u64, offset: u64) -> Result<&'a [u8]> {
1952    if data.is_null() && len > 0 {
1953        return Err(Error::InvalidArgument("null data with non-zero len"));
1954    }
1955    // Same hard cap as `fs_ext4_write_file` to defang a hostile caller
1956    // passing `len = u64::MAX` — constructing `&[u8]` from raw parts with a
1957    // length larger than the caller's buffer is UB even before any read
1958    // happens.
1959    const MAX_PWRITE_LEN: u64 = 1 << 30;
1960    if len > MAX_PWRITE_LEN {
1961        return Err(Error::InvalidArgument("len exceeds the 1 GiB per-call cap"));
1962    }
1963    // offset+len overflow: caught here so the error names the arguments
1964    // rather than surfacing from deep inside the write.
1965    if offset.checked_add(len).is_none() {
1966        return Err(Error::InvalidArgument("offset+len overflow"));
1967    }
1968    Ok(if len == 0 {
1969        &[]
1970    } else {
1971        std::slice::from_raw_parts(data as *const u8, len as usize)
1972    })
1973}
1974
1975/// Mount an ext4 filesystem read-write. Companion to `fs_ext4_mount`.
1976/// Returns NULL on failure. A successful mount will replay a dirty journal
1977/// before returning.
1978#[no_mangle]
1979pub unsafe extern "C" fn fs_ext4_mount_rw(device_path: *const c_char) -> *mut fs_ext4_fs_t {
1980    ffi_guard(
1981        std::ptr::null_mut(),
1982        AssertUnwindSafe(|| {
1983            clear_last_error();
1984            let path = cstr_to_str(device_path);
1985            if path.is_empty() {
1986                set_err_msg("null or empty device_path", EINVAL);
1987                return std::ptr::null_mut();
1988            }
1989            let dev = match FileDevice::open_rw(path) {
1990                Ok(d) => Arc::new(d) as Arc<dyn BlockDevice>,
1991                Err(e) => {
1992                    set_err_from(&e, &format!("open_rw {path}"));
1993                    return std::ptr::null_mut();
1994                }
1995            };
1996            match Filesystem::mount(dev) {
1997                Ok(fs) => Box::into_raw(Box::new(fs_ext4_fs_t { fs })),
1998                Err(e) => {
1999                    set_err_from(&e, &format!("mount_rw {path}"));
2000                    std::ptr::null_mut()
2001                }
2002            }
2003        }),
2004    )
2005}
2006
2007/// Create a hard link at `dst` pointing at the same inode as `src`.
2008/// Source must not be a directory; dest must not already exist; dest's
2009/// parent must be a directory. Increments the shared inode's
2010/// `i_links_count`. Returns 0 on success, -1 on failure.
2011#[no_mangle]
2012pub unsafe extern "C" fn fs_ext4_link(
2013    fs: *mut fs_ext4_fs_t,
2014    src: *const c_char,
2015    dst: *const c_char,
2016) -> c_int {
2017    ffi_guard(
2018        -1,
2019        AssertUnwindSafe(|| {
2020            clear_last_error();
2021            if fs.is_null() || src.is_null() || dst.is_null() {
2022                set_err_msg("null fs/src/dst", EINVAL);
2023                return -1;
2024            }
2025            let fs_ref = &(*fs).fs;
2026            let Some(src_b) = (unsafe { cstr_to_path(src, "src") }) else {
2027                return -1;
2028            };
2029            let src_str = shown(src_b);
2030            let Some(dst_b) = (unsafe { cstr_to_path(dst, "dst") }) else {
2031                return -1;
2032            };
2033            let dst_str = shown(dst_b);
2034            match fs_ref.apply_link_bytes(src_b, dst_b) {
2035                Ok(()) => 0,
2036                Err(e) => {
2037                    set_err_from(&e, &format!("link {src_str} -> {dst_str}"));
2038                    -1
2039                }
2040            }
2041        }),
2042    )
2043}
2044
2045/// Rename / move `src` → `dst` within this mount. Works for files and
2046/// directories; cross-parent moves fix the moved dir's `..` entry and
2047/// adjust both parents' `i_links_count`. Dest must NOT already exist —
2048/// for atomic overwrite use `fs_ext4_rename2` with the
2049/// `FS_EXT4_RENAME_REPLACE` flag. Returns 0 on success, -1 on failure.
2050#[no_mangle]
2051pub unsafe extern "C" fn fs_ext4_rename(
2052    fs: *mut fs_ext4_fs_t,
2053    src: *const c_char,
2054    dst: *const c_char,
2055) -> c_int {
2056    ffi_guard(
2057        -1,
2058        AssertUnwindSafe(|| {
2059            clear_last_error();
2060            if fs.is_null() || src.is_null() || dst.is_null() {
2061                set_err_msg("null fs/src/dst", EINVAL);
2062                return -1;
2063            }
2064            let fs_ref = &(*fs).fs;
2065            let Some(src_b) = (unsafe { cstr_to_path(src, "src") }) else {
2066                return -1;
2067            };
2068            let src_str = shown(src_b);
2069            let Some(dst_b) = (unsafe { cstr_to_path(dst, "dst") }) else {
2070                return -1;
2071            };
2072            let dst_str = shown(dst_b);
2073            match fs_ref.apply_rename_bytes(src_b, dst_b, false) {
2074                Ok(()) => 0,
2075                Err(e) => {
2076                    set_err_from(&e, &format!("rename {src_str} -> {dst_str}"));
2077                    -1
2078                }
2079            }
2080        }),
2081    )
2082}
2083
2084/// `fs_ext4_rename2` flag bits. `FS_EXT4_RENAME_REPLACE` enables
2085/// atomic overwrite of an existing destination — required to support
2086/// "Save As" / drag-drop-onto-existing on Windows / FUSE callers that
2087/// pass the equivalent of `RENAME_EXCHANGE`'s "replace" semantics.
2088/// Unknown flag bits are rejected with EINVAL so future flag additions
2089/// stay forward-compatible.
2090pub const FS_EXT4_RENAME_REPLACE: c_int = 0x01;
2091
2092/// Whether `flags` asks for replacement. Any flag bit we don't define is
2093/// rejected, so future additions can be detected by callers via EINVAL
2094/// probing. Shared by `fs_ext4_rename2` and `fs_ext4_rename_at`.
2095fn rename_flags(flags: c_int) -> Result<bool> {
2096    if flags & !FS_EXT4_RENAME_REPLACE != 0 {
2097        return Err(Error::InvalidArgument("unknown rename flag bits"));
2098    }
2099    Ok(flags & FS_EXT4_RENAME_REPLACE != 0)
2100}
2101
2102/// Rename / move `src` → `dst` within this mount, with explicit flags.
2103/// Currently the only flag is `FS_EXT4_RENAME_REPLACE` — when set, an
2104/// existing destination is atomically replaced (POSIX `rename(2)`
2105/// semantics): file→file overwrites the old file's data and frees its
2106/// inode (or decrements link count on a hardlinked target), empty-dir
2107/// → empty-dir overwrites the dropped subdir, and crossing the
2108/// file/directory boundary returns `EISDIR` / `ENOTDIR`. Without the
2109/// flag, behaves identically to `fs_ext4_rename`. Returns 0 on success,
2110/// -1 on failure.
2111#[no_mangle]
2112pub unsafe extern "C" fn fs_ext4_rename2(
2113    fs: *mut fs_ext4_fs_t,
2114    src: *const c_char,
2115    dst: *const c_char,
2116    flags: c_int,
2117) -> c_int {
2118    ffi_guard(
2119        -1,
2120        AssertUnwindSafe(|| {
2121            clear_last_error();
2122            if fs.is_null() || src.is_null() || dst.is_null() {
2123                set_err_msg("null fs/src/dst", EINVAL);
2124                return -1;
2125            }
2126            let replace = match rename_flags(flags) {
2127                Ok(r) => r,
2128                Err(e) => {
2129                    set_err_from(&e, "rename2");
2130                    return -1;
2131                }
2132            };
2133            let fs_ref = &(*fs).fs;
2134            let Some(src_b) = (unsafe { cstr_to_path(src, "src") }) else {
2135                return -1;
2136            };
2137            let src_str = shown(src_b);
2138            let Some(dst_b) = (unsafe { cstr_to_path(dst, "dst") }) else {
2139                return -1;
2140            };
2141            let dst_str = shown(dst_b);
2142            match fs_ref.apply_rename_bytes(src_b, dst_b, replace) {
2143                Ok(()) => 0,
2144                Err(e) => {
2145                    set_err_from(&e, &format!("rename2 {src_str} -> {dst_str}"));
2146                    -1
2147                }
2148            }
2149        }),
2150    )
2151}
2152
2153/// Create a subdirectory at `path` with POSIX permission bits `mode` (low
2154/// 12 bits used; file-type bits are set automatically). Returns the new
2155/// directory's inode number on success, 0 on failure.
2156#[no_mangle]
2157pub unsafe extern "C" fn fs_ext4_mkdir(
2158    fs: *mut fs_ext4_fs_t,
2159    path: *const c_char,
2160    mode: u16,
2161) -> u32 {
2162    ffi_guard(
2163        0u32,
2164        AssertUnwindSafe(|| {
2165            clear_last_error();
2166            if fs.is_null() || path.is_null() {
2167                set_err_msg("null fs/path", EINVAL);
2168                return 0u32;
2169            }
2170            let fs_ref = &(*fs).fs;
2171            let Some(path_b) = (unsafe { cstr_to_path(path, "path") }) else {
2172                return 0u32;
2173            };
2174            let path_str = shown(path_b);
2175            match fs_ref.apply_mkdir_bytes(path_b, mode) {
2176                Ok(ino) => ino,
2177                Err(e) => {
2178                    set_err_from(&e, &format!("mkdir {path_str}"));
2179                    0u32
2180                }
2181            }
2182        }),
2183    )
2184}
2185
2186/// Remove an empty directory at `path`. Fails if the directory contains
2187/// entries other than `.` and `..`. Returns 0 on success, -1 on failure
2188/// with details in `fs_ext4_last_error`.
2189#[no_mangle]
2190pub unsafe extern "C" fn fs_ext4_rmdir(fs: *mut fs_ext4_fs_t, path: *const c_char) -> c_int {
2191    ffi_guard(
2192        -1,
2193        AssertUnwindSafe(|| {
2194            clear_last_error();
2195            if fs.is_null() || path.is_null() {
2196                set_err_msg("null fs/path", EINVAL);
2197                return -1;
2198            }
2199            let fs_ref = &(*fs).fs;
2200            let Some(path_b) = (unsafe { cstr_to_path(path, "path") }) else {
2201                return -1;
2202            };
2203            let path_str = shown(path_b);
2204            match fs_ref.apply_rmdir_bytes(path_b) {
2205                Ok(()) => 0,
2206                Err(e) => {
2207                    set_err_from(&e, &format!("rmdir {path_str}"));
2208                    -1
2209                }
2210            }
2211        }),
2212    )
2213}
2214
2215/// Change the permission bits on `path`. Only the low 12 bits of `mode`
2216/// (suid/sgid/sticky + rwx/rwx/rwx) are applied; file-type bits (`S_IFMT`)
2217/// are preserved from the existing inode. Bumps `i_ctime`.
2218///
2219/// Returns 0 on success, -1 on failure with details in `fs_ext4_last_error`.
2220#[no_mangle]
2221pub unsafe extern "C" fn fs_ext4_chmod(
2222    fs: *mut fs_ext4_fs_t,
2223    path: *const c_char,
2224    mode: u16,
2225) -> c_int {
2226    ffi_guard(
2227        -1,
2228        AssertUnwindSafe(|| {
2229            clear_last_error();
2230            if fs.is_null() || path.is_null() {
2231                set_err_msg("null fs/path", EINVAL);
2232                return -1;
2233            }
2234            let fs_ref = &(*fs).fs;
2235            let Some(path_b) = (unsafe { cstr_to_path(path, "path") }) else {
2236                return -1;
2237            };
2238            let path_str = shown(path_b);
2239            match fs_ref.apply_chmod_bytes(path_b, mode) {
2240                Ok(()) => 0,
2241                Err(e) => {
2242                    set_err_from(&e, &format!("chmod {path_str}"));
2243                    -1
2244                }
2245            }
2246        }),
2247    )
2248}
2249
2250/// Change the owner of `path` to (`uid`, `gid`). Passing `u32::MAX` (0xFFFF_FFFF)
2251/// for either parameter leaves that value unchanged (matches Linux chown(2)
2252/// "-1 means leave alone" convention). Bumps `i_ctime`.
2253///
2254/// Returns 0 on success, -1 on failure with details in `fs_ext4_last_error`.
2255#[no_mangle]
2256pub unsafe extern "C" fn fs_ext4_chown(
2257    fs: *mut fs_ext4_fs_t,
2258    path: *const c_char,
2259    uid: u32,
2260    gid: u32,
2261) -> c_int {
2262    ffi_guard(
2263        -1,
2264        AssertUnwindSafe(|| {
2265            clear_last_error();
2266            if fs.is_null() || path.is_null() {
2267                set_err_msg("null fs/path", EINVAL);
2268                return -1;
2269            }
2270            let fs_ref = &(*fs).fs;
2271            let Some(path_b) = (unsafe { cstr_to_path(path, "path") }) else {
2272                return -1;
2273            };
2274            let path_str = shown(path_b);
2275            match fs_ref.apply_chown_bytes(path_b, uid, gid) {
2276                Ok(()) => 0,
2277                Err(e) => {
2278                    set_err_from(&e, &format!("chown {path_str}"));
2279                    -1
2280                }
2281            }
2282        }),
2283    )
2284}
2285
2286/// Create a special file: FIFO, socket, or character/block device.
2287///
2288/// `mode` must include the file-type bits plus permission bits, e.g.:
2289///   `0x1000 | 0644` for a FIFO (S_IFIFO), `0xC000 | 0666` for a socket,
2290///   `0x2000 | 0660` for a char device, `0x6000 | 0660` for a block device.
2291///
2292/// `major` / `minor` are device numbers for char/block devices; pass 0 for
2293/// FIFOs and sockets. Returns the new inode number on success, 0 on failure.
2294#[no_mangle]
2295pub unsafe extern "C" fn fs_ext4_mknod(
2296    fs: *mut fs_ext4_fs_t,
2297    path: *const c_char,
2298    mode: u16,
2299    major: u32,
2300    minor: u32,
2301) -> u32 {
2302    ffi_guard(
2303        0u32,
2304        AssertUnwindSafe(|| {
2305            clear_last_error();
2306            if fs.is_null() || path.is_null() {
2307                set_err_msg("null fs/path", EINVAL);
2308                return 0u32;
2309            }
2310            let fs_ref = &(*fs).fs;
2311            let Some(path_b) = (unsafe { cstr_to_path(path, "path") }) else {
2312                return 0u32;
2313            };
2314            let path_str = shown(path_b);
2315            match fs_ref.apply_mknod_bytes(path_b, mode, major, minor) {
2316                Ok(ino) => ino,
2317                Err(e) => {
2318                    set_err_from(&e, &format!("mknod {path_str}"));
2319                    0u32
2320                }
2321            }
2322        }),
2323    )
2324}
2325
2326/// Set the `i_flags` word (FS_IOC_SETFLAGS) on `path`.
2327///
2328/// `flags` is the full new flags value. Common flags:
2329///   0x00000010  EXT4_IMMUTABLE_FL (cannot modify / rename / delete)
2330///   0x00000020  EXT4_APPEND_FL    (append-only)
2331///   0x00000040  EXT4_NODUMP_FL    (excluded from `dump`)
2332///   0x00000200  EXT4_NOATIME_FL   (no atime updates on read)
2333///
2334/// Bumps i_ctime. Returns 0 on success, -1 on failure.
2335#[no_mangle]
2336pub unsafe extern "C" fn fs_ext4_set_flags(
2337    fs: *mut fs_ext4_fs_t,
2338    path: *const c_char,
2339    flags: u32,
2340) -> c_int {
2341    ffi_guard(
2342        -1,
2343        AssertUnwindSafe(|| {
2344            clear_last_error();
2345            if fs.is_null() || path.is_null() {
2346                set_err_msg("null fs/path", EINVAL);
2347                return -1;
2348            }
2349            let fs_ref = &(*fs).fs;
2350            let Some(path_b) = (unsafe { cstr_to_path(path, "path") }) else {
2351                return -1;
2352            };
2353            let path_str = shown(path_b);
2354            match fs_ref.apply_set_flags_bytes(path_b, flags) {
2355                Ok(()) => 0,
2356                Err(e) => {
2357                    set_err_from(&e, &format!("set_flags {path_str}"));
2358                    -1
2359                }
2360            }
2361        }),
2362    )
2363}
2364
2365/// Set the access + modification times on `path`. Each `*_sec` is the
2366/// POSIX seconds-since-epoch, signed and 64-bit to match what the format
2367/// stores; pass `FS_EXT4_TIME_OMIT` (`INT64_MIN`) to leave a given pair
2368/// unchanged. `*_nsec` are the sub-second nanoseconds (written only when
2369/// i_extra_isize covers them), below 1e9, or one of `utimensat(2)`'s
2370/// sentinels `FS_EXT4_UTIME_NOW` / `FS_EXT4_UTIME_OMIT` (Linux's values),
2371/// honoured per field. Bumps i_ctime unless both pairs are omitted.
2372///
2373/// Widened from `uint32_t` in 0.5.0. The old type could not express a
2374/// pre-1970 date, and its `UINT32_MAX` sentinel collided with a real
2375/// date in 2106; more to the point, a caller passing a post-2038 value
2376/// got it stored without the epoch-extension bits and read it back 136
2377/// years early.
2378///
2379/// Returns 0 on success, -1 on failure with details in
2380/// `fs_ext4_last_error`. Fails with EINVAL for a time outside the range
2381/// ext4 can store, one needing the epoch bits on an inode too small
2382/// to carry an `*_extra` field, or any other `*_nsec` of 1e9 or more.
2383#[no_mangle]
2384pub unsafe extern "C" fn fs_ext4_utimens(
2385    fs: *mut fs_ext4_fs_t,
2386    path: *const c_char,
2387    atime_sec: i64,
2388    atime_nsec: u32,
2389    mtime_sec: i64,
2390    mtime_nsec: u32,
2391) -> c_int {
2392    ffi_guard(
2393        -1,
2394        AssertUnwindSafe(|| {
2395            clear_last_error();
2396            if fs.is_null() || path.is_null() {
2397                set_err_msg("null fs/path", EINVAL);
2398                return -1;
2399            }
2400            let fs_ref = &(*fs).fs;
2401            let Some(path_b) = (unsafe { cstr_to_path(path, "path") }) else {
2402                return -1;
2403            };
2404            let path_str = shown(path_b);
2405            match fs_ref.apply_utimens_bytes(path_b, atime_sec, atime_nsec, mtime_sec, mtime_nsec) {
2406                Ok(()) => 0,
2407                Err(e) => {
2408                    set_err_from(&e, &format!("utimens {path_str}"));
2409                    -1
2410                }
2411            }
2412        }),
2413    )
2414}
2415
2416/// Create a symbolic link at `linkpath` whose target is `target`. POSIX
2417/// `symlink(target, linkpath)` semantics: `target` is the arbitrary string
2418/// the symlink points to (can be absolute or relative, need not exist);
2419/// `linkpath` is the path where the symlink is created. Parent of
2420/// `linkpath` must exist and be a directory; `linkpath` itself must not
2421/// already exist.
2422///
2423/// Targets <60 bytes use the fast-symlink path (stored inline in the
2424/// inode's i_block area). Targets >=60 and <=4096 bytes use the slow path
2425/// (one fs block allocated, target stored there, single extent inserted).
2426/// Targets longer than 4096 (Linux PATH_MAX) return 0 with errno set to
2427/// ENAMETOOLONG via `fs_ext4_last_error`.
2428///
2429/// Returns the new inode number on success (> 0), or 0 on failure with
2430/// details in `fs_ext4_last_error`.
2431#[no_mangle]
2432pub unsafe extern "C" fn fs_ext4_symlink(
2433    fs: *mut fs_ext4_fs_t,
2434    target: *const c_char,
2435    linkpath: *const c_char,
2436) -> u32 {
2437    ffi_guard(
2438        0u32,
2439        AssertUnwindSafe(|| {
2440            clear_last_error();
2441            if fs.is_null() || target.is_null() || linkpath.is_null() {
2442                set_err_msg("null fs/target/linkpath", EINVAL);
2443                return 0u32;
2444            }
2445            // Both are bytes, never decoded, and each is capped at
2446            // FFI_PATH_MAX with ENAMETOOLONG by `cstr_to_path`: a target
2447            // is arbitrary bytes the kernel stores as given.
2448            let fs_ref = &(*fs).fs;
2449            let Some(target_b) = (unsafe { cstr_to_path(target, "target") }) else {
2450                return 0u32;
2451            };
2452            let target_str = shown(target_b);
2453            let Some(linkpath_b) = (unsafe { cstr_to_path(linkpath, "linkpath") }) else {
2454                return 0u32;
2455            };
2456            let linkpath_str = shown(linkpath_b);
2457            match fs_ref.apply_symlink_bytes(target_b, linkpath_b) {
2458                Ok(ino) => ino,
2459                Err(e) => {
2460                    set_err_from(&e, &format!("symlink {linkpath_str} -> {target_str}"));
2461                    0u32
2462                }
2463            }
2464        }),
2465    )
2466}
2467
2468/// Remove the extended attribute `name` from the inode at `path`. `name`
2469/// must be fully-qualified (carry a known namespace prefix like `"user."`
2470/// or `"security."`). v1 scope: in-inode xattrs only; external-block
2471/// removal surfaces EINVAL until the slow path lands.
2472///
2473/// Returns 0 on success, -1 on failure with details in
2474/// `fs_ext4_last_error`. `fs_ext4_last_errno` codes: ENOENT if the name
2475/// isn't present, EINVAL on unknown prefix or external-block-only entry,
2476/// EROFS on a read-only mount.
2477#[no_mangle]
2478pub unsafe extern "C" fn fs_ext4_removexattr(
2479    fs: *mut fs_ext4_fs_t,
2480    path: *const c_char,
2481    name: *const c_char,
2482) -> c_int {
2483    ffi_guard(
2484        -1,
2485        AssertUnwindSafe(|| {
2486            clear_last_error();
2487            if fs.is_null() || path.is_null() || name.is_null() {
2488                set_err_msg("null fs/path/name", EINVAL);
2489                return -1;
2490            }
2491            let fs_ref = &(*fs).fs;
2492            let Some(path_b) = (unsafe { cstr_to_path(path, "path") }) else {
2493                return -1;
2494            };
2495            let path_str = shown(path_b);
2496            let Some(name_str) = (unsafe { cstr_to_name(name, "name") }) else {
2497                return -1;
2498            };
2499            match fs_ref.apply_removexattr_bytes(path_b, name_str) {
2500                Ok(()) => 0,
2501                Err(e) => {
2502                    set_err_from(&e, &format!("removexattr {path_str} {name_str}"));
2503                    -1
2504                }
2505            }
2506        }),
2507    )
2508}
2509
2510/// Set (create or replace) the extended attribute `name` on `path` with
2511/// `value_len` bytes from `value`. `name` must be fully-qualified
2512/// (carry a known namespace prefix like "user.").
2513///
2514/// v1 scope: in-inode xattrs only. ENOSPC if the in-inode region is
2515/// too small; external-block spill is not implemented.
2516///
2517/// Returns 0 on success, -1 on failure with details in
2518/// `fs_ext4_last_error`. `fs_ext4_last_errno` codes: EINVAL on unknown
2519/// prefix or null args, ENAMETOOLONG on >255-byte suffix, ENOSPC on
2520/// in-inode overflow, EROFS on RO mount.
2521#[no_mangle]
2522pub unsafe extern "C" fn fs_ext4_setxattr(
2523    fs: *mut fs_ext4_fs_t,
2524    path: *const c_char,
2525    name: *const c_char,
2526    value: *const c_void,
2527    value_len: usize,
2528) -> c_int {
2529    ffi_guard(
2530        -1,
2531        AssertUnwindSafe(|| {
2532            clear_last_error();
2533            if fs.is_null() || path.is_null() || name.is_null() {
2534                set_err_msg("null fs/path/name", EINVAL);
2535                return -1;
2536            }
2537            if value.is_null() && value_len > 0 {
2538                set_err_msg("null value with nonzero len", EINVAL);
2539                return -1;
2540            }
2541            // Cap `value_len` to defang oversize input. The kernel's
2542            // ext4 xattr value cap is one fs block (typically 4 KiB);
2543            // we use 64 KiB as a pragmatic ceiling that comfortably
2544            // exceeds real-world xattr usage but prevents a hostile
2545            // caller from triggering UB during slice construction.
2546            const MAX_XATTR_VALUE_LEN: usize = 64 * 1024;
2547            if value_len > MAX_XATTR_VALUE_LEN {
2548                set_err_msg(
2549                    &format!("setxattr: value_len {value_len} exceeds {MAX_XATTR_VALUE_LEN}"),
2550                    EINVAL,
2551                );
2552                return -1;
2553            }
2554            let fs_ref = &(*fs).fs;
2555            let Some(path_b) = (unsafe { cstr_to_path(path, "path") }) else {
2556                return -1;
2557            };
2558            let path_str = shown(path_b);
2559            let Some(name_str) = (unsafe { cstr_to_name(name, "name") }) else {
2560                return -1;
2561            };
2562            let value_bytes = if value_len == 0 {
2563                &[][..]
2564            } else {
2565                std::slice::from_raw_parts(value as *const u8, value_len)
2566            };
2567            match fs_ref.apply_setxattr_bytes(path_b, name_str, value_bytes) {
2568                Ok(()) => 0,
2569                Err(e) => {
2570                    set_err_from(&e, &format!("setxattr {path_str} {name_str}"));
2571                    -1
2572                }
2573            }
2574        }),
2575    )
2576}
2577
2578// ===========================================================================
2579// Inode-addressed entry points (#372)
2580// ===========================================================================
2581//
2582// A handle-based host names every item by its inode number and every
2583// mutation by a (directory inode, name) pair; it never has a path. These
2584// are the path functions above with the path replaced by that: each calls
2585// the same `Filesystem` implementation its path twin reaches after
2586// resolving, so the two behave identically.
2587//
2588// Every inode argument comes with a generation: the `generation` field of
2589// the `fs_ext4_attr_t` the caller read it from, or `FS_EXT4_GEN_ANY`. A
2590// freed inode, one reused under a different generation, and a number that
2591// never named a file all fail with ESTALE.
2592//
2593// Names are counted byte buffers, not C strings, so a name that is not
2594// UTF-8 survives exactly. They are one entry, not a path: empty, or holding
2595// a '/' or a NUL, is EINVAL; longer than 255 bytes is ENAMETOOLONG.
2596
2597/// Passed as a generation to accept whatever generation the inode has. A
2598/// real generation of `0xFFFF_FFFF` cannot be pinned; it is still refused
2599/// once the inode is freed.
2600pub const FS_EXT4_GEN_ANY: u32 = u32::MAX;
2601
2602fn inode_ref(ino: u32, generation: u32) -> InodeRef {
2603    if generation == FS_EXT4_GEN_ANY {
2604        InodeRef::any(ino)
2605    } else {
2606        InodeRef::new(ino, generation)
2607    }
2608}
2609
2610/// The body of every inode-addressed export: null-checks `fs`, clears the
2611/// last error, runs `body`, and records a failure under `what`.
2612fn ino_call<T: Copy>(
2613    fs: *mut fs_ext4_fs_t,
2614    fail: T,
2615    what: &dyn Fn() -> String,
2616    body: impl FnOnce(&Filesystem) -> Result<T>,
2617) -> T {
2618    ffi_guard(
2619        fail,
2620        AssertUnwindSafe(|| {
2621            clear_last_error();
2622            if fs.is_null() {
2623                set_err_msg(&format!("{}: null fs", what()), EINVAL);
2624                return fail;
2625            }
2626            // SAFETY: non-null, and the caller's handle from a mount.
2627            match body(unsafe { &(*fs).fs }) {
2628                Ok(v) => v,
2629                Err(e) => {
2630                    set_err_from(&e, &what());
2631                    fail
2632                }
2633            }
2634        }),
2635    )
2636}
2637
2638/// A counted name from the caller.
2639unsafe fn name_arg<'a>(name: *const c_char, name_len: usize) -> Result<&'a [u8]> {
2640    if name.is_null() {
2641        return Err(Error::InvalidArgument("null name"));
2642    }
2643    // Bounds the slice built from caller memory; any name past 255 bytes
2644    // is refused further in anyway.
2645    if name_len > FFI_PATH_MAX {
2646        return Err(Error::NameTooLong);
2647    }
2648    Ok(std::slice::from_raw_parts(name.cast::<u8>(), name_len))
2649}
2650
2651/// For messages only.
2652unsafe fn name_for_msg(name: *const c_char, name_len: usize) -> String {
2653    match name_arg(name, name_len) {
2654        Ok(n) => String::from_utf8_lossy(n).into_owned(),
2655        Err(_) => "<invalid name>".into(),
2656    }
2657}
2658
2659/// Fill an optional `attr` out-parameter for the inode just made or
2660/// linked. A failure here is reported although the entry now exists.
2661fn attr_out(fs: &Filesystem, ino: u32, attr: *mut fs_ext4_attr_t) -> Result<c_int> {
2662    if !attr.is_null() {
2663        // SAFETY: non-null; the caller passes a writable attr.
2664        stat_into(fs, InodeRef::any(ino), unsafe { &mut *attr })?;
2665    }
2666    Ok(0)
2667}
2668
2669/// Look up `name` in directory `dir_ino` and fill `attr` for what it names
2670/// (`attr->inode` and `attr->generation` are the handle for it). Returns 0,
2671/// or -1 with ENOENT, ENOTDIR, ESTALE, EINVAL, ...
2672#[no_mangle]
2673pub unsafe extern "C" fn fs_ext4_lookup_at(
2674    fs: *mut fs_ext4_fs_t,
2675    dir_ino: u32,
2676    dir_generation: u32,
2677    name: *const c_char,
2678    name_len: usize,
2679    attr: *mut fs_ext4_attr_t,
2680) -> c_int {
2681    let what = || format!("lookup_at {dir_ino}/{}", name_for_msg(name, name_len));
2682    ino_call(fs, -1, &what, |fs| {
2683        if attr.is_null() {
2684            return Err(Error::InvalidArgument("null attr"));
2685        }
2686        let ino = fs.lookup_at(
2687            inode_ref(dir_ino, dir_generation),
2688            name_arg(name, name_len)?,
2689        )?;
2690        stat_into(fs, InodeRef::any(ino), &mut *attr)?;
2691        Ok(0)
2692    })
2693}
2694
2695/// `fs_ext4_stat` of inode `ino`. Returns 0, or -1 (ESTALE when `ino` no
2696/// longer names a file).
2697#[no_mangle]
2698pub unsafe extern "C" fn fs_ext4_stat_ino(
2699    fs: *mut fs_ext4_fs_t,
2700    ino: u32,
2701    generation: u32,
2702    attr: *mut fs_ext4_attr_t,
2703) -> c_int {
2704    ino_call(fs, -1, &|| format!("stat_ino {ino}"), |fs| {
2705        if attr.is_null() {
2706            return Err(Error::InvalidArgument("null attr"));
2707        }
2708        stat_into(fs, inode_ref(ino, generation), &mut *attr)?;
2709        Ok(0)
2710    })
2711}
2712
2713/// `fs_ext4_dir_open` of directory `ino`. Returns NULL on failure.
2714#[no_mangle]
2715pub unsafe extern "C" fn fs_ext4_dir_open_ino(
2716    fs: *mut fs_ext4_fs_t,
2717    ino: u32,
2718    generation: u32,
2719) -> *mut fs_ext4_dir_iter_t {
2720    ino_call(
2721        fs,
2722        std::ptr::null_mut(),
2723        &|| format!("dir_open_ino {ino}"),
2724        |fs| dir_iter(fs, inode_ref(ino, generation)),
2725    )
2726}
2727
2728/// `fs_ext4_read_file` of inode `ino`: up to `length` bytes from `offset`.
2729/// Returns the bytes read, or -1.
2730#[no_mangle]
2731pub unsafe extern "C" fn fs_ext4_pread_ino(
2732    fs: *mut fs_ext4_fs_t,
2733    ino: u32,
2734    generation: u32,
2735    buf: *mut c_void,
2736    offset: u64,
2737    length: u64,
2738) -> i64 {
2739    ino_call(fs, -1, &|| format!("pread_ino {ino}"), |fs| {
2740        if buf.is_null() {
2741            return Err(Error::InvalidArgument("null buf"));
2742        }
2743        read_into(fs, inode_ref(ino, generation), buf, offset, length)
2744    })
2745}
2746
2747/// `fs_ext4_readlink` of inode `ino`, under the same contract: the target
2748/// and a NUL written into `buf`, its length returned, and -1 with ERANGE
2749/// (nothing written) when `bufsize` cannot hold both.
2750#[no_mangle]
2751pub unsafe extern "C" fn fs_ext4_readlink_ino(
2752    fs: *mut fs_ext4_fs_t,
2753    ino: u32,
2754    generation: u32,
2755    buf: *mut c_char,
2756    bufsize: usize,
2757) -> c_int {
2758    ino_call(fs, -1, &|| format!("readlink_ino {ino}"), |fs| {
2759        if buf.is_null() {
2760            return Err(Error::InvalidArgument("null buf"));
2761        }
2762        let target = fs.read_link_ino(inode_ref(ino, generation))?;
2763        Ok(readlink_into(
2764            &target,
2765            buf,
2766            bufsize,
2767            &format!("inode {ino}"),
2768        ))
2769    })
2770}
2771
2772/// `fs_ext4_pwrite` to inode `ino`. Returns the new size, or -1.
2773#[no_mangle]
2774pub unsafe extern "C" fn fs_ext4_pwrite_ino(
2775    fs: *mut fs_ext4_fs_t,
2776    ino: u32,
2777    generation: u32,
2778    data: *const c_void,
2779    len: u64,
2780    offset: u64,
2781) -> i64 {
2782    ino_call(
2783        fs,
2784        -1,
2785        &|| format!("pwrite_ino {ino} @{offset}+{len}"),
2786        |fs| {
2787            let data = write_arg(data, len, offset)?;
2788            Ok(fs.apply_pwrite_ino(inode_ref(ino, generation), offset, data)? as i64)
2789        },
2790    )
2791}
2792
2793/// `fs_ext4_truncate` of inode `ino`. Returns 0, or -1.
2794#[no_mangle]
2795pub unsafe extern "C" fn fs_ext4_truncate_ino(
2796    fs: *mut fs_ext4_fs_t,
2797    ino: u32,
2798    generation: u32,
2799    new_size: u64,
2800) -> c_int {
2801    ino_call(
2802        fs,
2803        -1,
2804        &|| format!("truncate_ino {ino} -> {new_size}"),
2805        |fs| {
2806            fs.apply_truncate_ino(inode_ref(ino, generation), new_size)?;
2807            Ok(0)
2808        },
2809    )
2810}
2811
2812/// `fs_ext4_chmod` of inode `ino`. Returns 0, or -1.
2813#[no_mangle]
2814pub unsafe extern "C" fn fs_ext4_chmod_ino(
2815    fs: *mut fs_ext4_fs_t,
2816    ino: u32,
2817    generation: u32,
2818    mode: u16,
2819) -> c_int {
2820    ino_call(fs, -1, &|| format!("chmod_ino {ino}"), |fs| {
2821        fs.apply_chmod_ino(inode_ref(ino, generation), mode)?;
2822        Ok(0)
2823    })
2824}
2825
2826/// `fs_ext4_chown` of inode `ino`. Returns 0, or -1.
2827#[no_mangle]
2828pub unsafe extern "C" fn fs_ext4_chown_ino(
2829    fs: *mut fs_ext4_fs_t,
2830    ino: u32,
2831    generation: u32,
2832    uid: u32,
2833    gid: u32,
2834) -> c_int {
2835    ino_call(fs, -1, &|| format!("chown_ino {ino}"), |fs| {
2836        fs.apply_chown_ino(inode_ref(ino, generation), uid, gid)?;
2837        Ok(0)
2838    })
2839}
2840
2841/// `fs_ext4_utimens` of inode `ino`. Returns 0, or -1.
2842#[no_mangle]
2843#[allow(clippy::too_many_arguments)]
2844pub unsafe extern "C" fn fs_ext4_utimens_ino(
2845    fs: *mut fs_ext4_fs_t,
2846    ino: u32,
2847    generation: u32,
2848    atime_sec: i64,
2849    atime_nsec: u32,
2850    mtime_sec: i64,
2851    mtime_nsec: u32,
2852) -> c_int {
2853    ino_call(fs, -1, &|| format!("utimens_ino {ino}"), |fs| {
2854        fs.apply_utimens_ino(
2855            inode_ref(ino, generation),
2856            atime_sec,
2857            atime_nsec,
2858            mtime_sec,
2859            mtime_nsec,
2860        )?;
2861        Ok(0)
2862    })
2863}
2864
2865/// `fs_ext4_create` of entry `name` in directory `dir_ino`. Returns 0 and
2866/// fills `attr` (may be NULL) for the new file, or -1.
2867#[no_mangle]
2868pub unsafe extern "C" fn fs_ext4_create_at(
2869    fs: *mut fs_ext4_fs_t,
2870    dir_ino: u32,
2871    dir_generation: u32,
2872    name: *const c_char,
2873    name_len: usize,
2874    mode: u16,
2875    attr: *mut fs_ext4_attr_t,
2876) -> c_int {
2877    let what = || format!("create_at {dir_ino}/{}", name_for_msg(name, name_len));
2878    ino_call(fs, -1, &what, |fs| {
2879        let dir = inode_ref(dir_ino, dir_generation);
2880        let ino = fs.apply_create_at(dir, name_arg(name, name_len)?, mode)?;
2881        attr_out(fs, ino, attr)
2882    })
2883}
2884
2885/// `fs_ext4_mkdir` of entry `name` in directory `dir_ino`. Returns 0 and
2886/// fills `attr` (may be NULL) for the new directory, or -1.
2887#[no_mangle]
2888pub unsafe extern "C" fn fs_ext4_mkdir_at(
2889    fs: *mut fs_ext4_fs_t,
2890    dir_ino: u32,
2891    dir_generation: u32,
2892    name: *const c_char,
2893    name_len: usize,
2894    mode: u16,
2895    attr: *mut fs_ext4_attr_t,
2896) -> c_int {
2897    let what = || format!("mkdir_at {dir_ino}/{}", name_for_msg(name, name_len));
2898    ino_call(fs, -1, &what, |fs| {
2899        let dir = inode_ref(dir_ino, dir_generation);
2900        let ino = fs.apply_mkdir_at(dir, name_arg(name, name_len)?, mode)?;
2901        attr_out(fs, ino, attr)
2902    })
2903}
2904
2905/// `fs_ext4_mknod` of entry `name` in directory `dir_ino`. Returns 0 and
2906/// fills `attr` (may be NULL) for the new node, or -1.
2907#[no_mangle]
2908#[allow(clippy::too_many_arguments)]
2909pub unsafe extern "C" fn fs_ext4_mknod_at(
2910    fs: *mut fs_ext4_fs_t,
2911    dir_ino: u32,
2912    dir_generation: u32,
2913    name: *const c_char,
2914    name_len: usize,
2915    mode: u16,
2916    major: u32,
2917    minor: u32,
2918    attr: *mut fs_ext4_attr_t,
2919) -> c_int {
2920    let what = || format!("mknod_at {dir_ino}/{}", name_for_msg(name, name_len));
2921    ino_call(fs, -1, &what, |fs| {
2922        let dir = inode_ref(dir_ino, dir_generation);
2923        let ino = fs.apply_mknod_at(dir, name_arg(name, name_len)?, mode, major, minor)?;
2924        attr_out(fs, ino, attr)
2925    })
2926}
2927
2928/// `fs_ext4_symlink`: entry `name` in directory `dir_ino`, pointing at the
2929/// NUL-terminated `target` (bytes, need not be UTF-8). Returns 0 and fills
2930/// `attr` (may be NULL) for the new symlink, or -1.
2931#[no_mangle]
2932#[allow(clippy::too_many_arguments)]
2933pub unsafe extern "C" fn fs_ext4_symlink_at(
2934    fs: *mut fs_ext4_fs_t,
2935    dir_ino: u32,
2936    dir_generation: u32,
2937    name: *const c_char,
2938    name_len: usize,
2939    target: *const c_char,
2940    attr: *mut fs_ext4_attr_t,
2941) -> c_int {
2942    let what = || format!("symlink_at {dir_ino}/{}", name_for_msg(name, name_len));
2943    ino_call(fs, -1, &what, |fs| {
2944        if target.is_null() {
2945            return Err(Error::InvalidArgument("null target"));
2946        }
2947        let target = CStr::from_ptr(target).to_bytes();
2948        if target.len() > FFI_PATH_MAX {
2949            return Err(Error::NameTooLong);
2950        }
2951        let dir = inode_ref(dir_ino, dir_generation);
2952        let ino = fs.apply_symlink_at(dir, name_arg(name, name_len)?, target)?;
2953        attr_out(fs, ino, attr)
2954    })
2955}
2956
2957/// `fs_ext4_link`: a new entry `name` in directory `dir_ino` for inode
2958/// `ino`. Returns 0 and fills `attr` (may be NULL) for `ino`, or -1.
2959#[no_mangle]
2960#[allow(clippy::too_many_arguments)]
2961pub unsafe extern "C" fn fs_ext4_link_at(
2962    fs: *mut fs_ext4_fs_t,
2963    ino: u32,
2964    generation: u32,
2965    dir_ino: u32,
2966    dir_generation: u32,
2967    name: *const c_char,
2968    name_len: usize,
2969    attr: *mut fs_ext4_attr_t,
2970) -> c_int {
2971    let what = || {
2972        format!(
2973            "link_at {ino} -> {dir_ino}/{}",
2974            name_for_msg(name, name_len)
2975        )
2976    };
2977    ino_call(fs, -1, &what, |fs| {
2978        fs.apply_link_at(
2979            inode_ref(ino, generation),
2980            inode_ref(dir_ino, dir_generation),
2981            name_arg(name, name_len)?,
2982        )?;
2983        attr_out(fs, ino, attr)
2984    })
2985}
2986
2987/// `fs_ext4_unlink` of entry `name` in directory `dir_ino`. Returns 0, or
2988/// -1.
2989#[no_mangle]
2990pub unsafe extern "C" fn fs_ext4_unlink_at(
2991    fs: *mut fs_ext4_fs_t,
2992    dir_ino: u32,
2993    dir_generation: u32,
2994    name: *const c_char,
2995    name_len: usize,
2996) -> c_int {
2997    let what = || format!("unlink_at {dir_ino}/{}", name_for_msg(name, name_len));
2998    ino_call(fs, -1, &what, |fs| {
2999        fs.apply_unlink_at(
3000            inode_ref(dir_ino, dir_generation),
3001            name_arg(name, name_len)?,
3002        )?;
3003        Ok(0)
3004    })
3005}
3006
3007/// `fs_ext4_rmdir` of entry `name` in directory `dir_ino`. Returns 0, or
3008/// -1 (EINVAL for `.`, ENOTEMPTY for `..`).
3009#[no_mangle]
3010pub unsafe extern "C" fn fs_ext4_rmdir_at(
3011    fs: *mut fs_ext4_fs_t,
3012    dir_ino: u32,
3013    dir_generation: u32,
3014    name: *const c_char,
3015    name_len: usize,
3016) -> c_int {
3017    let what = || format!("rmdir_at {dir_ino}/{}", name_for_msg(name, name_len));
3018    ino_call(fs, -1, &what, |fs| {
3019        fs.apply_rmdir_at(
3020            inode_ref(dir_ino, dir_generation),
3021            name_arg(name, name_len)?,
3022        )?;
3023        Ok(0)
3024    })
3025}
3026
3027/// `fs_ext4_rename2` of entry `src_name` in `src_dir_ino` to entry
3028/// `dst_name` in `dst_dir_ino`, with the same `flags`. Returns 0, or -1.
3029#[no_mangle]
3030#[allow(clippy::too_many_arguments)]
3031pub unsafe extern "C" fn fs_ext4_rename_at(
3032    fs: *mut fs_ext4_fs_t,
3033    src_dir_ino: u32,
3034    src_dir_generation: u32,
3035    src_name: *const c_char,
3036    src_name_len: usize,
3037    dst_dir_ino: u32,
3038    dst_dir_generation: u32,
3039    dst_name: *const c_char,
3040    dst_name_len: usize,
3041    flags: c_int,
3042) -> c_int {
3043    let what = || {
3044        format!(
3045            "rename_at {src_dir_ino}/{} -> {dst_dir_ino}/{}",
3046            name_for_msg(src_name, src_name_len),
3047            name_for_msg(dst_name, dst_name_len)
3048        )
3049    };
3050    ino_call(fs, -1, &what, |fs| {
3051        let replace = rename_flags(flags)?;
3052        fs.apply_rename_at(
3053            inode_ref(src_dir_ino, src_dir_generation),
3054            name_arg(src_name, src_name_len)?,
3055            inode_ref(dst_dir_ino, dst_dir_generation),
3056            name_arg(dst_name, dst_name_len)?,
3057            replace,
3058        )?;
3059        Ok(0)
3060    })
3061}
3062
3063// ===========================================================================
3064// mkfs (volume creation)
3065// ===========================================================================
3066
3067/// Format a block device as ext4. The device is reached through the same
3068/// callback config used by `fs_ext4_mount_*_with_callbacks`; `cfg->write` and
3069/// `cfg->size_bytes` must be set. `label` is an optional NUL-terminated UTF-8
3070/// volume name (≤ 16 bytes — longer names are truncated). `uuid` may be NULL
3071/// (the driver generates one) or a pointer to 16 raw bytes.
3072///
3073/// Returns 0 on success or `-errno` on failure. Use `fs_ext4_last_error` /
3074/// `fs_ext4_last_errno` for context.
3075#[no_mangle]
3076pub unsafe extern "C" fn fs_ext4_mkfs(
3077    cfg: *const fs_ext4_blockdev_cfg_t,
3078    label: *const c_char,
3079    uuid: *const u8,
3080) -> c_int {
3081    ffi_guard(
3082        -EINVAL,
3083        AssertUnwindSafe(|| {
3084            clear_last_error();
3085            if cfg.is_null() {
3086                set_err_msg("null cfg", EINVAL);
3087                return -EINVAL;
3088            }
3089            let cfg = &*cfg;
3090            let Some(read_fn) = cfg.read else {
3091                set_err_msg("cfg.read is null", EINVAL);
3092                return -EINVAL;
3093            };
3094            let Some(write_fn) = cfg.write else {
3095                set_err_msg("cfg.write is null (mkfs needs writes)", EINVAL);
3096                return -EINVAL;
3097            };
3098            let flush_fn = cfg.flush; // optional
3099            if cfg.size_bytes == 0 {
3100                set_err_msg("cfg.size_bytes is 0", EINVAL);
3101                return -EINVAL;
3102            }
3103            let block_size = if cfg.block_size == 0 {
3104                crate::mkfs::DEFAULT_BLOCK_SIZE
3105            } else {
3106                cfg.block_size
3107            };
3108
3109            let ctx_addr = cfg.context as usize;
3110
3111            let read_closure = move |offset: u64, buf: &mut [u8]| -> std::io::Result<()> {
3112                let rc = unsafe {
3113                    read_fn(
3114                        ctx_addr as *mut c_void,
3115                        buf.as_mut_ptr() as *mut c_void,
3116                        offset,
3117                        buf.len() as u64,
3118                    )
3119                };
3120                if rc != 0 {
3121                    Err(std::io::Error::other(format!("read cb {rc}")))
3122                } else {
3123                    Ok(())
3124                }
3125            };
3126            let write_closure = move |offset: u64, buf: &[u8]| -> std::io::Result<()> {
3127                let rc = unsafe {
3128                    write_fn(
3129                        ctx_addr as *mut c_void,
3130                        buf.as_ptr() as *const c_void,
3131                        offset,
3132                        buf.len() as u64,
3133                    )
3134                };
3135                if rc != 0 {
3136                    Err(std::io::Error::other(format!("write cb {rc}")))
3137                } else {
3138                    Ok(())
3139                }
3140            };
3141            let flush_closure: Option<crate::block_io::FlushCb> = flush_fn.map(|f| {
3142                let cb: crate::block_io::FlushCb = Box::new(move || -> std::io::Result<()> {
3143                    let rc = unsafe { f(ctx_addr as *mut c_void) };
3144                    if rc != 0 {
3145                        Err(std::io::Error::other(format!("flush cb {rc}")))
3146                    } else {
3147                        Ok(())
3148                    }
3149                });
3150                cb
3151            });
3152
3153            let dev = CallbackDevice {
3154                size: cfg.size_bytes,
3155                read: Box::new(read_closure),
3156                write: Some(Box::new(write_closure)),
3157                flush: flush_closure,
3158            };
3159
3160            let label_str: Option<&str> = if label.is_null() {
3161                None
3162            } else {
3163                Some(cstr_to_str(label))
3164            };
3165            let uuid_arr: Option<[u8; 16]> = if uuid.is_null() {
3166                None
3167            } else {
3168                let mut tmp = [0u8; 16];
3169                std::ptr::copy_nonoverlapping(uuid, tmp.as_mut_ptr(), 16);
3170                Some(tmp)
3171            };
3172
3173            match crate::mkfs::format_filesystem(
3174                &dev,
3175                label_str,
3176                uuid_arr,
3177                cfg.size_bytes,
3178                block_size,
3179            ) {
3180                Ok(()) => 0,
3181                Err(e) => {
3182                    let errno = e.to_errno();
3183                    set_err_from(&e, "mkfs");
3184                    -errno
3185                }
3186            }
3187        }),
3188    )
3189}
3190
3191// ===========================================================================
3192// Fsck (read-only audit)
3193//
3194// Architecture note: this is a *read-only* fsck — it never writes to disk.
3195// Findings are delivered via a per-finding C callback rather than collected
3196// into a Vec returned by reference, so a host UI can stream progress on huge
3197// volumes without buffering the entire anomaly list. Repair (link-count
3198// fixup, orphan relink, dotdot rewrite) is explicit future work and will
3199// require a journaled write path plus a new ABI entry point.
3200//
3201// The caller-supplied `on_progress` and `on_finding` callbacks run on the
3202// thread that called `fs_ext4_fsck_run`. They MUST NOT unwind / throw across
3203// the FFI boundary — Rust panics inside them turn into the same EIO last-
3204// error other capi functions surface (the whole body is wrapped in
3205// `ffi_guard`). The `phase_name`, `kind`, and `detail` strings are valid
3206// only for the duration of the callback call.
3207// ===========================================================================
3208
3209/// Phase id (matches `fs_ext4_fsck_phase_t` in `include/fs_ext4.h`).
3210#[repr(C)]
3211#[derive(Copy, Clone)]
3212pub enum fs_ext4_fsck_phase_t {
3213    Superblock = 0,
3214    Journal = 1,
3215    Directory = 2,
3216    Inodes = 3,
3217    Finalize = 4,
3218}
3219
3220/// Progress callback (matches `fs_ext4_fsck_progress_fn`).
3221pub type fs_ext4_fsck_progress_fn = Option<
3222    unsafe extern "C" fn(
3223        context: *mut c_void,
3224        phase: fs_ext4_fsck_phase_t,
3225        phase_name: *const c_char,
3226        done: u64,
3227        total: u64,
3228    ),
3229>;
3230
3231/// Per-finding callback (matches `fs_ext4_fsck_finding_fn`).
3232pub type fs_ext4_fsck_finding_fn = Option<
3233    unsafe extern "C" fn(
3234        context: *mut c_void,
3235        kind: *const c_char,
3236        inode: u32,
3237        detail: *const c_char,
3238    ),
3239>;
3240
3241/// Fsck options struct (matches `fs_ext4_fsck_options_t`).
3242#[repr(C)]
3243pub struct fs_ext4_fsck_options_t {
3244    /// 1 = audit only (no writes). 0 + `repair == 1` = repair pass.
3245    /// Non-{0,1} values are rejected with EINVAL.
3246    pub read_only: u8,
3247    /// 1 = call `fs_ext4_replay_journal_if_dirty` before the audit.
3248    /// Useful when the volume was mounted via the lazy variant and the
3249    /// caller wants a single fsck call to replay-then-audit.
3250    pub replay_journal: u8,
3251    /// 0 = unbounded (fsck visits every directory). Otherwise capped.
3252    pub max_dirs: u32,
3253    /// 0 = unbounded. Otherwise per-directory entry cap.
3254    pub max_entries_per_dir: u32,
3255    /// Nullable. Called with phase + done/total counters. See module
3256    /// note for which phases are emitted.
3257    pub on_progress: fs_ext4_fsck_progress_fn,
3258    /// Nullable. Called once per `Anomaly` discovered.
3259    pub on_finding: fs_ext4_fsck_finding_fn,
3260    /// Opaque pointer threaded through both callbacks. Not interpreted
3261    /// by Rust.
3262    pub context: *mut c_void,
3263    /// Repair-pass switch. Only honoured when `read_only == 0`. See
3264    /// the C header for the list of anomaly classes the current repair
3265    /// pass actually fixes.
3266    pub repair: u8,
3267}
3268
3269/// Fsck report (matches `fs_ext4_fsck_report_t`).
3270#[repr(C)]
3271pub struct fs_ext4_fsck_report_t {
3272    pub inodes_visited: u64,
3273    pub directories_scanned: u64,
3274    pub entries_scanned: u64,
3275    /// Authoritative current anomaly count. After a repair pass this
3276    /// is the **post-repair re-scan** count — actual anomalies still
3277    /// present on disk, NOT a delta against `repaired_count`.
3278    pub anomalies_found: u64,
3279    /// 1 if the on-disk superblock `s_state` showed dirty before the
3280    /// run started. Captured *before* journal replay (so the caller
3281    /// can tell whether the volume was crash-state on entry).
3282    pub was_dirty: u8,
3283    /// 1 if a repair commit cleared the dirty bit, 0 otherwise.
3284    pub dirty_cleared: u8,
3285    /// Number of anomalies repaired (0 unless the run was a repair pass).
3286    pub repaired_count: u64,
3287    /// Anomalies the audit found BEFORE any repair commits. Equal to
3288    /// `anomalies_found` for non-repair runs. After a repair pass:
3289    /// `initial_anomalies_count - repaired_count` is what we EXPECT
3290    /// to remain; `anomalies_found` is what ACTUALLY remains. A
3291    /// mismatch flags repair-logic bugs (we either failed to fix
3292    /// what we claimed to fix, or fixing one thing introduced a new
3293    /// anomaly).
3294    pub initial_anomalies_count: u64,
3295}
3296
3297/// Map an [`Anomaly`] variant to its locked C ABI kind string + the
3298/// most relevant inode + a short free-form detail blob.
3299fn anomaly_to_capi(a: &crate::fsck::Anomaly) -> (&'static str, u32, String) {
3300    use crate::fsck::Anomaly;
3301    match a {
3302        &Anomaly::LinkCountTooLow {
3303            ino,
3304            stored,
3305            observed,
3306        } => (
3307            "link_count_low",
3308            ino,
3309            format!("stored={stored} observed={observed}"),
3310        ),
3311        &Anomaly::LinkCountTooHigh {
3312            ino,
3313            stored,
3314            observed,
3315        } => (
3316            "link_count_high",
3317            ino,
3318            format!("stored={stored} observed={observed}"),
3319        ),
3320        &Anomaly::DanglingEntry {
3321            parent_ino,
3322            child_ino,
3323            observed,
3324        } => (
3325            "dangling_entry",
3326            child_ino,
3327            format!("parent_ino={parent_ino} observed={observed}"),
3328        ),
3329        &Anomaly::WrongDotDot {
3330            dir_ino,
3331            claims,
3332            actual_parent,
3333        } => (
3334            "wrong_dotdot",
3335            dir_ino,
3336            format!("claims={claims} actual_parent={actual_parent}"),
3337        ),
3338        Anomaly::BogusEntry {
3339            parent_ino,
3340            child_ino,
3341            name,
3342        } => (
3343            "bogus_entry",
3344            *child_ino,
3345            format!(
3346                "parent_ino={parent_ino} name={}",
3347                String::from_utf8_lossy(name)
3348            ),
3349        ),
3350        &Anomaly::BlockGroupFreeCountDrift {
3351            group_index,
3352            stored_blocks,
3353            observed_blocks,
3354            stored_inodes,
3355            observed_inodes,
3356        } => (
3357            "block_group_free_count_drift",
3358            group_index,
3359            format!(
3360                "stored_blocks={stored_blocks} observed_blocks={observed_blocks} \
3361                 stored_inodes={stored_inodes} observed_inodes={observed_inodes}"
3362            ),
3363        ),
3364        &Anomaly::SuperblockFreeCountDrift {
3365            stored_blocks,
3366            observed_blocks,
3367            stored_inodes,
3368            observed_inodes,
3369        } => (
3370            "superblock_free_count_drift",
3371            0,
3372            format!(
3373                "stored_blocks={stored_blocks} observed_blocks={observed_blocks} \
3374                 stored_inodes={stored_inodes} observed_inodes={observed_inodes}"
3375            ),
3376        ),
3377        // Surfaced via the new repair-aware Rust API; the C ABI
3378        // doesn't expose a repair entry point yet, but the read-only
3379        // audit can still encounter and report this variant once a
3380        // duplicate has slipped past detection. Mirror the existing
3381        // string format: short kind tag + parent/name details for
3382        // every alias so a downstream UI can render them.
3383        Anomaly::DuplicateDirentForDirInode { ino, dirents } => {
3384            let detail = dirents
3385                .iter()
3386                .map(|(p, n)| format!("{p}:{n}"))
3387                .collect::<Vec<_>>()
3388                .join(",");
3389            ("duplicate_dir_inode", *ino, format!("aliases={detail}"))
3390        }
3391        &Anomaly::DirBlockChecksumMismatch {
3392            dir_ino,
3393            logical_block,
3394            htree,
3395        } => (
3396            "dir_block_checksum",
3397            dir_ino,
3398            format!("logical_block={logical_block} htree={}", u8::from(htree)),
3399        ),
3400        Anomaly::UnreadableDirectory { ino, reason } => {
3401            ("unreadable_directory", *ino, format!("reason={reason}"))
3402        }
3403    }
3404}
3405
3406/// Run a read-only fsck audit on `fs`. Findings are delivered live via
3407/// `opts->on_finding`; counters are written to `*report` before return.
3408///
3409/// Returns 0 on success (regardless of how many anomalies were found —
3410/// they're surfaced through `on_finding` and counted in
3411/// `report->anomalies_found`). Returns -1 on hard failure (null args,
3412/// `read_only != 1`, journal replay failure, etc.) with the cause in
3413/// `fs_ext4_last_error` / `fs_ext4_last_errno`.
3414#[no_mangle]
3415pub unsafe extern "C" fn fs_ext4_fsck_run(
3416    fs: *mut fs_ext4_fs_t,
3417    opts: *const fs_ext4_fsck_options_t,
3418    report: *mut fs_ext4_fsck_report_t,
3419) -> c_int {
3420    ffi_guard(
3421        -1,
3422        AssertUnwindSafe(|| {
3423            clear_last_error();
3424            if fs.is_null() || opts.is_null() || report.is_null() {
3425                set_err_msg("null fs/opts/report", EINVAL);
3426                return -1;
3427            }
3428            let fs_ref = &mut (*fs).fs;
3429            let opts_ref = &*opts;
3430            // Sanity: read_only must be 0 or 1; repair can only run
3431            // when read_only == 0. Anything outside this matrix is a
3432            // caller bug worth surfacing rather than silently ignoring.
3433            if opts_ref.read_only > 1 {
3434                set_err_msg("fsck: opts.read_only must be 0 or 1", EINVAL);
3435                return -1;
3436            }
3437            if opts_ref.replay_journal > 1 {
3438                set_err_msg("fsck: opts.replay_journal must be 0 or 1", EINVAL);
3439                return -1;
3440            }
3441            if opts_ref.repair > 1 {
3442                set_err_msg("fsck: opts.repair must be 0 or 1", EINVAL);
3443                return -1;
3444            }
3445            if opts_ref.read_only == 1 && opts_ref.repair == 1 {
3446                set_err_msg("fsck: opts.repair = 1 requires opts.read_only = 0", EINVAL);
3447                return -1;
3448            }
3449            let repair_requested = opts_ref.repair == 1;
3450
3451            // Zero the report so partial fills on early-error paths don't
3452            // leave stale stack data visible to the caller.
3453            std::ptr::write_bytes(report, 0, 1);
3454            let report_out = &mut *report;
3455
3456            // Capture pre-run dirty flag (before any replay).
3457            report_out.was_dirty = if fs_ref.sb.is_clean() { 0 } else { 1 };
3458
3459            // Stash callbacks + context for the closures.
3460            let progress_cb = opts_ref.on_progress;
3461            let finding_cb = opts_ref.on_finding;
3462            let ctx = opts_ref.context;
3463
3464            // Helper: push a progress event through the C callback (if set).
3465            let emit_progress = |phase: crate::fsck::FsckPhase, done: u64, total: u64| {
3466                if let Some(cb) = progress_cb {
3467                    let phase_c = match phase {
3468                        crate::fsck::FsckPhase::Superblock => fs_ext4_fsck_phase_t::Superblock,
3469                        crate::fsck::FsckPhase::Journal => fs_ext4_fsck_phase_t::Journal,
3470                        crate::fsck::FsckPhase::Directory => fs_ext4_fsck_phase_t::Directory,
3471                        crate::fsck::FsckPhase::Inodes => fs_ext4_fsck_phase_t::Inodes,
3472                        crate::fsck::FsckPhase::Finalize => fs_ext4_fsck_phase_t::Finalize,
3473                    };
3474                    // phase_name strings are short ASCII literals; CString
3475                    // construction can't fail.
3476                    let name = CString::new(phase.name()).unwrap();
3477                    cb(ctx, phase_c, name.as_ptr(), done, total);
3478                }
3479            };
3480
3481            // Optional journal replay before the audit. We drive the
3482            // Journal phase ourselves (the audit walker doesn't know
3483            // whether the FFI shim asked for replay).
3484            if opts_ref.replay_journal != 0 {
3485                emit_progress(crate::fsck::FsckPhase::Journal, 0, 1);
3486                if let Err(e) = fs_ref.replay_journal_if_dirty() {
3487                    set_err_from(&e, "fsck: replay_journal_if_dirty");
3488                    return -1;
3489                }
3490                emit_progress(crate::fsck::FsckPhase::Journal, 1, 1);
3491            }
3492
3493            // Map opts.max_* (0 = unbounded → u32::MAX).
3494            let max_dirs = if opts_ref.max_dirs == 0 {
3495                u32::MAX
3496            } else {
3497                opts_ref.max_dirs
3498            };
3499            let max_entries = if opts_ref.max_entries_per_dir == 0 {
3500                u32::MAX
3501            } else {
3502                opts_ref.max_entries_per_dir
3503            };
3504
3505            // Helper: push a finding through the C callback (if set).
3506            let emit_finding = |a: &crate::fsck::Anomaly| {
3507                if let Some(cb) = finding_cb {
3508                    let (kind, ino, detail) = anomaly_to_capi(a);
3509                    // Both strings are static / format!-derived ASCII, no
3510                    // interior NUL — CString::new can't fail here, but we
3511                    // still fall back gracefully if it ever does.
3512                    let kind_c = CString::new(kind).unwrap_or_else(|_| CString::new("?").unwrap());
3513                    let detail_c =
3514                        CString::new(detail).unwrap_or_else(|_| CString::new("").unwrap());
3515                    // Safety: callbacks run synchronously; the CStrings
3516                    // outlive the call because they're local stack vars.
3517                    cb(ctx, kind_c.as_ptr(), ino, detail_c.as_ptr());
3518                }
3519            };
3520
3521            let result = crate::fsck::audit_with_repair(
3522                fs_ref,
3523                max_dirs,
3524                max_entries,
3525                emit_progress,
3526                emit_finding,
3527                repair_requested,
3528            );
3529
3530            match result {
3531                Ok(audit_report) => {
3532                    report_out.inodes_visited = audit_report.inodes_visited as u64;
3533                    report_out.directories_scanned = audit_report.directories_scanned as u64;
3534                    report_out.entries_scanned = audit_report.entries_scanned;
3535                    report_out.anomalies_found = audit_report.anomalies_count;
3536                    report_out.initial_anomalies_count = audit_report.initial_anomalies_count;
3537                    report_out.repaired_count = audit_report.repaired_count;
3538                    // `dirty_cleared` reports whether the on-disk
3539                    // dirty bit transitioned from set to clear over
3540                    // this run. Two requirements: (a) the FS was
3541                    // dirty pre-run (otherwise there's nothing to
3542                    // clear), and (b) the on-disk SB is clean after
3543                    // any repair commits. We can't trust
3544                    // `fs_ref.sb.is_clean()` here — that's the parsed
3545                    // mount-time snapshot, never mutated by repair —
3546                    // so re-read the SB from disk to get the truth.
3547                    let post_sb_clean = if repair_requested && report_out.was_dirty == 1 {
3548                        crate::superblock::Superblock::read(fs_ref.dev.as_ref())
3549                            .map(|sb| sb.is_clean())
3550                            .unwrap_or(false)
3551                    } else {
3552                        false
3553                    };
3554                    report_out.dirty_cleared = if post_sb_clean { 1 } else { 0 };
3555                    0
3556                }
3557                Err(e) => {
3558                    set_err_from(&e, "fsck: audit");
3559                    -1
3560                }
3561            }
3562        }),
3563    )
3564}