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, §or) != 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, §or)
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))
}