ajhahn.de
← FlashOS
Flash 1207 lines
// fat32: single source of truth for on-disk layout.
//
// All field offsets / sizes follow Microsoft's "FAT: General Overview
// of On-Disk Format" v1.03. Implements:
//   * BPB parse (only the fields used: BytsPerSec, SecPerClus,
//     RsvdSecCnt, NumFATs, FATSz32, RootClus, FSInfo)
//   * FAT table read/write (cluster -> next cluster, allocate fresh)
//   * Directory entry decode (8.3 only, no VFAT/LFN)
//   * Cluster allocate (linear scan, mark EOC, update FSInfo hints)
//   * FSInfo update (free_count decrement/increment + next_free hint)
//   * Directory-entry create (find/extend a free slot, stamp 8.3),
//     delete (0xE5 tombstone), and cluster-chain free — the metadata
//     primitives the create / unlink / rename VFS ops are built on
//
// Out of scope: LFN, attributes-as-permissions, sub-second
// timestamps, sub-directory creation (mkdir is future work).
//
// Block I/O is the caller's responsibility — every helper takes a
// `*Mount` (or `*mut Mount` for write paths) holding the
// `*block_dev.BlockDev` runtime pointer. That keeps fat32
// host-testable against an in-memory fake without a freestanding
// dependency.
//
// Layout decision: `packed struct` for the two on-disk types
// accessed by field (Bpb + DirEntry). An `extern struct`
// follows the C ABI and inserts alignment padding (u16 @ 0x0B
// bumps to 0x0C), which silently breaks every offset assumption
// against the FAT32 spec. Packed structs preserve bit-exact layout
// for known integer types; comptime asserts pin the spec offsets.
//
// FSInfo is decoded/encoded via std.mem.readInt / writeInt against
// the 512-byte sector buffer — a packed struct would need a u3712
// gap field that pushes some compilers over an internal size
// limit, and the only three fields touched (lead/struc/free_count
// + next_free) are easier read byte-wise.

const std = #import("std")
const block_dev = #import("block_dev")

pub const Bpb = packed struct {
    jmp_boot u24, // 0x00 (3 bytes)
    oem_name u64, // 0x03 (8 bytes — opaque)
    bytes_per_sec u16, // 0x0B
    sec_per_clus u8, // 0x0D
    rsvd_sec_cnt u16, // 0x0E
    num_fats u8, // 0x10
    root_ent_cnt u16, // 0x11 — must be 0 on FAT32
    tot_sec_16 u16, // 0x13 — must be 0 on FAT32
    media u8, // 0x15
    fat_sz_16 u16, // 0x16 — must be 0 on FAT32
    sec_per_trk u16, // 0x18
    num_heads u16, // 0x1A
    hidd_sec u32, // 0x1C
    tot_sec_32 u32, // 0x20
    fat_sz_32 u32, // 0x24
    ext_flags u16, // 0x28
    fs_ver u16, // 0x2A
    root_clus u32, // 0x2C
    fs_info u16, // 0x30
    bk_boot_sec u16, // 0x32
    reserved u96, // 0x34 (12 bytes)
    drv_num u8, // 0x40
    reserved1 u8, // 0x41
    boot_sig u8, // 0x42
    vol_id u32, // 0x43
    vol_lab_lo u64, // 0x47 (8 of 11)
    vol_lab_hi u24, // 0x4F (3 of 11)
    fil_sys_type u64, // 0x52 (8 bytes)
}
comptime {
    if #bitOffsetOf(Bpb, "fat_sz_32") / 8 != 0x24 { #compileError("BPB fat_sz_32 offset") }
    if #bitOffsetOf(Bpb, "root_clus") / 8 != 0x2C { #compileError("BPB root_clus offset") }
    if #bitOffsetOf(Bpb, "fs_info") / 8 != 0x30 { #compileError("BPB fs_info offset") }
}

pub const DirEntry = packed struct {
    name_lo u64, // 0x00 (8 of 11 — 8.3 first half)
    name_hi u24, // 0x08 (3 of 11 — 8.3 extension)
    attr u8, // 0x0B
    nt_res u8, // 0x0C
    crt_time_tenth u8, // 0x0D
    crt_time u16, // 0x0E
    crt_date u16, // 0x10
    lst_acc_date u16, // 0x12
    fst_clus_hi u16, // 0x14
    wrt_time u16, // 0x16
    wrt_date u16, // 0x18
    fst_clus_lo u16, // 0x1A
    file_size u32, // 0x1C
}
comptime {
    if #sizeOf(DirEntry) != 32 { #compileError("DirEntry size") }
    if #bitOffsetOf(DirEntry, "attr") / 8 != 0x0B { #compileError("DirEntry attr offset") }
    if #bitOffsetOf(DirEntry, "file_size") / 8 != 0x1C { #compileError("DirEntry file_size offset") }
}

pub const ATTR_READ_ONLY u8 = 0x01
pub const ATTR_HIDDEN u8 = 0x02
pub const ATTR_SYSTEM u8 = 0x04
pub const ATTR_VOLUME_ID u8 = 0x08
pub const ATTR_DIRECTORY u8 = 0x10
pub const ATTR_ARCHIVE u8 = 0x20
pub const ATTR_LONG_NAME u8 = 0x0F

pub const FAT_EOC u32 = 0x0FFFFFFF
pub const FAT_FREE u32 = 0
pub const FAT_BAD u32 = 0x0FFFFFF7
// End-of-chain test threshold. The FAT32 spec marks end-of-chain as
// ANY value >= 0x0FFFFFF8 (mkfs/mformat write 0x0FFFFFF8 or
// 0x0FFFFFFF interchangeably). Chain walkers must test
// `>= FAT_EOC_MIN`, not `== FAT_EOC` / `>= FAT_EOC` — otherwise a
// 0x0FFFFFF8 terminator reads as a (bogus) next cluster. allocCluster
// keeps writing FAT_EOC (0x0FFFFFFF), which is inside this range, so
// freshly-extended chains terminate correctly under the same test.
pub const FAT_EOC_MIN u32 = 0x0FFFFFF8

pub const FSINFO_LEAD_SIG u32 = 0x41615252
pub const FSINFO_STRUC_SIG u32 = 0x61417272
pub const FSINFO_TRAIL_SIG u32 = 0xAA550000

pub const Mount = struct {
    bpb Bpb,
    partition_lba u32,
    fat_lba u32,
    data_lba u32,
    sectors_per_cluster u32,
    bytes_per_cluster u32,
    fsinfo_lba u32,
    total_clusters u32,
    dev *block_dev.BlockDev,
}

pub const SectorBuf = [512]u8

// Kernel-stack relief (same posture as
// fat32_backend.harvest_sector and sys.zig's auth_scratch): these sector
// buffers used to be stack locals. The FAT32 syscall chains stack up to
// three 512-byte sector buffers at once, which overflows the ~2.2 KiB
// per-task kernel stack into the TaskStruct tail (credentials, fd table)
// on real hardware — QEMU never exercises these paths (EMMC2 dies at
// CMD8). Statics are safe: every VFS dispatch in sys.zig runs under
// preempt_disable, so at most one FAT32 operation is in flight; the two
// buffers are separate because lookupInRoot's directory walk calls
// readFatEntry while its own sector is still live.
var dir_sector_scratch SectorBuf align(4) = undefined
var fat_sector_scratch SectorBuf align(4) = undefined

pub const MountError = error{ BadBpb, NotFat32, BlockReadFailed }

pub fn mount(dev *block_dev.BlockDev, partition_lba u32) MountError!Mount {
    var sector SectorBuf align(4) = undefined
    const read_fn = dev.read_fn orelse return error.BlockReadFailed
    if read_fn(partition_lba, &sector) != 0 { return error.BlockReadFailed }
    if std.mem.readInt(u16, sector[510..512], .little) != 0xAA55 { return error.BadBpb }
    const bytes_per_sec = std.mem.readInt(u16, sector[0x0B..0x0D], .little)
    const sec_per_clus = sector[0x0D]
    const rsvd_sec_cnt = std.mem.readInt(u16, sector[0x0E..0x10], .little)
    const num_fats = sector[0x10]
    const root_ent_cnt = std.mem.readInt(u16, sector[0x11..0x13], .little)
    const tot_sec_16 = std.mem.readInt(u16, sector[0x13..0x15], .little)
    const fat_sz_16 = std.mem.readInt(u16, sector[0x16..0x18], .little)
    const tot_sec_32 = std.mem.readInt(u32, sector[0x20..0x24], .little)
    const fat_sz_32 = std.mem.readInt(u32, sector[0x24..0x28], .little)
    const root_clus = std.mem.readInt(u32, sector[0x2C..0x30], .little)
    const fs_info = std.mem.readInt(u16, sector[0x30..0x32], .little)

    if bytes_per_sec != 512 { return error.BadBpb }
    if sec_per_clus == 0 || (sec_per_clus & (sec_per_clus - 1)) != 0 { return error.BadBpb }
    if num_fats < 1 || num_fats > 2 { return error.BadBpb }
    if fat_sz_32 == 0 || fat_sz_16 != 0 { return error.NotFat32 }
    if root_ent_cnt != 0 || tot_sec_16 != 0 { return error.NotFat32 }
    if root_clus < 2 { return error.NotFat32 }

    var bpb Bpb = undefined
    bpb.bytes_per_sec = bytes_per_sec
    bpb.sec_per_clus = sec_per_clus
    bpb.rsvd_sec_cnt = rsvd_sec_cnt
    bpb.num_fats = num_fats
    bpb.tot_sec_32 = tot_sec_32
    bpb.fat_sz_32 = fat_sz_32
    bpb.root_clus = root_clus
    bpb.fs_info = fs_info

    const fat_lba = partition_lba + rsvd_sec_cnt
    // Compute the FAT-region span in u64 so a malformed num_fats *
    // fat_sz_32 cannot overflow the u32 product before validation.
    const fat_region = #as(u64, num_fats) * #as(u64, fat_sz_32)
    const data_lba_u64 = #as(u64, fat_lba) + fat_region
    // Reject a BPB whose data region wraps below the partition start
    // or begins past the volume's end — either would underflow the
    // total_clusters subtraction below and yield a bogus geometry.
    if data_lba_u64 < partition_lba { return error.BadBpb }
    if data_lba_u64 - partition_lba > tot_sec_32 { return error.BadBpb }
    const data_lba u32 = #intCast(data_lba_u64)
    const total_clusters = (tot_sec_32 - (data_lba - partition_lba)) / sec_per_clus + 2

    return .{
        .bpb = bpb,
        .partition_lba = partition_lba,
        .fat_lba = fat_lba,
        .data_lba = data_lba,
        .sectors_per_cluster = sec_per_clus,
        .bytes_per_cluster = #as(u32, sec_per_clus) * bytes_per_sec,
        .fsinfo_lba = partition_lba + fs_info,
        .total_clusters = total_clusters,
        .dev = dev,
    }
}

pub fn clusterLba(m *Mount, cluster u32) FatError!u32 {
    // Fail closed: clusters 0 and 1 are reserved (0 = "empty file" in a
    // directory entry, 1 = reserved), so `(cluster - 2)` would u32-
    // underflow to a huge multiplier and yield a wild out-of-bounds LBA.
    // readFatEntry range-checks chain values, but a directory entry's
    // first_cluster reaches the I/O loops unchecked — guard at the source.
    if cluster < 2 { return error.InvalidCluster }
    return m.data_lba + (cluster - 2) * m.sectors_per_cluster
}

pub const FatError = error{ BlockReadFailed, BlockWriteFailed, InvalidCluster }

pub fn readFatEntry(m *Mount, cluster u32) FatError!u32 {
    if cluster < 2 || cluster >= m.total_clusters { return error.InvalidCluster }
    const fat_offset u32 = cluster * 4
    const lba = m.fat_lba + fat_offset / 512
    const sector = &fat_sector_scratch
    const read_fn = m.dev.read_fn orelse return error.BlockReadFailed
    if read_fn(lba, sector) != 0 { return error.BlockReadFailed }
    const idx_byte = fat_offset % 512
    return std.mem.readInt(u32, sector[idx_byte..][0..4], .little) & 0x0FFFFFFF
}

pub fn writeFatEntry(m *mut Mount, cluster u32, value u32) FatError!void {
    if cluster < 2 || cluster >= m.total_clusters { return error.InvalidCluster }
    const fat_offset u32 = cluster * 4
    const lba = m.fat_lba + fat_offset / 512
    const sector = &fat_sector_scratch
    const read_fn = m.dev.read_fn orelse return error.BlockReadFailed
    if read_fn(lba, sector) != 0 { return error.BlockReadFailed }
    const idx_byte = fat_offset % 512
    const old = std.mem.readInt(u32, sector[idx_byte..][0..4], .little)
    const new = (old & 0xF0000000) | (value & 0x0FFFFFFF)
    std.mem.writeInt(u32, sector[idx_byte..][0..4], new, .little)
    const write_fn = m.dev.write_fn orelse return error.BlockWriteFailed
    if write_fn(lba, sector) != 0 { return error.BlockWriteFailed }
    if m.bpb.num_fats >= 2 {
        const lba2 = lba + m.bpb.fat_sz_32
        if write_fn(lba2, sector) != 0 { return error.BlockWriteFailed }
    }
}

pub const AllocError = error{ NoSpace, BlockReadFailed, BlockWriteFailed, InvalidCluster }

pub fn allocCluster(m *mut Mount) AllocError!u32 {
    // Linear scan from cluster 2 upward. Future work replaces with
    // the FSInfo next_free hint when contention shows up;
    // single-writer + small disks makes the scan fast
    // enough.
    var cluster u32 = 2
    while cluster < m.total_clusters {
        const entry = try readFatEntry(m, cluster)
        if entry == FAT_FREE {
            try writeFatEntry(m, cluster, FAT_EOC)
            return cluster
        }
        cluster += 1
    }
    return error.NoSpace
}

pub fn fsInfoOnAlloc(m *mut Mount, allocated_cluster u32) FatError!void {
    const sector = &fat_sector_scratch
    const read_fn = m.dev.read_fn orelse return error.BlockReadFailed
    if read_fn(m.fsinfo_lba, sector) != 0 { return error.BlockReadFailed }
    const lead = std.mem.readInt(u32, sector[0x000..0x004], .little)
    const struc = std.mem.readInt(u32, sector[0x1E4..0x1E8], .little)
    if lead != FSINFO_LEAD_SIG || struc != FSINFO_STRUC_SIG {
        // Corrupted FSInfo — bail without touching it. A future
        // fsck recomputes; trying to "fix" it here risks compounding
        // the damage.
        return
    }
    var free_count = std.mem.readInt(u32, sector[0x1E8..0x1EC], .little)
    if free_count != 0xFFFFFFFF && free_count > 0 { free_count -= 1 }
    std.mem.writeInt(u32, sector[0x1E8..0x1EC], free_count, .little)
    std.mem.writeInt(u32, sector[0x1EC..0x1F0], allocated_cluster + 1, .little)
    const write_fn = m.dev.write_fn orelse return error.BlockWriteFailed
    if write_fn(m.fsinfo_lba, sector) != 0 { return error.BlockWriteFailed }
}

pub const FoundEntry = struct {
    entry DirEntry,
    lba u32,
    byte_offset u16,
}

pub const LookupError = error{ NotFound, BlockReadFailed, InvalidCluster }

pub fn lookupInRoot(m *Mount, name8_3 [11]u8) LookupError!FoundEntry {
    return lookupInDir(m, m.bpb.root_clus, name8_3)
}

// Scan a single directory's cluster chain (starting at `start_cluster`)
// for an 8.3 entry. lookupInRoot is the root-directory specialisation;
// lookupPath calls this once per path component to descend
// subdirectories.
pub fn lookupInDir(m *Mount, start_cluster u32, name8_3 [11]u8) LookupError!FoundEntry {
    var cluster u32 = start_cluster
    const sector_buf = &dir_sector_scratch
    // Cycle guard: a valid chain visits at most total_clusters links;
    // exceeding that proves a self-loop or back-edge in a corrupted
    // FAT, so terminate rather than spin forever.
    var hops u32 = 0
    while cluster >= 2 && cluster < FAT_EOC_MIN {
        const start_lba = clusterLba(m, cluster) catch { return error.InvalidCluster }
        var i u32 = 0
        while i < m.sectors_per_cluster {
            const lba = start_lba + i
            const read_fn = m.dev.read_fn orelse return error.BlockReadFailed
            if read_fn(lba, sector_buf) != 0 { return error.BlockReadFailed }
            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 error.NotFound } // end-of-dir
                // 0xE5 = deleted, ATTR_LONG_NAME = VFAT slot: both 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 & ATTR_LONG_NAME) != ATTR_LONG_NAME {
                        if std.mem.eql(u8, sector_buf[byte_off..][0..11], &name8_3) {
                            var e DirEntry = undefined
                            e.name_lo = std.mem.readInt(u64, sector_buf[byte_off..][0..8], .little)
                            e.name_hi = std.mem.readInt(u24, sector_buf[byte_off + 8 ..][0..3], .little)
                            e.attr = attr
                            e.fst_clus_hi = std.mem.readInt(u16, sector_buf[byte_off + 0x14 ..][0..2], .little)
                            e.fst_clus_lo = std.mem.readInt(u16, sector_buf[byte_off + 0x1A ..][0..2], .little)
                            e.file_size = std.mem.readInt(u32, sector_buf[byte_off + 0x1C ..][0..4], .little)
                            return .{ .entry = e, .lba = lba, .byte_offset = byte_off }
                        }
                    }
                }
                j += 1
            }
            i += 1
        }
        cluster = readFatEntry(m, cluster) catch |err| {
            return switch err {
                error.InvalidCluster => error.InvalidCluster,
                error.BlockReadFailed, error.BlockWriteFailed => error.BlockReadFailed,
            }
        }
        hops += 1
        if hops > m.total_clusters { return error.NotFound }
    }
    return error.NotFound
}

// Combine a directory entry's split first-cluster fields into the
// 28-bit cluster number.
pub fn firstCluster(e DirEntry) u32 {
    return (#as(u32, e.fst_clus_hi) << 16) | e.fst_clus_lo
}

pub const PathError = error{ NotFound, NotADirectory, BlockReadFailed, InvalidCluster }

// Resolve a mount-relative, '/'-separated path to its directory entry,
// descending into ATTR_DIRECTORY entries for every non-final component.
// `rel` carries no leading slash (open() strips it; vfs.resolve already
// removed the /mnt prefix). A single-component path reduces to a
// root-directory lookup — the historical behaviour. A non-final
// component that resolves to a regular file yields NotADirectory; an
// empty path (the mount root itself) yields NotFound — a directory is
// not an openable file here.
//
// Iterative, not recursive: nesting depth costs only a handful of
// scalars on the stack, and each per-component lookup reuses the static
// directory scratch — so deep paths never threaten the kernel-stack
// budget (the prior sys_openFile overflow class).
pub fn lookupPath(m *Mount, rel []u8) PathError!FoundEntry {
    var dir_cluster u32 = m.bpb.root_clus
    var found ?FoundEntry = null

    var i usize = 0
    while i < rel.len {
        // Skip slash runs so a doubled or trailing slash never produces
        // an empty component (vfs.resolve collapses these, but fat32
        // stays self-contained for the host suite).
        while i < rel.len && rel[i] == '/' { i += 1 }
        if i >= rel.len { break }
        var j = i
        while j < rel.len && rel[j] != '/' { j += 1 }
        const comp = rel[i..j]
        i = j

        // Another component follows, so the entry matched last round had
        // to be a directory — descend into it before this lookup.
        if found |prev| {
            if (prev.entry.attr & ATTR_DIRECTORY) == 0 { return error.NotADirectory }
            dir_cluster = firstCluster(prev.entry)
        }

        const name = encode8_3(comp) orelse return error.NotFound
        found = try lookupInDir(m, dir_cluster, name)
    }

    return found orelse error.NotFound
}

pub fn updateDirEntrySize(m *mut Mount, found FoundEntry, new_size u32) FatError!void {
    // Shares fat_sector_scratch: self-contained RMW, and at its only call
    // site (fat32_backend.write step 3) every other scratch user is done.
    const sector_buf = &fat_sector_scratch
    const read_fn = m.dev.read_fn orelse return error.BlockReadFailed
    if read_fn(found.lba, sector_buf) != 0 { return error.BlockReadFailed }
    std.mem.writeInt(u32, sector_buf[found.byte_offset + 0x1C ..][0..4], new_size, .little)
    const write_fn = m.dev.write_fn orelse return error.BlockWriteFailed
    if write_fn(found.lba, sector_buf) != 0 { return error.BlockWriteFailed }
}

// Rewrite a directory entry's first-cluster fields. FAT32 splits the
// 32-bit cluster across two non-adjacent u16s — fst_clus_hi @0x14,
// fst_clus_lo @0x1A — so this writes both. Used when a previously empty
// file (first_cluster == 0) is given its first data cluster on the first
// write. Self-contained RMW on fat_sector_scratch, same posture as
// updateDirEntrySize; only found.lba + found.byte_offset are read.
pub fn updateDirEntryFirstCluster(m *mut Mount, found FoundEntry, cluster u32) FatError!void {
    const sector_buf = &fat_sector_scratch
    const read_fn = m.dev.read_fn orelse return error.BlockReadFailed
    if read_fn(found.lba, sector_buf) != 0 { return error.BlockReadFailed }
    std.mem.writeInt(u16, sector_buf[found.byte_offset + 0x14 ..][0..2], #truncate(cluster >> 16), .little)
    std.mem.writeInt(u16, sector_buf[found.byte_offset + 0x1A ..][0..2], #truncate(cluster), .little)
    const write_fn = m.dev.write_fn orelse return error.BlockWriteFailed
    if write_fn(found.lba, sector_buf) != 0 { return error.BlockWriteFailed }
}

// ---- Directory-entry mutation: create / delete / chain-free -----------------
//
// The three metadata operations sys_create / sys_unlink / sys_rename are built
// on, layered over the read-side lookup helpers and the cluster allocator
// above. Each is a self-contained sector RMW on dir_sector_scratch or the
// FSInfo sector — same static-buffer posture as updateDirEntry* (every VFS
// dispatch runs under preempt_disable, so at most one is in flight).

pub const DirSlot = struct {
    lba u32,
    byte_offset u16,
}

// findFreeDirSlot's failure set folds in allocCluster's NoSpace (a full volume
// that cannot be extended) on top of the plain block-I/O errors.
pub const DirSlotError = error{ NoSpace, BlockReadFailed, BlockWriteFailed, InvalidCluster }

// Locate a reusable directory slot in `dir_cluster`'s chain: the first entry
// whose first byte is 0x00 (never-used) or 0xE5 (deleted). When every slot in
// the chain is live, the chain is extended by one fresh, zero-filled cluster
// and that cluster's first slot is returned — so a create into a full directory
// grows the directory rather than failing. The bounded hop count is the same
// corrupted-FAT cycle guard lookupInDir uses (ReleaseSmall traps are off,
// so the walk must self-terminate).
pub fn findFreeDirSlot(m *mut Mount, dir_cluster u32) DirSlotError!DirSlot {
    var cluster u32 = dir_cluster
    var last_cluster u32 = 0
    const sector_buf = &dir_sector_scratch
    var hops u32 = 0
    while cluster >= 2 && cluster < FAT_EOC_MIN {
        const start_lba = clusterLba(m, cluster) catch { return error.InvalidCluster }
        var i u32 = 0
        while i < m.sectors_per_cluster {
            const lba = start_lba + i
            const read_fn = m.dev.read_fn orelse return error.BlockReadFailed
            if read_fn(lba, sector_buf) != 0 { return error.BlockReadFailed }
            var j u16 = 0
            while j < 16 {
                const byte_off u16 = j * 32
                const first_byte = sector_buf[byte_off]
                if first_byte == 0x00 || first_byte == 0xE5 {
                    return .{ .lba = lba, .byte_offset = byte_off }
                }
                j += 1
            }
            i += 1
        }
        last_cluster = cluster
        cluster = try readFatEntry(m, cluster)
        hops += 1
        if hops > m.total_clusters { return error.NoSpace }
    }
    // Chain exhausted with no free slot — grow the directory by one cluster.
    return extendDirChain(m, last_cluster)
}

// Append a fresh cluster to a directory chain whose tail is `last_cluster`,
// zero it (so every slot reads 0x00 = end-of-directory), link the old tail to
// it, and return its first slot. allocCluster already stamped the new cluster
// EOC, so the grown chain terminates correctly.
fn extendDirChain(m *mut Mount, last_cluster u32) DirSlotError!DirSlot {
    if last_cluster < 2 { return error.NoSpace } // never walked a real tail
    const new_cluster = try allocCluster(m)
    try writeFatEntry(m, last_cluster, new_cluster)
    try fsInfoOnAlloc(m, new_cluster)
    try zeroCluster(m, new_cluster)
    const lba = clusterLba(m, new_cluster) catch { return error.InvalidCluster }
    return .{ .lba = lba, .byte_offset = 0 }
}

// Zero every sector of `cluster` via the directory scratch buffer. Called on a
// freshly allocated directory cluster so its slots read as end-of-directory.
fn zeroCluster(m *mut Mount, cluster u32) DirSlotError!void {
    const start_lba = clusterLba(m, cluster) catch { return error.InvalidCluster }
    const sector_buf = &dir_sector_scratch
    #memset(sector_buf, 0)
    const write_fn = m.dev.write_fn orelse return error.BlockWriteFailed
    var i u32 = 0
    while i < m.sectors_per_cluster {
        if write_fn(start_lba + i, sector_buf) != 0 { return error.BlockWriteFailed }
        i += 1
    }
}

// Stamp a fresh 8.3 directory entry into the slot at (lba, byte_offset). The
// 32-byte slot is cleared first — it may be a recycled 0xE5 entry carrying
// stale cluster/size fields — then the live fields are written. Timestamps and
// dates stay zero (no RTC). The same primitive rewrites an existing entry's
// name in place (the sys_rename path), preserving its cluster + size by passing
// them back in.
pub fn writeDirEntry(m *mut Mount, lba u32, byte_offset u16, name8_3 [11]u8, attr u8, first_cluster u32, size u32) FatError!void {
    const sector_buf = &dir_sector_scratch
    const read_fn = m.dev.read_fn orelse return error.BlockReadFailed
    if read_fn(lba, sector_buf) != 0 { return error.BlockReadFailed }
    const base usize = byte_offset
    var z usize = 0
    while z < 32 {
        sector_buf[base + z] = 0
        z += 1
    }
    #memcpy(sector_buf[base..][0..11], &name8_3)
    sector_buf[base + 0x0B] = attr
    std.mem.writeInt(u16, sector_buf[base + 0x14 ..][0..2], #truncate(first_cluster >> 16), .little)
    std.mem.writeInt(u16, sector_buf[base + 0x1A ..][0..2], #truncate(first_cluster), .little)
    std.mem.writeInt(u32, sector_buf[base + 0x1C ..][0..4], size, .little)
    const write_fn = m.dev.write_fn orelse return error.BlockWriteFailed
    if write_fn(lba, sector_buf) != 0 { return error.BlockWriteFailed }
}

// Tombstone a directory entry by setting its first name byte to 0xE5. The
// cluster chain is freed separately (freeChain) — this only detaches the name
// so lookups skip it and findFreeDirSlot can reuse the slot.
pub fn markDeleted(m *mut Mount, lba u32, byte_offset u16) FatError!void {
    const sector_buf = &dir_sector_scratch
    const read_fn = m.dev.read_fn orelse return error.BlockReadFailed
    if read_fn(lba, sector_buf) != 0 { return error.BlockReadFailed }
    sector_buf[byte_offset] = 0xE5
    const write_fn = m.dev.write_fn orelse return error.BlockWriteFailed
    if write_fn(lba, sector_buf) != 0 { return error.BlockWriteFailed }
}

// Free a whole cluster chain starting at `first_cluster`, returning every link
// to FAT_FREE and crediting FSInfo. `first_cluster < 2` (an empty file's
// first_cluster == 0, or a reserved value) is a no-op. The next link is read
// before the current is freed; freeing as it walks also means a corrupted
// back-edge cannot spin forever (a revisited link reads FAT_FREE and stops),
// and the hop bound is the same total_clusters cycle guard as a belt-and-
// suspenders backstop.
pub fn freeChain(m *mut Mount, first_cluster u32) FatError!void {
    var cluster u32 = first_cluster
    var hops u32 = 0
    while cluster >= 2 && cluster < FAT_EOC_MIN {
        const next = try readFatEntry(m, cluster)
        try writeFatEntry(m, cluster, FAT_FREE)
        try fsInfoOnFree(m, cluster)
        cluster = next
        hops += 1
        if hops > m.total_clusters { return error.InvalidCluster }
    }
}

// FSInfo credit on free — the counterpart to fsInfoOnAlloc. Increments
// free_count and lowers the next-free hint to the just-freed cluster (a hint,
// not authority — allocCluster linear-scans regardless), keeping free_count
// honest across create + unlink cycles. A corrupted FSInfo (bad
// signatures) is left untouched, exactly as fsInfoOnAlloc does.
pub fn fsInfoOnFree(m *mut Mount, freed_cluster u32) FatError!void {
    const sector = &fat_sector_scratch
    const read_fn = m.dev.read_fn orelse return error.BlockReadFailed
    if read_fn(m.fsinfo_lba, sector) != 0 { return error.BlockReadFailed }
    const lead = std.mem.readInt(u32, sector[0x000..0x004], .little)
    const struc = std.mem.readInt(u32, sector[0x1E4..0x1E8], .little)
    if lead != FSINFO_LEAD_SIG || struc != FSINFO_STRUC_SIG {
        return
    }
    var free_count = std.mem.readInt(u32, sector[0x1E8..0x1EC], .little)
    if free_count != 0xFFFFFFFF { free_count += 1 }
    std.mem.writeInt(u32, sector[0x1E8..0x1EC], free_count, .little)
    std.mem.writeInt(u32, sector[0x1EC..0x1F0], freed_cluster, .little)
    const write_fn = m.dev.write_fn orelse return error.BlockWriteFailed
    if write_fn(m.fsinfo_lba, sector) != 0 { return error.BlockWriteFailed }
}

pub fn encode8_3(name []u8) ?[11]u8 {
    var out [11]u8 = .{' '} ** 11
    var dot usize = name.len
    for c, i in name {
        if c == '.' {
            dot = i
            break
        }
    }
    if dot > 8 || (name.len - dot) > 4 { return null }
    var i usize = 0
    while i < dot && i < 8 {
        out[i] = std.ascii.toUpper(name[i])
        i += 1
    }
    if dot < name.len {
        var k usize = 0
        var src = dot + 1
        while src < name.len && k < 3 {
            out[8 + k] = std.ascii.toUpper(name[src])
            src += 1
            k += 1
        }
    }
    return out
}

// Rendered 8.3 short name: lowercase `name.ext`, trailing space padding
// trimmed, the dot dropped when the extension is empty. `buf[0..len]` is
// the display basename (<= 12: 8 + '.' + 3). Sibling to encode8_3 — the
// readdir root walk renders each surviving directory entry through this
// (the on-disk form is upper-cased + space-padded to a fixed [11]u8).
pub const Rendered8_3 = struct {
    buf [12]u8 = .{0} ** 12,
    len usize = 0,
}

pub fn decode8_3(raw [11]u8) Rendered8_3 {
    var out Rendered8_3 = .{}
    var name_len usize = 8
    while name_len > 0 && raw[name_len - 1] == ' ' { name_len -= 1 }
    var ext_len usize = 3
    while ext_len > 0 && raw[8 + ext_len - 1] == ' ' { ext_len -= 1 }
    var i usize = 0
    while i < name_len {
        out.buf[out.len] = std.ascii.toLower(raw[i])
        out.len += 1
        i += 1
    }
    if ext_len > 0 {
        out.buf[out.len] = '.'
        out.len += 1
        var k usize = 0
        while k < ext_len {
            out.buf[out.len] = std.ascii.toLower(raw[8 + k])
            out.len += 1
            k += 1
        }
    }
    return out
}

// ---- Host tests ----
//
// The fixture is a minimal-but-real FAT32 volume built into a
// 64 KiB BSS buffer at test time (not comptime — the comptime
// budget can't carry a full image, and an external fixture file
// would need a named-module hop). Geometry:
//
//   bytes_per_sec   = 512
//   sec_per_clus    = 1
//   rsvd_sec_cnt    = 2     (LBA 0 = BPB, LBA 1 = FSInfo)
//   num_fats        = 2
//   fat_sz_32       = 2     (256 entries per FAT)
//   root_clus       = 2     (root dir at cluster 2 = LBA 6)
//   tot_sec_32      = 128
//   total_clusters  = 124   (entries 2..125 valid)
//
// Root cluster (LBA 6) carries: VOLUME_ID entry + a 0xE5 deleted
// entry + HELLO.TXT + a 0x00 end-of-dir marker. HELLO.TXT lives at
// cluster 3 (LBA 7), one cluster, FAT entry EOC.

const testing = std.testing

const FIXTURE_LEN usize = 128 * 512

var host_disk [FIXTURE_LEN]u8 align(512) = undefined

fn setupFixture() void {
    #memset(&host_disk, 0)

    // ---- LBA 0: BPB ----
    const bpb_sector = host_disk[0..512]
    bpb_sector[0] = 0xEB
    bpb_sector[1] = 0x58
    bpb_sector[2] = 0x90
    #memcpy(bpb_sector[3..11], "MSWIN4.1")
    std.mem.writeInt(u16, bpb_sector[0x0B..0x0D], 512, .little) // bytes_per_sec
    bpb_sector[0x0D] = 1 // sec_per_clus
    std.mem.writeInt(u16, bpb_sector[0x0E..0x10], 2, .little) // rsvd_sec_cnt
    bpb_sector[0x10] = 2 // num_fats
    std.mem.writeInt(u16, bpb_sector[0x11..0x13], 0, .little) // root_ent_cnt
    std.mem.writeInt(u16, bpb_sector[0x13..0x15], 0, .little) // tot_sec_16
    bpb_sector[0x15] = 0xF8 // media
    std.mem.writeInt(u16, bpb_sector[0x16..0x18], 0, .little) // fat_sz_16
    std.mem.writeInt(u32, bpb_sector[0x20..0x24], 128, .little) // tot_sec_32
    std.mem.writeInt(u32, bpb_sector[0x24..0x28], 2, .little) // fat_sz_32
    std.mem.writeInt(u32, bpb_sector[0x2C..0x30], 2, .little) // root_clus
    std.mem.writeInt(u16, bpb_sector[0x30..0x32], 1, .little) // fs_info
    std.mem.writeInt(u32, bpb_sector[0x43..0x47], 12345678, .little) // vol_id
    #memcpy(bpb_sector[0x47..0x52], "SCRATCH    ")
    #memcpy(bpb_sector[0x52..0x5A], "FAT32   ")
    std.mem.writeInt(u16, bpb_sector[510..512], 0xAA55, .little)

    // ---- LBA 1: FSInfo ----
    const fsi_sector = host_disk[512..1024]
    std.mem.writeInt(u32, fsi_sector[0x000..0x004], FSINFO_LEAD_SIG, .little)
    std.mem.writeInt(u32, fsi_sector[0x1E4..0x1E8], FSINFO_STRUC_SIG, .little)
    std.mem.writeInt(u32, fsi_sector[0x1E8..0x1EC], 120, .little) // free_count
    std.mem.writeInt(u32, fsi_sector[0x1EC..0x1F0], 4, .little) // next_free
    std.mem.writeInt(u32, fsi_sector[0x1FC..0x200], FSINFO_TRAIL_SIG, .little)

    // ---- LBA 2..3 : FAT1 ; LBA 4..5: FAT2 (mirror) ----
    // Cluster 0 = media + reserved bits, cluster 1 = clean, cluster 2
    // (root) = EOC, cluster 3 (HELLO.TXT) = EOC.
    const fat_entries = [_]u32{
        0x0FFFFFF8, // 0
        0x0FFFFFFF, // 1
        FAT_EOC, // 2 root dir
        FAT_EOC, // 3 HELLO.TXT
    }
    inline for fat_base in .{ 1024, 3072 } { // FAT1 @ LBA 2 (1024) + FAT2 @ LBA 4 (3072)
        for entry, idx in fat_entries {
            const off = fat_base + idx * 4
            std.mem.writeInt(u32, host_disk[off..][0..4], entry, .little)
        }
    }

    // ---- LBA 6: root dir cluster (cluster 2) ----
    const root_sector = host_disk[6 * 512 .. 7 * 512]

    // Entry 0: VOLUME_ID
    #memcpy(root_sector[0..11], "SCRATCH    ")
    root_sector[0x0B] = ATTR_VOLUME_ID

    // Entry 1: deleted (0xE5 first byte)
    #memcpy(root_sector[32 .. 32 + 11], "?DELETEDTXT")
    root_sector[32] = 0xE5
    root_sector[32 + 0x0B] = ATTR_ARCHIVE

    // Entry 2: HELLO.TXT, first cluster 3, file_size 11
    #memcpy(root_sector[64 .. 64 + 11], "HELLO   TXT")
    root_sector[64 + 0x0B] = ATTR_ARCHIVE
    std.mem.writeInt(u16, root_sector[64 + 0x14 ..][0..2], 0, .little) // fst_clus_hi
    std.mem.writeInt(u16, root_sector[64 + 0x1A ..][0..2], 3, .little) // fst_clus_lo
    std.mem.writeInt(u32, root_sector[64 + 0x1C ..][0..4], 11, .little) // file_size

    // Entry 3 onwards: first byte 0x00 (end-of-directory). #memset
    // above already zeroed the rest of the sector, so nothing to do.

    // ---- LBA 7: HELLO.TXT data (cluster 3) ----
    #memcpy(host_disk[7 * 512 ..][0..11], "Hello World")
}

fn fakeRead(lba u32, buf *mut [512]u8) callconv(.c) i32 {
    const off usize = #as(usize, lba) * 512
    if off + 512 > host_disk.len { return -1 }
    #memcpy(buf, host_disk[off..][0..512])
    return 0
}
fn fakeWrite(lba u32, buf *[512]u8) callconv(.c) i32 {
    const off usize = #as(usize, lba) * 512
    if off + 512 > host_disk.len { return -1 }
    #memcpy(host_disk[off..][0..512], buf)
    return 0
}
const fake_dev block_dev.BlockDev = .{ .read_fn = fakeRead, .write_fn = fakeWrite }

test "mount parses BPB and computes data_lba" {
    setupFixture()
    const m = try mount(&fake_dev, 0)
    try testing.expectEqual(#as(u32, 512), #as(u32, m.bpb.bytes_per_sec))
    try testing.expectEqual(#as(u32, 2), m.fat_lba)
    try testing.expectEqual(#as(u32, 6), m.data_lba)
    try testing.expectEqual(#as(u32, 124), m.total_clusters)
}

test "mount rejects BPB with data region past volume end" {
    setupFixture()
    // Inflate fat_sz_32 so the FAT region alone overruns the 128-sector
    // volume: data_lba would land far past tot_sec_32, underflowing the
    // total_clusters subtraction. fat_sz_32 stays non-zero so the check
    // fires as BadBpb (geometry), not NotFat32.
    std.mem.writeInt(u32, host_disk[0x24..0x28], 0x10000000, .little)
    try testing.expectError(error.BadBpb, mount(&fake_dev, 0))
}

test "lookupInRoot finds an existing 8.3 entry" {
    setupFixture()
    const m = try mount(&fake_dev, 0)
    const name = encode8_3("HELLO.TXT") orelse return error.EncodeFail
    const found = try lookupInRoot(&m, name)
    try testing.expectEqual(#as(u32, 11), found.entry.file_size)
    const first_clus = (#as(u32, found.entry.fst_clus_hi) << 16) | found.entry.fst_clus_lo
    try testing.expectEqual(#as(u32, 3), first_clus)
}

test "lookup terminates on self-looping FAT chain" {
    setupFixture()
    const m = try mount(&fake_dev, 0)

    // Point the root cluster's FAT entry at itself, forging a 1-cluster
    // cycle. Both FAT copies (LBA 2 = byte 1024, LBA 4 = byte 3072) are
    // patched; entry 2 lives at fat byte offset 8.
    std.mem.writeInt(u32, host_disk[1024 + 8 ..][0..4], 2, .little)
    std.mem.writeInt(u32, host_disk[3072 + 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 short-
    // circuits — termination can only come from the cycle guard once the
    // walk follows the self-loop back to cluster 2.
    const root_sector = host_disk[6 * 512 .. 7 * 512]
    var j usize = 0
    while j < 16 {
        const off = j * 32
        #memcpy(root_sector[off .. off + 11], "OTHER   BIN")
        root_sector[off + 0x0B] = ATTR_ARCHIVE
        j += 1
    }

    // A hang here would be a cycle-guard regression: with the guard the
    // bounded walk exhausts its hop budget and reports the missing name.
    const name = encode8_3("MISSING.TXT") orelse return error.EncodeFail
    try testing.expectError(error.NotFound, lookupInRoot(&m, name))
}

test "readFatEntry returns EOC for end-of-chain" {
    setupFixture()
    const m = try mount(&fake_dev, 0)
    const next = try readFatEntry(&m, 3) // HELLO.TXT's only cluster
    try testing.expectEqual(FAT_EOC, next)
}

test "allocCluster finds a free entry and marks EOC" {
    setupFixture()
    var m = try mount(&fake_dev, 0)
    const c = try allocCluster(&m)
    try testing.expectEqual(#as(u32, 4), c) // clusters 2+3 used; 4 is first free
    const after = try readFatEntry(&m, c)
    try testing.expectEqual(FAT_EOC, after)
}

test "writeFatEntry mirrors to FAT2 when NumFATs >= 2" {
    setupFixture()
    var m = try mount(&fake_dev, 0)
    try writeFatEntry(&m, 100, 0x12345)
    const reread = try readFatEntry(&m, 100)
    try testing.expectEqual(#as(u32, 0x12345), reread)
    // Read the FAT2 mirror directly via the same offset arithmetic
    // readFatEntry uses, but against (fat_lba + fat_sz_32).
    var sector [512]u8 align(4) = undefined
    const fat2_lba = m.fat_lba + m.bpb.fat_sz_32
    _ = fake_dev.read_fn.?(fat2_lba + (100 * 4) / 512, &sector)
    const mirror = std.mem.readInt(u32, sector[(100 * 4) % 512 ..][0..4], .little) & 0x0FFFFFFF
    try testing.expectEqual(#as(u32, 0x12345), mirror)
}

test "readFatEntry rejects cluster < 2" {
    setupFixture()
    const m = try mount(&fake_dev, 0)
    try testing.expectError(error.InvalidCluster, readFatEntry(&m, 0))
    try testing.expectError(error.InvalidCluster, readFatEntry(&m, 1))
}

test "writeFatEntry rejects cluster < 2" {
    setupFixture()
    var m = try mount(&fake_dev, 0)
    try testing.expectError(error.InvalidCluster, writeFatEntry(&m, 0, FAT_EOC))
    try testing.expectError(error.InvalidCluster, writeFatEntry(&m, 1, FAT_EOC))
}

test "clusterLba rejects cluster < 2" {
    setupFixture()
    const m = try mount(&fake_dev, 0)
    // cluster 0 (empty-file sentinel) and 1 (reserved) would underflow
    // (cluster - 2) into a wild LBA — must fail closed instead.
    try testing.expectError(error.InvalidCluster, clusterLba(&m, 0))
    try testing.expectError(error.InvalidCluster, clusterLba(&m, 1))
    // cluster 2 is the first data cluster → data_lba exactly.
    try testing.expectEqual(m.data_lba, try clusterLba(&m, 2))
}

test "fsInfoOnAlloc decrements free_count and advances next_free" {
    setupFixture()
    var m = try mount(&fake_dev, 0)
    var pre_sector [512]u8 align(4) = undefined
    _ = fake_dev.read_fn.?(m.fsinfo_lba, &pre_sector)
    const free_before = std.mem.readInt(u32, pre_sector[0x1E8..0x1EC], .little)
    try fsInfoOnAlloc(&m, 42)
    var post_sector [512]u8 align(4) = undefined
    _ = fake_dev.read_fn.?(m.fsinfo_lba, &post_sector)
    const free_after = std.mem.readInt(u32, post_sector[0x1E8..0x1EC], .little)
    const next_after = std.mem.readInt(u32, post_sector[0x1EC..0x1F0], .little)
    try testing.expectEqual(free_before - 1, free_after)
    try testing.expectEqual(#as(u32, 43), next_after)
}

test "lookupInRoot returns NotFound for missing entry" {
    setupFixture()
    const m = try mount(&fake_dev, 0)
    const name = encode8_3("MISSING.TXT") orelse return error.EncodeFail
    try testing.expectError(error.NotFound, lookupInRoot(&m, name))
}

test "lookupInRoot skips 0xE5 deleted entries and still finds HELLO.TXT after" {
    setupFixture()
    const m = try mount(&fake_dev, 0)
    const name = encode8_3("HELLO.TXT") orelse return error.EncodeFail
    const found = try lookupInRoot(&m, name)
    // The fixture stamps the deleted entry at offset 32 and HELLO.TXT
    // at offset 64; if the skip-branch were broken the walker would
    // either match the deleted name or short-circuit on 0xE5.
    try testing.expectEqual(#as(u16, 64), found.byte_offset)
}

test "updateDirEntrySize round-trips through the sector" {
    setupFixture()
    var m = try mount(&fake_dev, 0)
    const name = encode8_3("HELLO.TXT") orelse return error.EncodeFail
    const found = try lookupInRoot(&m, name)
    try updateDirEntrySize(&m, found, 0xDEADBEEF)
    const re = try lookupInRoot(&m, name)
    try testing.expectEqual(#as(u32, 0xDEADBEEF), re.entry.file_size)
}

test "encode8_3 uppercase + space-pad + dotless name" {
    const o = encode8_3("init") orelse return error.EncodeFail
    try testing.expectEqualStrings("INIT       ", &o)
}

test "encode8_3 splits name and ext, uppercases both" {
    const o = encode8_3("hello.txt") orelse return error.EncodeFail
    try testing.expectEqualStrings("HELLO   TXT", &o)
}

test "encode8_3 rejects long names" {
    try testing.expectEqual(#as(?[11]u8, null), encode8_3("verylongname.txt"))
}

test "decode8_3 renders lowercase name.ext and trims space padding" {
    const r = decode8_3("HELLO   TXT".*)
    try testing.expectEqualStrings("hello.txt", r.buf[0..r.len])
}

test "decode8_3 drops the dot when the extension is empty" {
    const r = decode8_3("INIT       ".*)
    try testing.expectEqualStrings("init", r.buf[0..r.len])
}

test "decode8_3 round-trips an encode8_3 name" {
    const enc = encode8_3("readme.md") orelse return error.EncodeFail
    const dec = decode8_3(enc)
    try testing.expectEqualStrings("readme.md", dec.buf[0..dec.len])
}

// ---- lookupPath subdirectory descent ----

// Stamp a directory entry into the host fixture at absolute byte offset
// `base` (an LBA*512 + slot*32 address inside host_disk).
fn putEntry(base usize, name *[11]u8, attr u8, first_clus u16, size u32) void {
    #memcpy(host_disk[base..][0..11], name)
    host_disk[base + 0x0B] = attr
    std.mem.writeInt(u16, host_disk[base + 0x14 ..][0..2], 0, .little) // fst_clus_hi
    std.mem.writeInt(u16, host_disk[base + 0x1A ..][0..2], first_clus, .little)
    std.mem.writeInt(u32, host_disk[base + 0x1C ..][0..4], size, .little)
}

// Plant a two-level subtree onto the base fixture (which occupies only
// cluster 2 = root and cluster 3 = HELLO.TXT), reusing free clusters 4
// and 5 for the two directories:
//   /SUBDIR               dir,  cluster 4 (LBA 8)
//   /SUBDIR/DEEP.TXT      file, cluster 6, size 7
//   /SUBDIR/SUB2          dir,  cluster 5 (LBA 9)
//   /SUBDIR/SUB2/NEST.TXT file, cluster 7, size 9
// Each directory fits its single cluster; the trailing slots stay 0x00
// (end-of-dir) from setupFixture's zero-fill. The file clusters (6, 7)
// hold no data — lookupPath never reads file contents.
fn seedSubtree() void {
    putEntry(6 * 512 + 96, "SUBDIR     ", ATTR_DIRECTORY, 4, 0) // root slot 3
    putEntry(8 * 512 + 0, "DEEP    TXT", ATTR_ARCHIVE, 6, 7) // SUBDIR slot 0
    putEntry(8 * 512 + 32, "SUB2       ", ATTR_DIRECTORY, 5, 0) // SUBDIR slot 1
    putEntry(9 * 512 + 0, "NEST    TXT", ATTR_ARCHIVE, 7, 9) // SUB2 slot 0
    // Mark both new directory clusters as single-cluster (EOC) chains in
    // FAT1 (LBA 2 = byte 1024) and FAT2 (LBA 4 = byte 3072).
    inline for fat_base in .{ 1024, 3072 } {
        std.mem.writeInt(u32, host_disk[fat_base + 4 * 4 ..][0..4], FAT_EOC, .little)
        std.mem.writeInt(u32, host_disk[fat_base + 5 * 4 ..][0..4], FAT_EOC, .little)
    }
}

test "lookupPath descends one level into a subdirectory" {
    setupFixture()
    seedSubtree()
    const m = try mount(&fake_dev, 0)
    const found = try lookupPath(&m, "subdir/deep.txt")
    try testing.expectEqual(#as(u32, 7), found.entry.file_size)
    try testing.expectEqual(#as(u32, 6), firstCluster(found.entry))
}

test "lookupPath descends two levels" {
    setupFixture()
    seedSubtree()
    const m = try mount(&fake_dev, 0)
    const found = try lookupPath(&m, "subdir/sub2/nest.txt")
    try testing.expectEqual(#as(u32, 9), found.entry.file_size)
    try testing.expectEqual(#as(u32, 7), firstCluster(found.entry))
}

test "lookupPath single component still resolves at the root" {
    setupFixture()
    const m = try mount(&fake_dev, 0)
    // No subtree seeded: a bare name must behave exactly like the old
    // lookupInRoot path (HELLO.TXT at cluster 3, size 11).
    const found = try lookupPath(&m, "hello.txt")
    try testing.expectEqual(#as(u32, 11), found.entry.file_size)
    try testing.expectEqual(#as(u32, 3), firstCluster(found.entry))
}

test "lookupPath returns NotADirectory when a non-final component is a file" {
    setupFixture()
    const m = try mount(&fake_dev, 0)
    // HELLO.TXT is a regular file; descending through it must fail rather
    // than misread its size/clusters as a directory.
    try testing.expectError(error.NotADirectory, lookupPath(&m, "hello.txt/deep.txt"))
}

test "lookupPath returns NotFound for a missing intermediate directory" {
    setupFixture()
    const m = try mount(&fake_dev, 0)
    try testing.expectError(error.NotFound, lookupPath(&m, "nope/deep.txt"))
}

test "lookupPath returns NotFound for a missing leaf inside a real subdirectory" {
    setupFixture()
    seedSubtree()
    const m = try mount(&fake_dev, 0)
    // Proves the descent reached SUBDIR (cluster 4) and only then failed
    // to find the leaf — not a first-component miss.
    try testing.expectError(error.NotFound, lookupPath(&m, "subdir/missing.txt"))
}

test "lookupPath tolerates redundant slashes" {
    setupFixture()
    seedSubtree()
    const m = try mount(&fake_dev, 0)
    // A leading slash and a doubled separator must collapse to the same
    // resolution (defensive — vfs.resolve normally pre-collapses).
    const found = try lookupPath(&m, "/subdir//deep.txt")
    try testing.expectEqual(#as(u32, 6), firstCluster(found.entry))
}

// ---- create / unlink / rename primitives ----

// Read the FSInfo free_count directly, the same way fsInfoOnAlloc/Free do.
fn readFreeCount(m *Mount) u32 {
    var sec [512]u8 align(4) = undefined
    _ = fake_dev.read_fn.?(m.fsinfo_lba, &sec)
    return std.mem.readInt(u32, sec[0x1E8..0x1EC], .little)
}

// Fill all 16 slots of the root cluster (LBA 6) with live, non-matching
// entries so no 0x00/0xE5 slot remains — forces findFreeDirSlot to extend.
fn fillRootCluster() void {
    const root_sector = host_disk[6 * 512 .. 7 * 512]
    var j usize = 0
    while j < 16 {
        const off = j * 32
        #memcpy(root_sector[off .. off + 11], "FULL    BIN")
        root_sector[off + 0x0B] = ATTR_ARCHIVE
        j += 1
    }
}

test "findFreeDirSlot reuses a 0xE5 deleted slot" {
    setupFixture()
    var m = try mount(&fake_dev, 0)
    // The fixture's root slot 1 (offset 32) is the 0xE5 deleted entry; it must
    // be handed back before the trailing 0x00 slots so deletes free up space.
    const slot = try findFreeDirSlot(&m, m.bpb.root_clus)
    try testing.expectEqual(#as(u32, 6), slot.lba) // root cluster 2 -> LBA 6
    try testing.expectEqual(#as(u16, 32), slot.byte_offset)
}

test "findFreeDirSlot returns the first end-of-dir slot when none are deleted" {
    setupFixture()
    seedSubtree()
    var m = try mount(&fake_dev, 0)
    // SUBDIR (cluster 4, LBA 8) holds DEEP.TXT @0 and SUB2 @32; slot 2 (offset
    // 64) is the first 0x00 end-of-dir slot.
    const slot = try findFreeDirSlot(&m, 4)
    try testing.expectEqual(#as(u32, 8), slot.lba)
    try testing.expectEqual(#as(u16, 64), slot.byte_offset)
}

test "findFreeDirSlot extends a full directory cluster" {
    setupFixture()
    fillRootCluster()
    var m = try mount(&fake_dev, 0)
    const free_before = readFreeCount(&m)
    // Root cluster (2) is full and EOC-terminated, so the slot must come from a
    // freshly allocated cluster 4 (clusters 2,3 already used) at LBA 8, slot 0.
    const slot = try findFreeDirSlot(&m, m.bpb.root_clus)
    try testing.expectEqual(#as(u32, 8), slot.lba)
    try testing.expectEqual(#as(u16, 0), slot.byte_offset)
    // The directory chain grew: root(2) -> 4 -> EOC.
    try testing.expectEqual(#as(u32, 4), try readFatEntry(&m, 2))
    try testing.expectEqual(FAT_EOC, try readFatEntry(&m, 4))
    // And the allocation was credited to FSInfo.
    try testing.expectEqual(free_before - 1, readFreeCount(&m))
}

test "findFreeDirSlot terminates on a self-looping directory chain" {
    setupFixture()
    fillRootCluster()
    // Forge a 1-cluster cycle on the root: entry 2 (fat byte offset 8) -> 2,
    // patched in both FAT copies (LBA 2 = byte 1024, LBA 4 = byte 3072).
    std.mem.writeInt(u32, host_disk[1024 + 8 ..][0..4], 2, .little)
    std.mem.writeInt(u32, host_disk[3072 + 8 ..][0..4], 2, .little)
    var m = try mount(&fake_dev, 0)
    // A hang here would be a cycle-guard regression: the full, self-looping
    // chain exhausts the hop budget and reports NoSpace rather than spinning.
    try testing.expectError(error.NoSpace, findFreeDirSlot(&m, m.bpb.root_clus))
}

test "writeDirEntry stamps a findable new entry" {
    setupFixture()
    var m = try mount(&fake_dev, 0)
    const name = encode8_3("new.fl") orelse return error.EncodeFail
    const slot = try findFreeDirSlot(&m, m.bpb.root_clus)
    try writeDirEntry(&m, slot.lba, slot.byte_offset, name, ATTR_ARCHIVE, 0, 0)
    const found = try lookupInRoot(&m, name)
    try testing.expectEqual(slot.byte_offset, found.byte_offset)
    try testing.expectEqual(#as(u32, 0), found.entry.file_size)
    try testing.expectEqual(#as(u32, 0), firstCluster(found.entry))
}

test "writeDirEntry rewrites a name in place, preserving cluster and size" {
    setupFixture()
    var m = try mount(&fake_dev, 0)
    // The rename primitive: rewrite HELLO.TXT's slot with a new 8.3 name while
    // keeping its first cluster (3) and size (11).
    const old = encode8_3("hello.txt") orelse return error.EncodeFail
    const found = try lookupInRoot(&m, old)
    const new = encode8_3("renamed.fl") orelse return error.EncodeFail
    try writeDirEntry(&m, found.lba, found.byte_offset, new, found.entry.attr, firstCluster(found.entry), found.entry.file_size)
    const re = try lookupInRoot(&m, new)
    try testing.expectEqual(#as(u32, 11), re.entry.file_size)
    try testing.expectEqual(#as(u32, 3), firstCluster(re.entry))
    // The old name is gone (same slot, overwritten).
    try testing.expectError(error.NotFound, lookupInRoot(&m, old))
}

test "markDeleted + freeChain unlinks a file and credits FSInfo" {
    setupFixture()
    var m = try mount(&fake_dev, 0)
    const name = encode8_3("hello.txt") orelse return error.EncodeFail
    const found = try lookupInRoot(&m, name)
    const fc = firstCluster(found.entry) // 3
    const free_before = readFreeCount(&m)
    try markDeleted(&m, found.lba, found.byte_offset)
    try freeChain(&m, fc)
    try testing.expectError(error.NotFound, lookupInRoot(&m, name))
    try testing.expectEqual(FAT_FREE, try readFatEntry(&m, fc))
    try testing.expectEqual(free_before + 1, readFreeCount(&m))
}

test "freeChain on an empty file (cluster 0) is a no-op" {
    setupFixture()
    var m = try mount(&fake_dev, 0)
    const free_before = readFreeCount(&m)
    try freeChain(&m, 0)
    try testing.expectEqual(free_before, readFreeCount(&m))
}

test "free_count round-trips through alloc then free" {
    setupFixture()
    var m = try mount(&fake_dev, 0)
    const free0 = readFreeCount(&m)
    const c = try allocCluster(&m)
    try fsInfoOnAlloc(&m, c)
    try testing.expectEqual(free0 - 1, readFreeCount(&m))
    try freeChain(&m, c)
    try testing.expectEqual(free0, readFreeCount(&m))
}

test "freeChain frees every link of a multi-cluster chain" {
    setupFixture()
    var m = try mount(&fake_dev, 0)
    const c1 = try allocCluster(&m) // 4
    const c2 = try allocCluster(&m) // 5
    try writeFatEntry(&m, c1, c2) // link 4 -> 5 (5 stays EOC from allocCluster)
    try freeChain(&m, c1)
    try testing.expectEqual(FAT_FREE, try readFatEntry(&m, c1))
    try testing.expectEqual(FAT_FREE, try readFatEntry(&m, c2))
}