ajhahn.de
← FlashOS
Flash 1139 lines
// fat32_backend: FAT32 VFS backend. Wraps src/fat32.zig's
// on-disk decode in the VfsOps vtable.
//
// open / read / seek / close / write do live cluster-chain I/O
// against block_dev.sd_dev. write (writeBack) extends-or-overwrites
// an existing file: chain-extend via allocCluster + writeFatEntry,
// sector read-modify-write loop, dir-entry file_size update by
// re-walking the root by first cluster, FSInfo decrement per alloc.
// create / unlink / rename close the metadata gap: create stamps a
// fresh 8.3 entry (find/extend a free slot) and hands back a writable
// handle; unlink tombstones the entry and frees its chain; rename
// rewrites the 8.3 name in place. All file-only and same-directory —
// mkdir and cross-directory move are future scope (see each fn header).
// No sparse-write past EOF (see writeBack header).
//
// init() MUST run after board.emmc2.init() has wired
// block_dev.sd_dev — fat32.mount issues block reads through that
// vtable. kernel.zig calls init() inside the emmc2-init-OK branch
// for exactly that reason; calling it before sd_dev is wired would
// dereference an undefined function pointer.

const std = #import("std")
const fat32 = #import("fat32")
const vfs = #import("vfs")
const file_mod = #import("file")
const block_dev = #import("block_dev")
const overlay = #import("overlay")

const File = file_mod.File

// Single static superblock for the /mnt mount (slot 1). fs_type is
// re-stamped by vfs.register_fat32.
pub var sb vfs.SuperBlock = .{ .fs_type = #intFromEnum(vfs.FsType.FAT32) }

// Volume descriptor, populated by init()'s fat32.mount. sb.private
// carries its address (per the vfs.zig SuperBlock contract); the
// vtable bodies reach it directly through this module global.
var mount_info fat32.Mount = undefined

// `var`, not `const`: init() relocates these entries to their
// high-mem aliases in place via vfs.relocateOps (mirrors the earlier
// stub's pattern). Slot names match real src/vfs.zig VfsOps.
var ops_vtable vfs.VfsOps = .{
    .open = open,
    .read = read,
    .seek = seek,
    .close = close,
    .write = write,
    .readdir = readdir,
    .create = create,
    .unlink = unlink,
    .rename = rename,
}

// Start sector of the single FAT32 partition on the SD card. Matches
// scripts/format_sd.sh (MBR, one FAT32 at LBA 2048 = the 1 MiB
// alignment offset); make_test_disk.sh formats the QEMU image to the
// same offset.
const FAT32_PARTITION_LBA u32 = 2048

// ---- FAT32 permission overlay ----
//
// FAT32 has no native owner/mode concept, so /mnt files get their
// permission metadata from a root-level text file (PERMS.TAB) parsed once
// at mount time into this fixed table; open() consults it. Un-annotated
// paths keep the documented default (0666 root:root) — except the shadow
// basename, which floors at 0600 root:root so a missing or corrupt
// overlay can never expose the on-card password file (defense in depth
// behind the anti-brick fallback).

// Overlay file name in the FAT32 root (matched case-insensitively).
const OVERLAY_NAME []u8 = "perms.tab"
// The basename that floors at 0600 when the overlay carries no entry.
const SHADOW_NAME []u8 = "shadow"

// True when PERMS.TAB was found AND parsed cleanly at mount time.
// kernel.zig reads this after init() to emit the loud anti-brick
// announcement — this module has no console.
pub var overlay_ok bool = false
var overlay_count usize = 0
var overlay_entries [overlay.MAX_ENTRIES]overlay.Entry = undefined
// Static read buffer for the overlay file. The overlay is sub-KiB by
// design; an oversized file is treated as corrupt (rejected wholesale).
var overlay_buf [1024]u8 = undefined

// Kernel-stack relief: shared sector scratch for the
// vtable I/O entry points (read / write / readdir and the dir-walk
// helper). They never nest in each other and every dispatch runs under
// the sys.zig preempt_disable bracket, so one buffer serves all four.
// See src/fat32.zig's dir/fat_sector_scratch for the full rationale.
var io_sector_scratch fat32.SectorBuf align(4) = undefined

// Read + parse /PERMS.TAB from the freshly mounted volume. Any failure
// (absent, empty, oversized, unreadable, malformed) leaves overlay_ok
// false and the table empty — open() then applies the defaults + shadow
// floor. Called by init() right after register_fat32, so the table is
// ready before the first syscall-path open.
fn applyOverlay() void {
    overlay_ok = false
    overlay_count = 0

    const name = fat32.encode8_3(OVERLAY_NAME) orelse return
    const found = fat32.lookupInRoot(&mount_info, name) catch return
    if (found.entry.file_size == 0 || found.entry.file_size > overlay_buf.len) { return }

    const first_clus = (#as(u32, found.entry.fst_clus_hi) << 16) | found.entry.fst_clus_lo
    var f File = .{
        .ftype = 0,
        .refs = 1,
        .offset = 0,
        .private = first_clus,
        .size = found.entry.file_size,
        .sb = &sb,
    }
    var got u64 = 0
    while (got < f.size) {
        const n = read(&sb, &f, overlay_buf[#intCast(got)..].ptr, f.size - got)
        if (n < 0) { return }
        if (n == 0) { break }
        got += #intCast(n)
    }

    // Flash has no `orelse { block }` (the block handler is catch-only),
    // so the original `overlay_count = parse(...) orelse { overlay_count =
    // 0; return; }` is spelled as an explicit null-check — behavior
    // identical (the `= 0` is the already-set value from the top).
    const parsed = overlay.parse(overlay_buf[0..#intCast(got)], &overlay_entries)
    if (parsed == null) {
        overlay_count = 0
        return
    }
    overlay_count = parsed.?
    overlay_ok = true
}

// Kernel bring-up hook. Returns 0 on a mounted volume, -1 if
// fat32.mount fails (blank/bad disk, no BPB). On failure the mount
// table slot is left null — non-fatal: vfs.resolve returns null for
// /mnt/* and the caller treats it as ENOENT. kernel.zig logs the
// outcome (this module has no console). Allocates nothing (mount
// uses a stack sector buffer), so the free-page baseline holds.
pub fn init() i32 {
    vfs.relocateOps(&ops_vtable)
    // The block-device function pointers are link-time (low) addresses
    // wired by the board's emmc2 init. Like the vtable above, they are
    // invoked from syscall context (TTBR0 = user pgd), so they must be
    // re-pointed to their high-mem aliases before the first mount.
    block_dev.relocate()
    sb.ops = &ops_vtable
    mount_info = fat32.mount(&block_dev.sd_dev, FAT32_PARTITION_LBA) catch return -1
    sb.private = #intFromPtr(&mount_info)
    vfs.register_fat32(&sb)
    // Permission overlay: parse PERMS.TAB into the static table
    // while the mount is fresh. Failure is soft — overlay_ok stays false
    // and open() falls back to defaults + the shadow floor.
    applyOverlay()
    return 0
}

// path crosses as ptr+len (callconv(.c) forbids slices). The /mnt
// prefix is already stripped by vfs.resolve, leaving a leading '/'.
// out.private carries the file's first cluster in the low 32 bits
// (high bits 0 — read re-walks the chain; no cluster_count cached
// until fd state grows). out.size carries the dir-entry
// file_size.
fn open(_ *mut vfs.SuperBlock, path_ptr [*]u8, path_len usize, out *mut vfs.OpenResult) callconv(.c) c_int {
    const path = path_ptr[0..path_len]
    const rel = if (path.len > 0 && path[0] == '/') path[1..] else path
    const found = fat32.lookupPath(&mount_info, rel) catch return -1
    const first_clus = fat32.firstCluster(found.entry)
    out.private = first_clus
    out.size = found.entry.file_size
    // Stash the on-disk directory-entry location so write() can rewrite
    // the entry's first-cluster (empty-file first write) and file_size
    // without an ambiguous re-walk by first cluster.
    out.dirent_lba = found.lba
    out.dirent_off = found.byte_offset
    // Permission metadata: the mount-time overlay (PERMS.TAB)
    // supplies per-file mode/uid/gid. Annotated paths get their entry
    // (low 9 bits + the regular-file type the perm layer expects);
    // un-annotated paths keep the documented default — rw-rw-rw-
    // root:root, no exec bit (the historical /mnt contract) — except the
    // shadow basename, which floors at 0600 root:root so a missing or
    // corrupt overlay never exposes the on-card password file. An
    // explicit overlay entry can still override the floor (operator's
    // call).
    if (overlay.lookup(overlay_entries[0..overlay_count], rel)) |e| {
        out.mode = 0o100000 | (e.mode & 0o777)
        out.uid = e.uid
        out.gid = e.gid
    } else if (overlay.nameEql(rel, SHADOW_NAME)) {
        out.mode = 0o100600
        out.uid = 0
        out.gid = 0
    } else {
        out.mode = 0o100666
        out.uid = 0
        out.gid = 0
    }
    return 0
}

fn read(_ *mut vfs.SuperBlock, f *mut File, buf [*]mut u8, len u64) callconv(.c) i64 {
    if (f.offset >= f.size) { return 0 }
    const remaining = f.size - f.offset
    const n u64 = if (len > remaining) remaining else len

    // Walk the FAT chain to the cluster covering f.offset.
    var cluster u32 = #intCast(f.private & 0xFFFF_FFFF)
    var cluster_offset u64 = f.offset
    while (cluster_offset >= mount_info.bytes_per_cluster) {
        cluster = fat32.readFatEntry(&mount_info, cluster) catch return -1
        if (cluster >= fat32.FAT_EOC_MIN) { return -1 }
        cluster_offset -= mount_info.bytes_per_cluster
    }

    var copied u64 = 0
    const sector_buf = &io_sector_scratch
    while (copied < n) {
        const sector_in_cluster u32 = #intCast(cluster_offset / 512)
        const byte_in_sector u32 = #intCast(cluster_offset % 512)
        const lba = (fat32.clusterLba(&mount_info, cluster) catch return -1) + sector_in_cluster
        const read_fn = block_dev.sd_dev.read_fn orelse return -1
        if (read_fn(lba, sector_buf) != 0) { return -1 }
        const take u64 = #min(n - copied, 512 - byte_in_sector)
        // Symmetric to write()'s splice — explicit byte loop so the
        // read_fn(&sector_buf) -> copy-out dependency is preserved
        // for the sub-sector (take<512) case (see write() comment).
        // (Bare scoping block hoisted to fn scope — Flash has no
        // anonymous block statement; `si` is dead after the loop.)
        var si usize = 0
        while (si < take) {
            buf[#as(usize, #intCast(copied)) + si] = sector_buf[#as(usize, byte_in_sector) + si]
            si += 1
        }
        copied += take
        cluster_offset += take
        if (cluster_offset >= mount_info.bytes_per_cluster) {
            cluster = fat32.readFatEntry(&mount_info, cluster) catch return -1
            if (cluster >= fat32.FAT_EOC_MIN) { break }
            cluster_offset = 0
        }
    }
    f.offset += copied
    return #bitCast(copied)
}

fn seek(_ *mut vfs.SuperBlock, f *mut File, off i64, whence i32) callconv(.c) i64 {
    const cur_signed i64 = #bitCast(f.offset)
    const sz_signed i64 = #bitCast(f.size)
    const target i64 = switch whence {
        0 => off, // SEEK_SET
        1 => cur_signed + off, // SEEK_CUR
        2 => sz_signed + off, // SEEK_END
        else => return -1,
    }
    if (target < 0 || target > sz_signed) { return -1 }
    f.offset = #bitCast(target)
    return target
}

fn close(_ *mut vfs.SuperBlock, _ *mut File) callconv(.c) void {
    // No per-handle state — every read is sector-fetched inline and
    // the File page lifetime is file.zig's refcount's job. Step 4's
    // write path stays sector-flushed too, so close stays a no-op
    // until a future buffer cache adds a real fsync here.
}

// write (writeBack) — extends or overwrites an existing file, including
// a previously empty one. No create-if-missing for a NON-existent path
// yet (the dir entry must already exist); no sparse write past EOF + len
// (offset > size treated as -1). Sequence:
//   0. If the file is empty (first_cluster == 0), allocate its first
//      data cluster, link it EOC, and record it in the dir entry (via
//      the open-time-stashed dirent location — an empty file can't be
//      found by first cluster, since 0 is not unique across empty files).
//   1. Walk the chain from first_cluster to the cluster covering
//      f.offset; if the chain ends before that, allocCluster + link.
//   2. Sector read-modify-write loop: read the target sector, splice
//      `take` bytes, write it back. Cross cluster boundaries via the
//      same alloc-or-follow path.
//   3. If f.offset + copied > f.size, update the in-RAM f.size and the
//      on-disk dir entry's file_size at the stashed dirent location.
//   4. fsInfoOnAlloc once per allocCluster.
//
// Not crash-safe (FAT1/FAT2, dir-entry, FSInfo writes are separate
// non-atomic RMW points). Single-shot acceptance run never power-
// cycles mid-write; a future journal closes the gap.
fn write(_ *mut vfs.SuperBlock, f *mut File, buf [*]u8, len u64) callconv(.c) i64 {
    if (len == 0) { return 0 }
    // No sparse write: a hole between f.size and f.offset is -1.
    if (f.offset > f.size) { return -1 }

    // On-disk dir-entry location, stashed at open. Used to give an empty
    // file its first cluster (step 0) and to grow file_size (step 3) —
    // both rewrite the entry, which an empty file can't locate by first
    // cluster (0 is not unique). `.entry` is unread by either updater.
    const dirent_loc fat32.FoundEntry = .{
        .entry = undefined,
        .lba = f.dirent_lba,
        .byte_offset = #intCast(f.dirent_off),
    }

    var cluster u32 = #intCast(f.private & 0xFFFF_FFFF)
    var cluster_offset u64 = f.offset

    // Step 0: first write to an empty file. Its dir entry has
    // first_cluster == 0 (no data yet), so clusterLba would fail closed.
    // allocCluster reserves a free cluster and writes its FAT_EOC; record
    // it in the dir entry and adopt it as the chain head.
    if (cluster == 0) {
        const first = fat32.allocCluster(&mount_info) catch return -1
        fat32.fsInfoOnAlloc(&mount_info, first) catch {}
        fat32.updateDirEntryFirstCluster(&mount_info, dirent_loc, first) catch return -1
        f.private = first
        cluster = first
    }

    // Step 1: walk to the cluster covering f.offset, extending the
    // chain via allocCluster when the walk hits end-of-chain.
    while (cluster_offset >= mount_info.bytes_per_cluster) {
        var next = fat32.readFatEntry(&mount_info, cluster) catch return -1
        if (next >= fat32.FAT_EOC_MIN) {
            next = fat32.allocCluster(&mount_info) catch return -1
            fat32.writeFatEntry(&mount_info, cluster, next) catch return -1
            // FSInfo free-count/next-free is an advisory hint; the FAT
            // chain and dir entry are already durable, so a failed
            // hint update only costs a slower future allocation scan,
            // never data — swallow it rather than fail the write.
            fat32.fsInfoOnAlloc(&mount_info, next) catch {}
        }
        cluster = next
        cluster_offset -= mount_info.bytes_per_cluster
    }

    // Step 2: sector read-modify-write loop.
    var copied u64 = 0
    const sector_buf = &io_sector_scratch
    while (copied < len) {
        const sector_in_cluster u32 = #intCast(cluster_offset / 512)
        const byte_in_sector u32 = #intCast(cluster_offset % 512)
        const lba = (fat32.clusterLba(&mount_info, cluster) catch return -1) + sector_in_cluster
        const read_fn = block_dev.sd_dev.read_fn orelse return -1
        if (read_fn(lba, sector_buf) != 0) { return -1 }
        const take u64 = #min(len - copied, 512 - byte_in_sector)
        // Explicit byte loop, NOT @memcpy: the sub-sector (take<512)
        // splice as `@memcpy(sector_buf[bis..][0..take], buf[..])`
        // lowered to an inlined store that the optimizer hoisted
        // ABOVE the preceding `read_fn(&sector_buf)` fn-pointer call,
        // so read_fn re-zeroed sector_buf[bis] after the splice — the
        // 1-byte ROUNDTR.MAG write read back 0x00 every boot while the
        // take=512 DAT path (lowered to an opaque memcpy call, not
        // reordered) worked. Indexing through the buffer keeps the
        // read_fn -> splice dependency the compiler must honour.
        // (Bare scoping block hoisted to fn scope — Flash has no
        // anonymous block statement; `si` is dead after the loop.)
        var si usize = 0
        while (si < take) {
            sector_buf[#as(usize, byte_in_sector) + si] = buf[#as(usize, #intCast(copied)) + si]
            si += 1
        }
        const write_fn = block_dev.sd_dev.write_fn orelse return -1
        if (write_fn(lba, sector_buf) != 0) { return -1 }
        copied += take
        cluster_offset += take
        if (cluster_offset >= mount_info.bytes_per_cluster && copied < len) {
            var next = fat32.readFatEntry(&mount_info, cluster) catch return -1
            if (next >= fat32.FAT_EOC_MIN) {
                next = fat32.allocCluster(&mount_info) catch return -1
                fat32.writeFatEntry(&mount_info, cluster, next) catch return -1
                // FSInfo free-count/next-free is an advisory hint; the
                // FAT chain and dir entry are already durable, so a
                // failed hint update only costs a slower future
                // allocation scan, never data — swallow it.
                fat32.fsInfoOnAlloc(&mount_info, next) catch {}
            }
            cluster = next
            cluster_offset = 0
        }
    }

    // Step 3: grow file_size on disk if the write went past EOF, at the
    // open-time-stashed dirent location (works for root + subdir files
    // and for the just-given first cluster — no re-walk, no ambiguity).
    const new_offset = f.offset + copied
    if (new_offset > f.size) {
        fat32.updateDirEntrySize(&mount_info, dirent_loc, #intCast(new_offset)) catch return -1
        f.size = new_offset
    }

    f.offset = new_offset
    return #bitCast(copied)
}

// readdir — enumerate the FAT32 mount root, one entry per call. Stateless
// like the rest of the VFS surface: the caller passes a fresh `index`, the
// walk re-reads the root chain and emits the `index`-th survivor. Root-only
// — only the mount root ("/", i.e. `/mnt/`) enumerates; a subdirectory
// listing needs a directory-cluster walk keyed off the entry's first
// cluster (deferred, no nested dirs in the demo image), so a
// non-root path lists empty. Skips the same entries lookupInRoot does
// (0x00 end-of-dir, 0xE5 deleted, LFN) plus the volume-label entry, which
// is not an enumerable file. Renders 8.3 via fat32.decode8_3; d_type from
// ATTR_DIRECTORY. Allocates nothing (one stack sector buffer). Runtime
// Pi-only: FAT32 does not mount under QEMU, so vfs.resolve("/mnt/*")
// returns null and sys_readdir answers -1 before reaching here.
fn readdir(_ *mut vfs.SuperBlock, path_ptr [*]u8, path_len usize, index u64, out *mut vfs.Dirent) callconv(.c) c_int {
    const path = path_ptr[0..path_len]
    if (!(path.len == 1 && path[0] == '/')) { return -1 } // root-only

    var cluster u32 = mount_info.bpb.root_clus
    const sector_buf = &io_sector_scratch
    var emitted u64 = 0
    // Cycle guard: a valid chain visits at most total_clusters links;
    // exceeding that proves a self-loop in a corrupted FAT, so stop.
    var hops u32 = 0
    while (cluster >= 2 && cluster < fat32.FAT_EOC_MIN) {
        const start_lba = fat32.clusterLba(&mount_info, cluster) catch return -1
        var i u32 = 0
        while (i < mount_info.sectors_per_cluster) {
            const lba = start_lba + i
            const read_fn = block_dev.sd_dev.read_fn orelse return -1
            if (read_fn(lba, sector_buf) != 0) { return -1 }
            var j u16 = 0
            while (j < 16) {
                const byte_off u16 = j * 32
                const first_byte = sector_buf[byte_off]
                if (first_byte == 0x00) { return -1 } // end-of-dir
                // 0xE5 = deleted, ATTR_LONG_NAME = VFAT slot, ATTR_VOLUME_ID
                // = label: all skipped. Inverted from the original's
                // `continue` guards because Flash has no continue-expression
                // — the scan index must still advance at the loop tail.
                if (first_byte != 0xE5) {
                    const attr = sector_buf[byte_off + 0x0B]
                    if ((attr & fat32.ATTR_LONG_NAME) != fat32.ATTR_LONG_NAME) {
                        if ((attr & fat32.ATTR_VOLUME_ID) == 0) {
                            if (emitted == index) {
                                var raw [11]u8 = undefined
                                #memcpy(&raw, sector_buf[byte_off..][0..11])
                                const dec = fat32.decode8_3(raw)
                                out.* = .{}
                                const n = #min(dec.len, out.name.len - 1)
                                #memcpy(out.name[0..n], dec.buf[0..n])
                                out.d_type = if ((attr & fat32.ATTR_DIRECTORY) != 0) vfs.DT_DIR else vfs.DT_REG
                                return 0
                            }
                            emitted += 1
                        }
                    }
                }
                j += 1
            }
            i += 1
        }
        cluster = fat32.readFatEntry(&mount_info, cluster) catch return -1
        hops += 1
        if (hops > mount_info.total_clusters) { return -1 }
    }
    return -1
}

// ---- create / unlink / rename — directory-entry mutation ----
//
// Layered over fat32.zig's slot/chain primitives. All three are
// file-only and (for rename) same-directory: mkdir / rmdir / cross-dir
// move are deferred (mv falls back to copy+unlink across directories).

// Split a mount-relative path into its parent directory and basename. A
// path with no slash has an empty parent (the mount root); a trailing
// slash yields an empty basename, which create/rename reject.
const PathSplit = struct {
    parent []u8,
    base []u8,
}
fn splitBasename(rel []u8) PathSplit {
    var ls ?usize = null
    var k usize = 0
    while (k < rel.len) {
        if (rel[k] == '/') { ls = k }
        k += 1
    }
    if ls |i| {
        return .{ .parent = rel[0..i], .base = rel[i + 1 ..] }
    }
    return .{ .parent = rel[0..0], .base = rel }
}

// Resolve a parent-directory path to its first cluster. An empty path is
// the mount root. Returns null when the path is missing or names a
// non-directory (so a create/rename into it fails closed).
fn resolveParentCluster(parent []u8) ?u32 {
    if (parent.len == 0) { return mount_info.bpb.root_clus }
    const pf = fat32.lookupPath(&mount_info, parent) catch return null
    if ((pf.entry.attr & fat32.ATTR_DIRECTORY) == 0) { return null }
    return fat32.firstCluster(pf.entry)
}

// Existence probe for an 8.3 name in a directory cluster, tri-state so the
// I/O-error case never reads as "free to create":
//   0  → name is free (the only go-ahead)
//   1  → name already taken
//  -1  → block-read / corrupt-FAT error
fn probeExists(dir_cluster u32, name8_3 [11]u8) i32 {
    // catch-into-tri-state: a clean lookup means the name is taken (1); a
    // NotFound is the only go-ahead (0); any other error is an I/O fault (-1).
    // The catch arm returns on every path, so control reaches the trailing
    // `return 1` only when the lookup succeeded.
    _ = fat32.lookupInDir(&mount_info, dir_cluster, name8_3) catch |err| {
        const not_found = err == error.NotFound
        if (not_found) { return 0 }
        return -1
    }
    return 1
}

// create — make a new empty file at `path` and hand back a writable handle.
// Splits off the basename, resolves the parent directory, rejects a >8.3
// name or an existing name, finds-or-extends a free directory slot, and
// stamps an empty entry (first_cluster 0, size 0). out is filled like open's
// writable side: dirent_lba/off point at the fresh entry so the first write
// grows it. The 0644 root:root perms are a baseline — sys_create overrides
// uid/gid with the caller's effective ids (the backend has no view of
// credentials; created-file perms do not persist across reboot).
fn create(_ *mut vfs.SuperBlock, path_ptr [*]u8, path_len usize, out *mut vfs.OpenResult) callconv(.c) c_int {
    const path = path_ptr[0..path_len]
    const rel = if (path.len > 0 && path[0] == '/') path[1..] else path
    const sp = splitBasename(rel)
    if (sp.base.len == 0) { return -1 } // trailing slash / empty name
    const parent_cluster = resolveParentCluster(sp.parent) orelse return -1
    const name8_3 = fat32.encode8_3(sp.base) orelse return -1 // reject a name that does not fit 8.3
    if (probeExists(parent_cluster, name8_3) != 0) { return -1 } // exists or I/O error
    const slot = fat32.findFreeDirSlot(&mount_info, parent_cluster) catch return -1
    fat32.writeDirEntry(&mount_info, slot.lba, slot.byte_offset, name8_3, fat32.ATTR_ARCHIVE, 0, 0) catch return -1
    out.private = 0 // empty file: no first cluster until the first write
    out.size = 0
    out.dirent_lba = slot.lba
    out.dirent_off = slot.byte_offset
    out.mode = 0o100644
    out.uid = 0
    out.gid = 0
    return 0
}

// unlink — remove the file at `path`: tombstone its 8.3 entry (0xE5) and free
// its cluster chain. Files only — a directory entry is refused (rmdir is
// future scope). The tombstone is written before the chain is freed: a
// failure after the tombstone leaks clusters (fsck-recoverable) rather than
// leaving a live entry pointing at freed clusters.
fn unlink(_ *mut vfs.SuperBlock, path_ptr [*]u8, path_len usize) callconv(.c) c_int {
    const path = path_ptr[0..path_len]
    const rel = if (path.len > 0 && path[0] == '/') path[1..] else path
    const found = fat32.lookupPath(&mount_info, rel) catch return -1
    if ((found.entry.attr & fat32.ATTR_DIRECTORY) != 0) { return -1 } // files only
    const first_clus = fat32.firstCluster(found.entry)
    fat32.markDeleted(&mount_info, found.lba, found.byte_offset) catch return -1
    fat32.freeChain(&mount_info, first_clus) catch return -1
    return 0
}

// rename — rewrite `old`'s 8.3 name to `new`'s within the same directory, no
// data move. Same-directory only: the parents must match (cross-directory is
// mv's copy+unlink job). Rejects a >8.3 new name and an existing target
// (no clobber); a same-name rename is a harmless no-op rewrite. Cluster, size
// and attributes are carried over from the existing entry.
fn rename(_ *mut vfs.SuperBlock, old_ptr [*]u8, old_len usize, new_ptr [*]u8, new_len usize) callconv(.c) c_int {
    const old_path = old_ptr[0..old_len]
    const new_path = new_ptr[0..new_len]
    const old_rel = if (old_path.len > 0 && old_path[0] == '/') old_path[1..] else old_path
    const new_rel = if (new_path.len > 0 && new_path[0] == '/') new_path[1..] else new_path
    const found = fat32.lookupPath(&mount_info, old_rel) catch return -1
    const old_sp = splitBasename(old_rel)
    const new_sp = splitBasename(new_rel)
    if (!std.mem.eql(u8, old_sp.parent, new_sp.parent)) { return -1 } // same-dir only
    if (new_sp.base.len == 0) { return -1 }
    const parent_cluster = resolveParentCluster(new_sp.parent) orelse return -1
    const new_name = fat32.encode8_3(new_sp.base) orelse return -1 // reject a name that does not fit 8.3
    const old_name = fat32.encode8_3(old_sp.base) orelse return -1
    // A rename to a different name must not clobber an existing target; a
    // same-name rename skips the probe (the entry would match itself).
    if (!std.mem.eql(u8, &old_name, &new_name)) {
        if (probeExists(parent_cluster, new_name) != 0) { return -1 }
    }
    fat32.writeDirEntry(&mount_info, found.lba, found.byte_offset, new_name, found.entry.attr, fat32.firstCluster(found.entry), found.entry.file_size) catch return -1
    return 0
}

// ---------------------------------------------------------------------
// FAT32 splice contract — sub-sector write at File.offset must land
// in the on-disk sector even when a hostile read_fn re-zeros
// sector_buf on the preceding read. The byte-loop splice at
// write():203-208 enforces this against the FAT32 splice reorder
// bug class (Zig 0.16 hoisted inlined @memcpy stores ABOVE the
// read_fn fn-pointer call on aarch64-elf freestanding under
// ReleaseSmall, so read_fn zeroed the splice).
//
// This host-test does NOT reproduce the reorder — the
// aarch64-darwin host LLVM pipeline keeps the splice below the call
// under both byte-loop AND inline-@memcpy variants (verified
// empirically against -Doptimize=ReleaseSmall, 2026-05-24). The
// real regression catcher is `[TEST] fs-roundtrip` on Pi-4 hardware.
// This block's job: (a) document the rationale inline so a future
// "cleanup" PR sees why the byte loop exists, (b) assert the splice
// contract so structural breakage of write() (wrong index, bleed)
// gets caught here.
//
// The antagonist/harvest fn-pointers below drop the original `noinline`
// (Flash has no such keyword): they are only ever called indirectly
// through the block_dev vtable, so they cannot be inlined regardless of
// the annotation — behavior identical.
// ---------------------------------------------------------------------

const testing = std.testing

var antagonist_read_calls u32 = 0
fn antagonistRead(_ u32, buf *mut [512]u8) callconv(.c) i32 {
    antagonist_read_calls += 1
    #memset(buf, 0)
    return 0
}

var harvest_sector [512]u8 align(4) = undefined
var harvest_writes u32 = 0
fn harvestWrite(_ u32, buf *[512]u8) callconv(.c) i32 {
    harvest_writes += 1
    #memcpy(&harvest_sector, buf)
    return 0
}

fn installAntagonist() void {
    antagonist_read_calls = 0
    harvest_writes = 0
    #memset(&harvest_sector, 0xCC)
    block_dev.sd_dev = .{ .read_fn = antagonistRead, .write_fn = harvestWrite }
}

fn installMountInfo() void {
    mount_info = .{
        .bpb = std.mem.zeroes(fat32.Bpb),
        .partition_lba = 0,
        .fat_lba = 2,
        .data_lba = 6,
        .sectors_per_cluster = 1,
        .bytes_per_cluster = 512,
        .fsinfo_lba = 1,
        .total_clusters = 124,
        .dev = &block_dev.sd_dev,
    }
}

test "splice contract: 1-byte sub-sector write lands at File.offset with no bleed" {
    installAntagonist()
    installMountInfo()

    var f File = .{
        .ftype = 0,
        .refs = 1,
        .offset = 100,
        .private = 3,
        .size = 512,
        .sb = &sb,
    }
    const payload [1]u8 = .{0xAA}

    const n = ops_vtable.write(&sb, &f, &payload, 1)

    try testing.expectEqual(#as(i64, 1), n)
    try testing.expectEqual(#as(u32, 1), antagonist_read_calls)
    try testing.expectEqual(#as(u32, 1), harvest_writes)
    try testing.expectEqual(#as(u8, 0xAA), harvest_sector[100])
    try testing.expectEqual(#as(u8, 0), harvest_sector[99])
    try testing.expectEqual(#as(u8, 0), harvest_sector[101])
}

test "splice contract: 4-byte sub-sector write lands at File.offset with no bleed" {
    installAntagonist()
    installMountInfo()

    var f File = .{
        .ftype = 0,
        .refs = 1,
        .offset = 200,
        .private = 3,
        .size = 512,
        .sb = &sb,
    }
    const payload = [_]u8{ 0xDE, 0xAD, 0xBE, 0xEF }

    const n = ops_vtable.write(&sb, &f, &payload, 4)

    try testing.expectEqual(#as(i64, 4), n)
    try testing.expectEqualSlices(u8, &payload, harvest_sector[200..204])
    try testing.expectEqual(#as(u8, 0), harvest_sector[199])
    try testing.expectEqual(#as(u8, 0), harvest_sector[204])
}

test "splice contract: whole-file same-length rewrite from offset 0 (shadow rewrite shape)" {
    // The sys_passwd write shape: the whole shadow file,
    // rewritten in place from offset 0 with byte-identical length.
    // Pins three contract points: (a) every byte lands exactly
    // (sub-sector splice through the byte loop), (b) no bleed past the
    // written length, (c) the same-length write never takes the
    // dir-entry resize branch — against this antagonist (no root
    // directory) that branch could only return -1, so a non-negative
    // return proves it was skipped.
    installAntagonist()
    installMountInfo()

    const content =
        "root:4096:" ++ ("aa" ** 16) ++ ":" ++ ("bb" ** 32) ++ "\n" ++
        "flash:4096:" ++ ("cc" ** 16) ++ ":" ++ ("dd" ** 32) ++ "\n"

    var f File = .{
        .ftype = 0,
        .refs = 1,
        .offset = 0,
        .private = 3,
        .size = content.len, // same length -> no resize
        .sb = &sb,
    }

    const n = ops_vtable.write(&sb, &f, content, content.len)

    try testing.expectEqual(#as(i64, #intCast(content.len)), n)
    try testing.expectEqualSlices(u8, content, harvest_sector[0..content.len])
    // No bleed past the written length (antagonist zeroed the rest).
    try testing.expectEqual(#as(u8, 0), harvest_sector[content.len])
    // Offset advanced to exactly size; size untouched (no resize).
    try testing.expectEqual(#as(u64, content.len), f.offset)
    try testing.expectEqual(#as(u64, content.len), f.size)
}

// readdir fixture — a real root-dir cluster for the enumeration walk
// (the splice tests above use an antagonist with no directory). LBA 0..7
// of an in-memory disk: FAT @ LBA 2 terminates the root chain (cluster 2
// -> EOC), root dir @ LBA 6 (data_lba 6, sec_per_clus 1) carries a volume
// label (skipped), a deleted entry (skipped), a file, and a subdirectory.
var rd_disk [8 * 512]u8 align(512) = undefined

fn rdRead(lba u32, buf *mut [512]u8) callconv(.c) i32 {
    const off usize = #as(usize, lba) * 512
    if (off + 512 > rd_disk.len) { return -1 }
    #memcpy(buf, rd_disk[off..][0..512])
    return 0
}

fn setupReaddirFixture() void {
    #memset(&rd_disk, 0)
    // FAT @ LBA 2: cluster 2 (root) -> EOC so the chain walk stops after
    // the single root cluster (entry 2 is at fat byte offset 8).
    std.mem.writeInt(u32, rd_disk[2 * 512 + 8 ..][0..4], fat32.FAT_EOC, .little)
    // Root dir @ LBA 6 (cluster 2).
    const root = rd_disk[6 * 512 .. 7 * 512]
    #memcpy(root[0..11], "SCRATCH    ") // volume label — skipped
    root[0x0B] = fat32.ATTR_VOLUME_ID
    #memcpy(root[32..][0..11], "?DELETEDBIN") // deleted — skipped
    root[32] = 0xE5
    #memcpy(root[64..][0..11], "HELLO   TXT") // regular file
    root[64 + 0x0B] = fat32.ATTR_ARCHIVE
    #memcpy(root[96..][0..11], "SUBDIR     ") // directory
    root[96 + 0x0B] = fat32.ATTR_DIRECTORY
    // Entry 4 onward: first byte 0x00 (end-of-dir) — already zeroed.
}

fn installReaddirMount() void {
    block_dev.sd_dev = .{ .read_fn = rdRead, .write_fn = null }
    var bpb = std.mem.zeroes(fat32.Bpb)
    bpb.root_clus = 2
    mount_info = .{
        .bpb = bpb,
        .partition_lba = 0,
        .fat_lba = 2,
        .data_lba = 6,
        .sectors_per_cluster = 1,
        .bytes_per_cluster = 512,
        .fsinfo_lba = 1,
        .total_clusters = 124,
        .dev = &block_dev.sd_dev,
    }
}

test "readdir lists root entries, skipping volume label and deleted" {
    setupReaddirFixture()
    installReaddirMount()

    var d vfs.Dirent = .{}
    // index 0 -> first real survivor (volume + deleted skipped).
    try testing.expectEqual(#as(c_int, 0), ops_vtable.readdir(&sb, "/".ptr, 1, 0, &d))
    try testing.expectEqualStrings("hello.txt", std.mem.sliceTo(&d.name, 0))
    try testing.expectEqual(vfs.DT_REG, d.d_type)
    // index 1 -> the subdirectory, flagged DT_DIR.
    try testing.expectEqual(#as(c_int, 0), ops_vtable.readdir(&sb, "/".ptr, 1, 1, &d))
    try testing.expectEqualStrings("subdir", std.mem.sliceTo(&d.name, 0))
    try testing.expectEqual(vfs.DT_DIR, d.d_type)
    // index 2 -> past the last survivor: end sentinel.
    try testing.expectEqual(#as(c_int, -1), ops_vtable.readdir(&sb, "/".ptr, 1, 2, &d))
}

test "readdir on a non-root path lists empty (root-only walk)" {
    setupReaddirFixture()
    installReaddirMount()
    var d vfs.Dirent = .{}
    try testing.expectEqual(#as(c_int, -1), ops_vtable.readdir(&sb, "/subdir".ptr, 7, 0, &d))
}

test "readdir terminates on self-looping FAT chain" {
    setupReaddirFixture()
    installReaddirMount()

    // Forge a 1-cluster cycle: the root cluster's FAT entry (cluster 2,
    // at fat byte 2*512 + 8) points back at itself instead of EOC.
    std.mem.writeInt(u32, rd_disk[2 * 512 + 8 ..][0..4], 2, .little)

    // Fill the root cluster (LBA 6) with valid, non-matching entries and
    // NO 0x00 end-of-dir marker, so the in-cluster scan never stops — the
    // walk must follow the self-loop and only the cycle guard can break
    // it. A hang here would be a cycle-guard regression.
    const root = rd_disk[6 * 512 .. 7 * 512]
    var j usize = 0
    while (j < 16) {
        const off = j * 32
        #memcpy(root[off .. off + 11], "OTHER   BIN")
        root[off + 0x0B] = fat32.ATTR_ARCHIVE
        j += 1
    }

    var d vfs.Dirent = .{}
    // A high index forces the walk to traverse every survivor and then
    // follow the chain; with the guard it terminates with the -1 sentinel
    // instead of spinning forever.
    try testing.expectEqual(#as(c_int, -1), ops_vtable.readdir(&sb, "/".ptr, 1, 9999, &d))
}

// Overlay fixture — a root dir carrying PERMS.TAB (with real text in a
// data cluster), SHADOW, and ROUNDTR.DAT, so applyOverlay() and the
// open() metadata selection run against a real lookup + read path.
// Reuses the readdir fixture's in-memory disk (rd_disk) + mount wiring.
const OVERLAY_TEXT = "PERMS.TAB 0600 0 0\nSHADOW 0640 0 0\n"

fn setupOverlayFixture() void {
    #memset(&rd_disk, 0)
    // FAT @ LBA 2: root chain (cluster 2) -> EOC; PERMS.TAB data
    // (cluster 3) -> EOC. FAT entry for cluster N sits at byte N*4.
    std.mem.writeInt(u32, rd_disk[2 * 512 + 8 ..][0..4], fat32.FAT_EOC, .little)
    std.mem.writeInt(u32, rd_disk[2 * 512 + 12 ..][0..4], fat32.FAT_EOC, .little)

    // Root dir @ LBA 6 (cluster 2, data_lba 6).
    const root = rd_disk[6 * 512 .. 7 * 512]
    // Entry 0: PERMS.TAB -> cluster 3, size = overlay text length.
    #memcpy(root[0..11], "PERMS   TAB")
    root[0x0B] = fat32.ATTR_ARCHIVE
    std.mem.writeInt(u16, root[0x1A..][0..2], 3, .little)
    std.mem.writeInt(u32, root[0x1C..][0..4], OVERLAY_TEXT.len, .little)
    // Entry 1: SHADOW -> cluster 4 (no data needed for open()).
    #memcpy(root[32..][0..11], "SHADOW     ")
    root[32 + 0x0B] = fat32.ATTR_ARCHIVE
    std.mem.writeInt(u16, root[32 + 0x1A ..][0..2], 4, .little)
    std.mem.writeInt(u32, root[32 + 0x1C ..][0..4], 100, .little)
    // Entry 2: ROUNDTR.DAT -> cluster 5.
    #memcpy(root[64..][0..11], "ROUNDTR DAT")
    root[64 + 0x0B] = fat32.ATTR_ARCHIVE
    std.mem.writeInt(u16, root[64 + 0x1A ..][0..2], 5, .little)
    std.mem.writeInt(u32, root[64 + 0x1C ..][0..4], 4096, .little)

    // PERMS.TAB data @ LBA 7 (cluster 3 = data_lba + (3-2)*1).
    #memcpy(rd_disk[7 * 512 ..][0..OVERLAY_TEXT.len], OVERLAY_TEXT)
}

test "overlay: annotated entries, the shadow floor override, and defaults" {
    setupOverlayFixture()
    installReaddirMount()

    applyOverlay()
    try testing.expect(overlay_ok)
    try testing.expectEqual(#as(usize, 2), overlay_count)

    var out vfs.OpenResult = .{}
    // SHADOW carries an explicit overlay entry (0640) — it overrides the
    // floor (operator's call).
    try testing.expectEqual(#as(c_int, 0), ops_vtable.open(&sb, "/shadow".ptr, 7, &out))
    try testing.expectEqual(#as(u32, 0o100640), out.mode)
    try testing.expectEqual(#as(u32, 0), out.uid)
    try testing.expectEqual(#as(u32, 0), out.gid)
    // PERMS.TAB protects itself through its self-entry.
    try testing.expectEqual(#as(c_int, 0), ops_vtable.open(&sb, "/perms.tab".ptr, 10, &out))
    try testing.expectEqual(#as(u32, 0o100600), out.mode)
    // ROUNDTR.DAT is un-annotated -> documented default.
    try testing.expectEqual(#as(c_int, 0), ops_vtable.open(&sb, "/roundtr.dat".ptr, 12, &out))
    try testing.expectEqual(#as(u32, 0o100666), out.mode)
}

test "overlay: absent PERMS.TAB floors shadow at 0600 and keeps defaults elsewhere" {
    setupOverlayFixture()
    installReaddirMount()

    // Delete the PERMS.TAB dir entry, then re-apply: the overlay is gone.
    rd_disk[6 * 512] = 0xE5
    applyOverlay()
    try testing.expect(!overlay_ok)
    try testing.expectEqual(#as(usize, 0), overlay_count)

    var out vfs.OpenResult = .{}
    // The shadow basename floors at 0600 root:root — a lost overlay
    // never exposes the on-card password file.
    try testing.expectEqual(#as(c_int, 0), ops_vtable.open(&sb, "/shadow".ptr, 7, &out))
    try testing.expectEqual(#as(u32, 0o100600), out.mode)
    try testing.expectEqual(#as(u32, 0), out.uid)
    try testing.expectEqual(#as(u32, 0), out.gid)
    // Everything else keeps the documented default.
    try testing.expectEqual(#as(c_int, 0), ops_vtable.open(&sb, "/roundtr.dat".ptr, 12, &out))
    try testing.expectEqual(#as(u32, 0o100666), out.mode)
}

test "overlay: corrupt PERMS.TAB content is rejected wholesale (floor applies)" {
    setupOverlayFixture()
    installReaddirMount()

    // Corrupt the mode field in the data cluster: "PERMS.TAB 0600 ..."
    // -> "PERMS.TAB x600 ..." — overlay.parse rejects the whole file.
    rd_disk[7 * 512 + 10] = 'x'
    applyOverlay()
    try testing.expect(!overlay_ok)
    try testing.expectEqual(#as(usize, 0), overlay_count)

    var out vfs.OpenResult = .{}
    // With the table empty, the floor still protects the shadow file.
    try testing.expectEqual(#as(c_int, 0), ops_vtable.open(&sb, "/shadow".ptr, 7, &out))
    try testing.expectEqual(#as(u32, 0o100600), out.mode)
}

// ---- empty-file write fixture (read + write in-memory disk) ----
//
// The splice tests above use an antagonist with no real storage; this
// one is an 8-sector read+write disk so the first-write-to-an-empty-file
// path runs end to end. Layout: FAT @ LBA 2 (root cluster 2 -> EOC,
// clusters 3.. FREE), root dir @ LBA 6 with one empty file (EMPTY.TXT,
// first_cluster 0, size 0). data_lba 6, sec/clus 1, so cluster N lives at
// LBA 6 + (N - 2).
var rw_disk [8 * 512]u8 align(512) = undefined

fn rwRead(lba u32, buf *mut [512]u8) callconv(.c) i32 {
    const off usize = #as(usize, lba) * 512
    if (off + 512 > rw_disk.len) { return -1 }
    #memcpy(buf, rw_disk[off..][0..512])
    return 0
}

fn rwWrite(lba u32, buf *[512]u8) callconv(.c) i32 {
    const off usize = #as(usize, lba) * 512
    if (off + 512 > rw_disk.len) { return -1 }
    #memcpy(rw_disk[off..][0..512], buf)
    return 0
}

fn setupEmptyFileFixture() void {
    #memset(&rw_disk, 0)
    // FAT @ LBA 2: root cluster 2 -> EOC; clusters 3+ left FREE (0).
    std.mem.writeInt(u32, rw_disk[2 * 512 + 8 ..][0..4], fat32.FAT_EOC, .little)
    // Root dir @ LBA 6 (cluster 2): one empty file at entry 0.
    const root = rw_disk[6 * 512 .. 7 * 512]
    #memcpy(root[0..11], "EMPTY   TXT")
    root[0x0B] = fat32.ATTR_ARCHIVE
    // fst_clus_hi/lo = 0, file_size = 0 — already zeroed.
}

fn installRwMount() void {
    block_dev.sd_dev = .{ .read_fn = rwRead, .write_fn = rwWrite }
    var bpb = std.mem.zeroes(fat32.Bpb)
    bpb.root_clus = 2
    bpb.num_fats = 1 // single FAT in this fixture (no mirror)
    mount_info = .{
        .bpb = bpb,
        .partition_lba = 0,
        .fat_lba = 2,
        .data_lba = 6,
        .sectors_per_cluster = 1,
        .bytes_per_cluster = 512,
        .fsinfo_lba = 1,
        .total_clusters = 124,
        .dev = &block_dev.sd_dev,
    }
}

test "write to an empty file allocates a first cluster and records it in the dir entry" {
    setupEmptyFileFixture()
    installRwMount()

    // open() resolves the entry and stashes its on-disk location.
    var out vfs.OpenResult = .{}
    try testing.expectEqual(#as(c_int, 0), ops_vtable.open(&sb, "/empty.txt".ptr, 10, &out))
    try testing.expectEqual(#as(u64, 0), out.private) // empty -> first_cluster 0
    try testing.expectEqual(#as(u32, 6), out.dirent_lba) // root dir LBA
    try testing.expectEqual(#as(u32, 0), out.dirent_off) // first entry

    // Build the handle the way sys_openFile would (private + dirent loc).
    var f File = .{
        .refs = 1,
        .offset = 0,
        .private = out.private,
        .size = out.size,
        .sb = &sb,
        .dirent_lba = out.dirent_lba,
        .dirent_off = out.dirent_off,
    }

    const payload = [_]u8{ 0xDE, 0xAD, 0xBE, 0xEF }
    try testing.expectEqual(#as(i64, 4), ops_vtable.write(&sb, &f, &payload, 4))

    // (a) allocCluster reserved cluster 3 and linked it EOC.
    try testing.expectEqual(fat32.FAT_EOC, fat32.readFatEntry(&mount_info, 3) catch unreachable)
    // (b) the dir entry now points at cluster 3 (hi:lo = 0:3).
    const root = rw_disk[6 * 512 .. 7 * 512]
    try testing.expectEqual(#as(u16, 3), std.mem.readInt(u16, root[0x1A..][0..2], .little))
    try testing.expectEqual(#as(u16, 0), std.mem.readInt(u16, root[0x14..][0..2], .little))
    // (c) the dir-entry size grew to 4.
    try testing.expectEqual(#as(u32, 4), std.mem.readInt(u32, root[0x1C..][0..4], .little))
    // (d) the bytes landed at cluster 3's LBA (6 + (3 - 2) = 7).
    try testing.expectEqualSlices(u8, &payload, rw_disk[7 * 512 ..][0..4])
    // (e) the handle adopted the new chain head and grew its size.
    try testing.expectEqual(#as(u64, 3), f.private)
    try testing.expectEqual(#as(u64, 4), f.size)
}

test "write past EOF on an existing file grows size via the stashed dirent location" {
    setupEmptyFileFixture()
    // Pre-seed EMPTY.TXT with a first cluster (3) + size 4, FAT[3] -> EOC.
    // (Bare scoping block hoisted; `seed_root` renamed off the later `root`
    // — Flash has no anonymous block statement.)
    const seed_root = rw_disk[6 * 512 .. 7 * 512]
    std.mem.writeInt(u16, seed_root[0x1A..][0..2], 3, .little) // fst_clus_lo = 3
    std.mem.writeInt(u32, seed_root[0x1C..][0..4], 4, .little) // size = 4
    std.mem.writeInt(u32, rw_disk[2 * 512 + 12 ..][0..4], fat32.FAT_EOC, .little) // FAT[3]
    installRwMount()

    var f File = .{
        .refs = 1,
        .offset = 4, // append at the current EOF
        .private = 3,
        .size = 4,
        .sb = &sb,
        .dirent_lba = 6,
        .dirent_off = 0,
    }
    const payload = [_]u8{ 0x11, 0x22 }
    try testing.expectEqual(#as(i64, 2), ops_vtable.write(&sb, &f, &payload, 2))

    const root = rw_disk[6 * 512 .. 7 * 512]
    // Size grew 4 -> 6 in the dir entry (step 3 via the stashed location,
    // not the removed first-cluster re-walk).
    try testing.expectEqual(#as(u32, 6), std.mem.readInt(u32, root[0x1C..][0..4], .little))
    // The appended bytes landed at cluster 3, byte offset 4 (LBA 7).
    try testing.expectEqualSlices(u8, &payload, rw_disk[7 * 512 + 4 ..][0..2])
    try testing.expectEqual(#as(u64, 6), f.size)
}

// ---- create / unlink / rename (read+write in-memory disk) ----
//
// Reuse the empty-file fixture's 8-sector rw_disk: root cluster 2 -> EOC,
// EMPTY.TXT at root slot 0 (first_cluster 0, size 0), clusters 3+ free.

// Seed EMPTY.TXT with a one-cluster chain (cluster 3, size 4) so unlink /
// rename have a real chain to act on. Patches the dir entry + FAT[3].
fn seedEmptyWithChain() void {
    const root = rw_disk[6 * 512 .. 7 * 512]
    std.mem.writeInt(u16, root[0x1A..][0..2], 3, .little) // fst_clus_lo = 3
    std.mem.writeInt(u32, root[0x1C..][0..4], 4, .little) // size = 4
    std.mem.writeInt(u32, rw_disk[2 * 512 + 12 ..][0..4], fat32.FAT_EOC, .little) // FAT[3]
}

test "create stamps a new entry that open then resolves" {
    setupEmptyFileFixture()
    installRwMount()

    var out vfs.OpenResult = .{}
    try testing.expectEqual(#as(c_int, 0), ops_vtable.create(&sb, "/new.fl".ptr, 7, &out))
    try testing.expectEqual(#as(u64, 0), out.private) // empty: no first cluster yet
    try testing.expectEqual(#as(u64, 0), out.size)
    try testing.expectEqual(#as(u32, 0o100644), out.mode)
    // The new entry took the first free slot (slot 1; slot 0 is EMPTY.TXT).
    try testing.expectEqual(#as(u32, 6), out.dirent_lba)
    try testing.expectEqual(#as(u32, 32), out.dirent_off)

    // open now resolves it.
    var o2 vfs.OpenResult = .{}
    try testing.expectEqual(#as(c_int, 0), ops_vtable.open(&sb, "/new.fl".ptr, 7, &o2))
    try testing.expectEqual(#as(u64, 0), o2.size)
}

test "create rejects an existing name" {
    setupEmptyFileFixture()
    installRwMount()
    var out vfs.OpenResult = .{}
    // EMPTY.TXT already exists at the root.
    try testing.expectEqual(#as(c_int, -1), ops_vtable.create(&sb, "/empty.txt".ptr, 10, &out))
}

test "create rejects a name that does not fit 8.3" {
    setupEmptyFileFixture()
    installRwMount()
    var out vfs.OpenResult = .{}
    // "toolongname" is 11 chars (> 8) — encode8_3 rejects, create fails clean.
    try testing.expectEqual(#as(c_int, -1), ops_vtable.create(&sb, "/toolongname.flash".ptr, 18, &out))
}

test "unlink removes a file and frees its cluster chain" {
    setupEmptyFileFixture()
    seedEmptyWithChain()
    installRwMount()

    try testing.expectEqual(#as(c_int, 0), ops_vtable.unlink(&sb, "/empty.txt".ptr, 10))
    // open now misses (the entry is tombstoned).
    var out vfs.OpenResult = .{}
    try testing.expectEqual(#as(c_int, -1), ops_vtable.open(&sb, "/empty.txt".ptr, 10, &out))
    // Cluster 3 was returned to the free list.
    try testing.expectEqual(fat32.FAT_FREE, fat32.readFatEntry(&mount_info, 3) catch unreachable)
}

test "rename rewrites a name in place, preserving cluster and size" {
    setupEmptyFileFixture()
    seedEmptyWithChain()
    installRwMount()

    try testing.expectEqual(#as(c_int, 0), ops_vtable.rename(&sb, "/empty.txt".ptr, 10, "/renamed.fl".ptr, 11))
    // The new name resolves with the same first cluster + size.
    var out vfs.OpenResult = .{}
    try testing.expectEqual(#as(c_int, 0), ops_vtable.open(&sb, "/renamed.fl".ptr, 11, &out))
    try testing.expectEqual(#as(u64, 4), out.size)
    try testing.expectEqual(#as(u64, 3), out.private)
    // The old name is gone.
    try testing.expectEqual(#as(c_int, -1), ops_vtable.open(&sb, "/empty.txt".ptr, 10, &out))
}

test "rename rejects a cross-directory move (different parent)" {
    setupEmptyFileFixture()
    installRwMount()
    // old is root-level, new names a subdirectory: the same-dir guard rejects
    // before any rewrite (cross-dir is mv's copy+unlink job).
    try testing.expectEqual(#as(c_int, -1), ops_vtable.rename(&sb, "/empty.txt".ptr, 10, "/sub/empty.txt".ptr, 14))
}