Flash 1139 lines
// fat32_backend: FAT32 VFS backend. Wraps src/fat32.zig's
// on-disk decode in the VfsOps vtable.
//
// open / read / seek / close / write do live cluster-chain I/O
// against block_dev.sd_dev. write (writeBack) extends-or-overwrites
// an existing file: chain-extend via allocCluster + writeFatEntry,
// sector read-modify-write loop, dir-entry file_size update by
// re-walking the root by first cluster, FSInfo decrement per alloc.
// create / unlink / rename close the metadata gap: create stamps a
// fresh 8.3 entry (find/extend a free slot) and hands back a writable
// handle; unlink tombstones the entry and frees its chain; rename
// rewrites the 8.3 name in place. All file-only and same-directory —
// mkdir and cross-directory move are future scope (see each fn header).
// No sparse-write past EOF (see writeBack header).
//
// init() MUST run after board.emmc2.init() has wired
// block_dev.sd_dev — fat32.mount issues block reads through that
// vtable. kernel.zig calls init() inside the emmc2-init-OK branch
// for exactly that reason; calling it before sd_dev is wired would
// dereference an undefined function pointer.
const std = #import("std")
const fat32 = #import("fat32")
const vfs = #import("vfs")
const file_mod = #import("file")
const block_dev = #import("block_dev")
const overlay = #import("overlay")
const File = file_mod.File
// Single static superblock for the /mnt mount (slot 1). fs_type is
// re-stamped by vfs.register_fat32.
pub var sb vfs.SuperBlock = .{ .fs_type = #intFromEnum(vfs.FsType.FAT32) }
// Volume descriptor, populated by init()'s fat32.mount. sb.private
// carries its address (per the vfs.zig SuperBlock contract); the
// vtable bodies reach it directly through this module global.
var mount_info fat32.Mount = undefined
// `var`, not `const`: init() relocates these entries to their
// high-mem aliases in place via vfs.relocateOps (mirrors the earlier
// stub's pattern). Slot names match real src/vfs.zig VfsOps.
var ops_vtable vfs.VfsOps = .{
.open = open,
.read = read,
.seek = seek,
.close = close,
.write = write,
.readdir = readdir,
.create = create,
.unlink = unlink,
.rename = rename,
}
// Start sector of the single FAT32 partition on the SD card. Matches
// scripts/format_sd.sh (MBR, one FAT32 at LBA 2048 = the 1 MiB
// alignment offset); make_test_disk.sh formats the QEMU image to the
// same offset.
const FAT32_PARTITION_LBA u32 = 2048
// ---- FAT32 permission overlay ----
//
// FAT32 has no native owner/mode concept, so /mnt files get their
// permission metadata from a root-level text file (PERMS.TAB) parsed once
// at mount time into this fixed table; open() consults it. Un-annotated
// paths keep the documented default (0666 root:root) — except the shadow
// basename, which floors at 0600 root:root so a missing or corrupt
// overlay can never expose the on-card password file (defense in depth
// behind the anti-brick fallback).
// Overlay file name in the FAT32 root (matched case-insensitively).
const OVERLAY_NAME []u8 = "perms.tab"
// The basename that floors at 0600 when the overlay carries no entry.
const SHADOW_NAME []u8 = "shadow"
// True when PERMS.TAB was found AND parsed cleanly at mount time.
// kernel.zig reads this after init() to emit the loud anti-brick
// announcement — this module has no console.
pub var overlay_ok bool = false
var overlay_count usize = 0
var overlay_entries [overlay.MAX_ENTRIES]overlay.Entry = undefined
// Static read buffer for the overlay file. The overlay is sub-KiB by
// design; an oversized file is treated as corrupt (rejected wholesale).
var overlay_buf [1024]u8 = undefined
// Kernel-stack relief: shared sector scratch for the
// vtable I/O entry points (read / write / readdir and the dir-walk
// helper). They never nest in each other and every dispatch runs under
// the sys.zig preempt_disable bracket, so one buffer serves all four.
// See src/fat32.zig's dir/fat_sector_scratch for the full rationale.
var io_sector_scratch fat32.SectorBuf align(4) = undefined
// Read + parse /PERMS.TAB from the freshly mounted volume. Any failure
// (absent, empty, oversized, unreadable, malformed) leaves overlay_ok
// false and the table empty — open() then applies the defaults + shadow
// floor. Called by init() right after register_fat32, so the table is
// ready before the first syscall-path open.
fn applyOverlay() void {
overlay_ok = false
overlay_count = 0
const name = fat32.encode8_3(OVERLAY_NAME) orelse return
const found = fat32.lookupInRoot(&mount_info, name) catch return
if (found.entry.file_size == 0 || found.entry.file_size > overlay_buf.len) { return }
const first_clus = (#as(u32, found.entry.fst_clus_hi) << 16) | found.entry.fst_clus_lo
var f File = .{
.ftype = 0,
.refs = 1,
.offset = 0,
.private = first_clus,
.size = found.entry.file_size,
.sb = &sb,
}
var got u64 = 0
while (got < f.size) {
const n = read(&sb, &f, overlay_buf[#intCast(got)..].ptr, f.size - got)
if (n < 0) { return }
if (n == 0) { break }
got += #intCast(n)
}
// Flash has no `orelse { block }` (the block handler is catch-only),
// so the original `overlay_count = parse(...) orelse { overlay_count =
// 0; return; }` is spelled as an explicit null-check — behavior
// identical (the `= 0` is the already-set value from the top).
const parsed = overlay.parse(overlay_buf[0..#intCast(got)], &overlay_entries)
if (parsed == null) {
overlay_count = 0
return
}
overlay_count = parsed.?
overlay_ok = true
}
// Kernel bring-up hook. Returns 0 on a mounted volume, -1 if
// fat32.mount fails (blank/bad disk, no BPB). On failure the mount
// table slot is left null — non-fatal: vfs.resolve returns null for
// /mnt/* and the caller treats it as ENOENT. kernel.zig logs the
// outcome (this module has no console). Allocates nothing (mount
// uses a stack sector buffer), so the free-page baseline holds.
pub fn init() i32 {
vfs.relocateOps(&ops_vtable)
// The block-device function pointers are link-time (low) addresses
// wired by the board's emmc2 init. Like the vtable above, they are
// invoked from syscall context (TTBR0 = user pgd), so they must be
// re-pointed to their high-mem aliases before the first mount.
block_dev.relocate()
sb.ops = &ops_vtable
mount_info = fat32.mount(&block_dev.sd_dev, FAT32_PARTITION_LBA) catch return -1
sb.private = #intFromPtr(&mount_info)
vfs.register_fat32(&sb)
// Permission overlay: parse PERMS.TAB into the static table
// while the mount is fresh. Failure is soft — overlay_ok stays false
// and open() falls back to defaults + the shadow floor.
applyOverlay()
return 0
}
// path crosses as ptr+len (callconv(.c) forbids slices). The /mnt
// prefix is already stripped by vfs.resolve, leaving a leading '/'.
// out.private carries the file's first cluster in the low 32 bits
// (high bits 0 — read re-walks the chain; no cluster_count cached
// until fd state grows). out.size carries the dir-entry
// file_size.
fn open(_ *mut vfs.SuperBlock, path_ptr [*]u8, path_len usize, out *mut vfs.OpenResult) callconv(.c) c_int {
const path = path_ptr[0..path_len]
const rel = if (path.len > 0 && path[0] == '/') path[1..] else path
const found = fat32.lookupPath(&mount_info, rel) catch return -1
const first_clus = fat32.firstCluster(found.entry)
out.private = first_clus
out.size = found.entry.file_size
// Stash the on-disk directory-entry location so write() can rewrite
// the entry's first-cluster (empty-file first write) and file_size
// without an ambiguous re-walk by first cluster.
out.dirent_lba = found.lba
out.dirent_off = found.byte_offset
// Permission metadata: the mount-time overlay (PERMS.TAB)
// supplies per-file mode/uid/gid. Annotated paths get their entry
// (low 9 bits + the regular-file type the perm layer expects);
// un-annotated paths keep the documented default — rw-rw-rw-
// root:root, no exec bit (the historical /mnt contract) — except the
// shadow basename, which floors at 0600 root:root so a missing or
// corrupt overlay never exposes the on-card password file. An
// explicit overlay entry can still override the floor (operator's
// call).
if (overlay.lookup(overlay_entries[0..overlay_count], rel)) |e| {
out.mode = 0o100000 | (e.mode & 0o777)
out.uid = e.uid
out.gid = e.gid
} else if (overlay.nameEql(rel, SHADOW_NAME)) {
out.mode = 0o100600
out.uid = 0
out.gid = 0
} else {
out.mode = 0o100666
out.uid = 0
out.gid = 0
}
return 0
}
fn read(_ *mut vfs.SuperBlock, f *mut File, buf [*]mut u8, len u64) callconv(.c) i64 {
if (f.offset >= f.size) { return 0 }
const remaining = f.size - f.offset
const n u64 = if (len > remaining) remaining else len
// Walk the FAT chain to the cluster covering f.offset.
var cluster u32 = #intCast(f.private & 0xFFFF_FFFF)
var cluster_offset u64 = f.offset
while (cluster_offset >= mount_info.bytes_per_cluster) {
cluster = fat32.readFatEntry(&mount_info, cluster) catch return -1
if (cluster >= fat32.FAT_EOC_MIN) { return -1 }
cluster_offset -= mount_info.bytes_per_cluster
}
var copied u64 = 0
const sector_buf = &io_sector_scratch
while (copied < n) {
const sector_in_cluster u32 = #intCast(cluster_offset / 512)
const byte_in_sector u32 = #intCast(cluster_offset % 512)
const lba = (fat32.clusterLba(&mount_info, cluster) catch return -1) + sector_in_cluster
const read_fn = block_dev.sd_dev.read_fn orelse return -1
if (read_fn(lba, sector_buf) != 0) { return -1 }
const take u64 = #min(n - copied, 512 - byte_in_sector)
// Symmetric to write()'s splice — explicit byte loop so the
// read_fn(§or_buf) -> copy-out dependency is preserved
// for the sub-sector (take<512) case (see write() comment).
// (Bare scoping block hoisted to fn scope — Flash has no
// anonymous block statement; `si` is dead after the loop.)
var si usize = 0
while (si < take) {
buf[#as(usize, #intCast(copied)) + si] = sector_buf[#as(usize, byte_in_sector) + si]
si += 1
}
copied += take
cluster_offset += take
if (cluster_offset >= mount_info.bytes_per_cluster) {
cluster = fat32.readFatEntry(&mount_info, cluster) catch return -1
if (cluster >= fat32.FAT_EOC_MIN) { break }
cluster_offset = 0
}
}
f.offset += copied
return #bitCast(copied)
}
fn seek(_ *mut vfs.SuperBlock, f *mut File, off i64, whence i32) callconv(.c) i64 {
const cur_signed i64 = #bitCast(f.offset)
const sz_signed i64 = #bitCast(f.size)
const target i64 = switch whence {
0 => off, // SEEK_SET
1 => cur_signed + off, // SEEK_CUR
2 => sz_signed + off, // SEEK_END
else => return -1,
}
if (target < 0 || target > sz_signed) { return -1 }
f.offset = #bitCast(target)
return target
}
fn close(_ *mut vfs.SuperBlock, _ *mut File) callconv(.c) void {
// No per-handle state — every read is sector-fetched inline and
// the File page lifetime is file.zig's refcount's job. Step 4's
// write path stays sector-flushed too, so close stays a no-op
// until a future buffer cache adds a real fsync here.
}
// write (writeBack) — extends or overwrites an existing file, including
// a previously empty one. No create-if-missing for a NON-existent path
// yet (the dir entry must already exist); no sparse write past EOF + len
// (offset > size treated as -1). Sequence:
// 0. If the file is empty (first_cluster == 0), allocate its first
// data cluster, link it EOC, and record it in the dir entry (via
// the open-time-stashed dirent location — an empty file can't be
// found by first cluster, since 0 is not unique across empty files).
// 1. Walk the chain from first_cluster to the cluster covering
// f.offset; if the chain ends before that, allocCluster + link.
// 2. Sector read-modify-write loop: read the target sector, splice
// `take` bytes, write it back. Cross cluster boundaries via the
// same alloc-or-follow path.
// 3. If f.offset + copied > f.size, update the in-RAM f.size and the
// on-disk dir entry's file_size at the stashed dirent location.
// 4. fsInfoOnAlloc once per allocCluster.
//
// Not crash-safe (FAT1/FAT2, dir-entry, FSInfo writes are separate
// non-atomic RMW points). Single-shot acceptance run never power-
// cycles mid-write; a future journal closes the gap.
fn write(_ *mut vfs.SuperBlock, f *mut File, buf [*]u8, len u64) callconv(.c) i64 {
if (len == 0) { return 0 }
// No sparse write: a hole between f.size and f.offset is -1.
if (f.offset > f.size) { return -1 }
// On-disk dir-entry location, stashed at open. Used to give an empty
// file its first cluster (step 0) and to grow file_size (step 3) —
// both rewrite the entry, which an empty file can't locate by first
// cluster (0 is not unique). `.entry` is unread by either updater.
const dirent_loc fat32.FoundEntry = .{
.entry = undefined,
.lba = f.dirent_lba,
.byte_offset = #intCast(f.dirent_off),
}
var cluster u32 = #intCast(f.private & 0xFFFF_FFFF)
var cluster_offset u64 = f.offset
// Step 0: first write to an empty file. Its dir entry has
// first_cluster == 0 (no data yet), so clusterLba would fail closed.
// allocCluster reserves a free cluster and writes its FAT_EOC; record
// it in the dir entry and adopt it as the chain head.
if (cluster == 0) {
const first = fat32.allocCluster(&mount_info) catch return -1
fat32.fsInfoOnAlloc(&mount_info, first) catch {}
fat32.updateDirEntryFirstCluster(&mount_info, dirent_loc, first) catch return -1
f.private = first
cluster = first
}
// Step 1: walk to the cluster covering f.offset, extending the
// chain via allocCluster when the walk hits end-of-chain.
while (cluster_offset >= mount_info.bytes_per_cluster) {
var next = fat32.readFatEntry(&mount_info, cluster) catch return -1
if (next >= fat32.FAT_EOC_MIN) {
next = fat32.allocCluster(&mount_info) catch return -1
fat32.writeFatEntry(&mount_info, cluster, next) catch return -1
// FSInfo free-count/next-free is an advisory hint; the FAT
// chain and dir entry are already durable, so a failed
// hint update only costs a slower future allocation scan,
// never data — swallow it rather than fail the write.
fat32.fsInfoOnAlloc(&mount_info, next) catch {}
}
cluster = next
cluster_offset -= mount_info.bytes_per_cluster
}
// Step 2: sector read-modify-write loop.
var copied u64 = 0
const sector_buf = &io_sector_scratch
while (copied < len) {
const sector_in_cluster u32 = #intCast(cluster_offset / 512)
const byte_in_sector u32 = #intCast(cluster_offset % 512)
const lba = (fat32.clusterLba(&mount_info, cluster) catch return -1) + sector_in_cluster
const read_fn = block_dev.sd_dev.read_fn orelse return -1
if (read_fn(lba, sector_buf) != 0) { return -1 }
const take u64 = #min(len - copied, 512 - byte_in_sector)
// Explicit byte loop, NOT @memcpy: the sub-sector (take<512)
// splice as `@memcpy(sector_buf[bis..][0..take], buf[..])`
// lowered to an inlined store that the optimizer hoisted
// ABOVE the preceding `read_fn(§or_buf)` fn-pointer call,
// so read_fn re-zeroed sector_buf[bis] after the splice — the
// 1-byte ROUNDTR.MAG write read back 0x00 every boot while the
// take=512 DAT path (lowered to an opaque memcpy call, not
// reordered) worked. Indexing through the buffer keeps the
// read_fn -> splice dependency the compiler must honour.
// (Bare scoping block hoisted to fn scope — Flash has no
// anonymous block statement; `si` is dead after the loop.)
var si usize = 0
while (si < take) {
sector_buf[#as(usize, byte_in_sector) + si] = buf[#as(usize, #intCast(copied)) + si]
si += 1
}
const write_fn = block_dev.sd_dev.write_fn orelse return -1
if (write_fn(lba, sector_buf) != 0) { return -1 }
copied += take
cluster_offset += take
if (cluster_offset >= mount_info.bytes_per_cluster && copied < len) {
var next = fat32.readFatEntry(&mount_info, cluster) catch return -1
if (next >= fat32.FAT_EOC_MIN) {
next = fat32.allocCluster(&mount_info) catch return -1
fat32.writeFatEntry(&mount_info, cluster, next) catch return -1
// FSInfo free-count/next-free is an advisory hint; the
// FAT chain and dir entry are already durable, so a
// failed hint update only costs a slower future
// allocation scan, never data — swallow it.
fat32.fsInfoOnAlloc(&mount_info, next) catch {}
}
cluster = next
cluster_offset = 0
}
}
// Step 3: grow file_size on disk if the write went past EOF, at the
// open-time-stashed dirent location (works for root + subdir files
// and for the just-given first cluster — no re-walk, no ambiguity).
const new_offset = f.offset + copied
if (new_offset > f.size) {
fat32.updateDirEntrySize(&mount_info, dirent_loc, #intCast(new_offset)) catch return -1
f.size = new_offset
}
f.offset = new_offset
return #bitCast(copied)
}
// readdir — enumerate the FAT32 mount root, one entry per call. Stateless
// like the rest of the VFS surface: the caller passes a fresh `index`, the
// walk re-reads the root chain and emits the `index`-th survivor. Root-only
// — only the mount root ("/", i.e. `/mnt/`) enumerates; a subdirectory
// listing needs a directory-cluster walk keyed off the entry's first
// cluster (deferred, no nested dirs in the demo image), so a
// non-root path lists empty. Skips the same entries lookupInRoot does
// (0x00 end-of-dir, 0xE5 deleted, LFN) plus the volume-label entry, which
// is not an enumerable file. Renders 8.3 via fat32.decode8_3; d_type from
// ATTR_DIRECTORY. Allocates nothing (one stack sector buffer). Runtime
// Pi-only: FAT32 does not mount under QEMU, so vfs.resolve("/mnt/*")
// returns null and sys_readdir answers -1 before reaching here.
fn readdir(_ *mut vfs.SuperBlock, path_ptr [*]u8, path_len usize, index u64, out *mut vfs.Dirent) callconv(.c) c_int {
const path = path_ptr[0..path_len]
if (!(path.len == 1 && path[0] == '/')) { return -1 } // root-only
var cluster u32 = mount_info.bpb.root_clus
const sector_buf = &io_sector_scratch
var emitted u64 = 0
// Cycle guard: a valid chain visits at most total_clusters links;
// exceeding that proves a self-loop in a corrupted FAT, so stop.
var hops u32 = 0
while (cluster >= 2 && cluster < fat32.FAT_EOC_MIN) {
const start_lba = fat32.clusterLba(&mount_info, cluster) catch return -1
var i u32 = 0
while (i < mount_info.sectors_per_cluster) {
const lba = start_lba + i
const read_fn = block_dev.sd_dev.read_fn orelse return -1
if (read_fn(lba, sector_buf) != 0) { return -1 }
var j u16 = 0
while (j < 16) {
const byte_off u16 = j * 32
const first_byte = sector_buf[byte_off]
if (first_byte == 0x00) { return -1 } // end-of-dir
// 0xE5 = deleted, ATTR_LONG_NAME = VFAT slot, ATTR_VOLUME_ID
// = label: all skipped. Inverted from the original's
// `continue` guards because Flash has no continue-expression
// — the scan index must still advance at the loop tail.
if (first_byte != 0xE5) {
const attr = sector_buf[byte_off + 0x0B]
if ((attr & fat32.ATTR_LONG_NAME) != fat32.ATTR_LONG_NAME) {
if ((attr & fat32.ATTR_VOLUME_ID) == 0) {
if (emitted == index) {
var raw [11]u8 = undefined
#memcpy(&raw, sector_buf[byte_off..][0..11])
const dec = fat32.decode8_3(raw)
out.* = .{}
const n = #min(dec.len, out.name.len - 1)
#memcpy(out.name[0..n], dec.buf[0..n])
out.d_type = if ((attr & fat32.ATTR_DIRECTORY) != 0) vfs.DT_DIR else vfs.DT_REG
return 0
}
emitted += 1
}
}
}
j += 1
}
i += 1
}
cluster = fat32.readFatEntry(&mount_info, cluster) catch return -1
hops += 1
if (hops > mount_info.total_clusters) { return -1 }
}
return -1
}
// ---- create / unlink / rename — directory-entry mutation ----
//
// Layered over fat32.zig's slot/chain primitives. All three are
// file-only and (for rename) same-directory: mkdir / rmdir / cross-dir
// move are deferred (mv falls back to copy+unlink across directories).
// Split a mount-relative path into its parent directory and basename. A
// path with no slash has an empty parent (the mount root); a trailing
// slash yields an empty basename, which create/rename reject.
const PathSplit = struct {
parent []u8,
base []u8,
}
fn splitBasename(rel []u8) PathSplit {
var ls ?usize = null
var k usize = 0
while (k < rel.len) {
if (rel[k] == '/') { ls = k }
k += 1
}
if ls |i| {
return .{ .parent = rel[0..i], .base = rel[i + 1 ..] }
}
return .{ .parent = rel[0..0], .base = rel }
}
// Resolve a parent-directory path to its first cluster. An empty path is
// the mount root. Returns null when the path is missing or names a
// non-directory (so a create/rename into it fails closed).
fn resolveParentCluster(parent []u8) ?u32 {
if (parent.len == 0) { return mount_info.bpb.root_clus }
const pf = fat32.lookupPath(&mount_info, parent) catch return null
if ((pf.entry.attr & fat32.ATTR_DIRECTORY) == 0) { return null }
return fat32.firstCluster(pf.entry)
}
// Existence probe for an 8.3 name in a directory cluster, tri-state so the
// I/O-error case never reads as "free to create":
// 0 → name is free (the only go-ahead)
// 1 → name already taken
// -1 → block-read / corrupt-FAT error
fn probeExists(dir_cluster u32, name8_3 [11]u8) i32 {
// catch-into-tri-state: a clean lookup means the name is taken (1); a
// NotFound is the only go-ahead (0); any other error is an I/O fault (-1).
// The catch arm returns on every path, so control reaches the trailing
// `return 1` only when the lookup succeeded.
_ = fat32.lookupInDir(&mount_info, dir_cluster, name8_3) catch |err| {
const not_found = err == error.NotFound
if (not_found) { return 0 }
return -1
}
return 1
}
// create — make a new empty file at `path` and hand back a writable handle.
// Splits off the basename, resolves the parent directory, rejects a >8.3
// name or an existing name, finds-or-extends a free directory slot, and
// stamps an empty entry (first_cluster 0, size 0). out is filled like open's
// writable side: dirent_lba/off point at the fresh entry so the first write
// grows it. The 0644 root:root perms are a baseline — sys_create overrides
// uid/gid with the caller's effective ids (the backend has no view of
// credentials; created-file perms do not persist across reboot).
fn create(_ *mut vfs.SuperBlock, path_ptr [*]u8, path_len usize, out *mut vfs.OpenResult) callconv(.c) c_int {
const path = path_ptr[0..path_len]
const rel = if (path.len > 0 && path[0] == '/') path[1..] else path
const sp = splitBasename(rel)
if (sp.base.len == 0) { return -1 } // trailing slash / empty name
const parent_cluster = resolveParentCluster(sp.parent) orelse return -1
const name8_3 = fat32.encode8_3(sp.base) orelse return -1 // reject a name that does not fit 8.3
if (probeExists(parent_cluster, name8_3) != 0) { return -1 } // exists or I/O error
const slot = fat32.findFreeDirSlot(&mount_info, parent_cluster) catch return -1
fat32.writeDirEntry(&mount_info, slot.lba, slot.byte_offset, name8_3, fat32.ATTR_ARCHIVE, 0, 0) catch return -1
out.private = 0 // empty file: no first cluster until the first write
out.size = 0
out.dirent_lba = slot.lba
out.dirent_off = slot.byte_offset
out.mode = 0o100644
out.uid = 0
out.gid = 0
return 0
}
// unlink — remove the file at `path`: tombstone its 8.3 entry (0xE5) and free
// its cluster chain. Files only — a directory entry is refused (rmdir is
// future scope). The tombstone is written before the chain is freed: a
// failure after the tombstone leaks clusters (fsck-recoverable) rather than
// leaving a live entry pointing at freed clusters.
fn unlink(_ *mut vfs.SuperBlock, path_ptr [*]u8, path_len usize) callconv(.c) c_int {
const path = path_ptr[0..path_len]
const rel = if (path.len > 0 && path[0] == '/') path[1..] else path
const found = fat32.lookupPath(&mount_info, rel) catch return -1
if ((found.entry.attr & fat32.ATTR_DIRECTORY) != 0) { return -1 } // files only
const first_clus = fat32.firstCluster(found.entry)
fat32.markDeleted(&mount_info, found.lba, found.byte_offset) catch return -1
fat32.freeChain(&mount_info, first_clus) catch return -1
return 0
}
// rename — rewrite `old`'s 8.3 name to `new`'s within the same directory, no
// data move. Same-directory only: the parents must match (cross-directory is
// mv's copy+unlink job). Rejects a >8.3 new name and an existing target
// (no clobber); a same-name rename is a harmless no-op rewrite. Cluster, size
// and attributes are carried over from the existing entry.
fn rename(_ *mut vfs.SuperBlock, old_ptr [*]u8, old_len usize, new_ptr [*]u8, new_len usize) callconv(.c) c_int {
const old_path = old_ptr[0..old_len]
const new_path = new_ptr[0..new_len]
const old_rel = if (old_path.len > 0 && old_path[0] == '/') old_path[1..] else old_path
const new_rel = if (new_path.len > 0 && new_path[0] == '/') new_path[1..] else new_path
const found = fat32.lookupPath(&mount_info, old_rel) catch return -1
const old_sp = splitBasename(old_rel)
const new_sp = splitBasename(new_rel)
if (!std.mem.eql(u8, old_sp.parent, new_sp.parent)) { return -1 } // same-dir only
if (new_sp.base.len == 0) { return -1 }
const parent_cluster = resolveParentCluster(new_sp.parent) orelse return -1
const new_name = fat32.encode8_3(new_sp.base) orelse return -1 // reject a name that does not fit 8.3
const old_name = fat32.encode8_3(old_sp.base) orelse return -1
// A rename to a different name must not clobber an existing target; a
// same-name rename skips the probe (the entry would match itself).
if (!std.mem.eql(u8, &old_name, &new_name)) {
if (probeExists(parent_cluster, new_name) != 0) { return -1 }
}
fat32.writeDirEntry(&mount_info, found.lba, found.byte_offset, new_name, found.entry.attr, fat32.firstCluster(found.entry), found.entry.file_size) catch return -1
return 0
}
// ---------------------------------------------------------------------
// FAT32 splice contract — sub-sector write at File.offset must land
// in the on-disk sector even when a hostile read_fn re-zeros
// sector_buf on the preceding read. The byte-loop splice at
// write():203-208 enforces this against the FAT32 splice reorder
// bug class (Zig 0.16 hoisted inlined @memcpy stores ABOVE the
// read_fn fn-pointer call on aarch64-elf freestanding under
// ReleaseSmall, so read_fn zeroed the splice).
//
// This host-test does NOT reproduce the reorder — the
// aarch64-darwin host LLVM pipeline keeps the splice below the call
// under both byte-loop AND inline-@memcpy variants (verified
// empirically against -Doptimize=ReleaseSmall, 2026-05-24). The
// real regression catcher is `[TEST] fs-roundtrip` on Pi-4 hardware.
// This block's job: (a) document the rationale inline so a future
// "cleanup" PR sees why the byte loop exists, (b) assert the splice
// contract so structural breakage of write() (wrong index, bleed)
// gets caught here.
//
// The antagonist/harvest fn-pointers below drop the original `noinline`
// (Flash has no such keyword): they are only ever called indirectly
// through the block_dev vtable, so they cannot be inlined regardless of
// the annotation — behavior identical.
// ---------------------------------------------------------------------
const testing = std.testing
var antagonist_read_calls u32 = 0
fn antagonistRead(_ u32, buf *mut [512]u8) callconv(.c) i32 {
antagonist_read_calls += 1
#memset(buf, 0)
return 0
}
var harvest_sector [512]u8 align(4) = undefined
var harvest_writes u32 = 0
fn harvestWrite(_ u32, buf *[512]u8) callconv(.c) i32 {
harvest_writes += 1
#memcpy(&harvest_sector, buf)
return 0
}
fn installAntagonist() void {
antagonist_read_calls = 0
harvest_writes = 0
#memset(&harvest_sector, 0xCC)
block_dev.sd_dev = .{ .read_fn = antagonistRead, .write_fn = harvestWrite }
}
fn installMountInfo() void {
mount_info = .{
.bpb = std.mem.zeroes(fat32.Bpb),
.partition_lba = 0,
.fat_lba = 2,
.data_lba = 6,
.sectors_per_cluster = 1,
.bytes_per_cluster = 512,
.fsinfo_lba = 1,
.total_clusters = 124,
.dev = &block_dev.sd_dev,
}
}
test "splice contract: 1-byte sub-sector write lands at File.offset with no bleed" {
installAntagonist()
installMountInfo()
var f File = .{
.ftype = 0,
.refs = 1,
.offset = 100,
.private = 3,
.size = 512,
.sb = &sb,
}
const payload [1]u8 = .{0xAA}
const n = ops_vtable.write(&sb, &f, &payload, 1)
try testing.expectEqual(#as(i64, 1), n)
try testing.expectEqual(#as(u32, 1), antagonist_read_calls)
try testing.expectEqual(#as(u32, 1), harvest_writes)
try testing.expectEqual(#as(u8, 0xAA), harvest_sector[100])
try testing.expectEqual(#as(u8, 0), harvest_sector[99])
try testing.expectEqual(#as(u8, 0), harvest_sector[101])
}
test "splice contract: 4-byte sub-sector write lands at File.offset with no bleed" {
installAntagonist()
installMountInfo()
var f File = .{
.ftype = 0,
.refs = 1,
.offset = 200,
.private = 3,
.size = 512,
.sb = &sb,
}
const payload = [_]u8{ 0xDE, 0xAD, 0xBE, 0xEF }
const n = ops_vtable.write(&sb, &f, &payload, 4)
try testing.expectEqual(#as(i64, 4), n)
try testing.expectEqualSlices(u8, &payload, harvest_sector[200..204])
try testing.expectEqual(#as(u8, 0), harvest_sector[199])
try testing.expectEqual(#as(u8, 0), harvest_sector[204])
}
test "splice contract: whole-file same-length rewrite from offset 0 (shadow rewrite shape)" {
// The sys_passwd write shape: the whole shadow file,
// rewritten in place from offset 0 with byte-identical length.
// Pins three contract points: (a) every byte lands exactly
// (sub-sector splice through the byte loop), (b) no bleed past the
// written length, (c) the same-length write never takes the
// dir-entry resize branch — against this antagonist (no root
// directory) that branch could only return -1, so a non-negative
// return proves it was skipped.
installAntagonist()
installMountInfo()
const content =
"root:4096:" ++ ("aa" ** 16) ++ ":" ++ ("bb" ** 32) ++ "\n" ++
"flash:4096:" ++ ("cc" ** 16) ++ ":" ++ ("dd" ** 32) ++ "\n"
var f File = .{
.ftype = 0,
.refs = 1,
.offset = 0,
.private = 3,
.size = content.len, // same length -> no resize
.sb = &sb,
}
const n = ops_vtable.write(&sb, &f, content, content.len)
try testing.expectEqual(#as(i64, #intCast(content.len)), n)
try testing.expectEqualSlices(u8, content, harvest_sector[0..content.len])
// No bleed past the written length (antagonist zeroed the rest).
try testing.expectEqual(#as(u8, 0), harvest_sector[content.len])
// Offset advanced to exactly size; size untouched (no resize).
try testing.expectEqual(#as(u64, content.len), f.offset)
try testing.expectEqual(#as(u64, content.len), f.size)
}
// readdir fixture — a real root-dir cluster for the enumeration walk
// (the splice tests above use an antagonist with no directory). LBA 0..7
// of an in-memory disk: FAT @ LBA 2 terminates the root chain (cluster 2
// -> EOC), root dir @ LBA 6 (data_lba 6, sec_per_clus 1) carries a volume
// label (skipped), a deleted entry (skipped), a file, and a subdirectory.
var rd_disk [8 * 512]u8 align(512) = undefined
fn rdRead(lba u32, buf *mut [512]u8) callconv(.c) i32 {
const off usize = #as(usize, lba) * 512
if (off + 512 > rd_disk.len) { return -1 }
#memcpy(buf, rd_disk[off..][0..512])
return 0
}
fn setupReaddirFixture() void {
#memset(&rd_disk, 0)
// FAT @ LBA 2: cluster 2 (root) -> EOC so the chain walk stops after
// the single root cluster (entry 2 is at fat byte offset 8).
std.mem.writeInt(u32, rd_disk[2 * 512 + 8 ..][0..4], fat32.FAT_EOC, .little)
// Root dir @ LBA 6 (cluster 2).
const root = rd_disk[6 * 512 .. 7 * 512]
#memcpy(root[0..11], "SCRATCH ") // volume label — skipped
root[0x0B] = fat32.ATTR_VOLUME_ID
#memcpy(root[32..][0..11], "?DELETEDBIN") // deleted — skipped
root[32] = 0xE5
#memcpy(root[64..][0..11], "HELLO TXT") // regular file
root[64 + 0x0B] = fat32.ATTR_ARCHIVE
#memcpy(root[96..][0..11], "SUBDIR ") // directory
root[96 + 0x0B] = fat32.ATTR_DIRECTORY
// Entry 4 onward: first byte 0x00 (end-of-dir) — already zeroed.
}
fn installReaddirMount() void {
block_dev.sd_dev = .{ .read_fn = rdRead, .write_fn = null }
var bpb = std.mem.zeroes(fat32.Bpb)
bpb.root_clus = 2
mount_info = .{
.bpb = bpb,
.partition_lba = 0,
.fat_lba = 2,
.data_lba = 6,
.sectors_per_cluster = 1,
.bytes_per_cluster = 512,
.fsinfo_lba = 1,
.total_clusters = 124,
.dev = &block_dev.sd_dev,
}
}
test "readdir lists root entries, skipping volume label and deleted" {
setupReaddirFixture()
installReaddirMount()
var d vfs.Dirent = .{}
// index 0 -> first real survivor (volume + deleted skipped).
try testing.expectEqual(#as(c_int, 0), ops_vtable.readdir(&sb, "/".ptr, 1, 0, &d))
try testing.expectEqualStrings("hello.txt", std.mem.sliceTo(&d.name, 0))
try testing.expectEqual(vfs.DT_REG, d.d_type)
// index 1 -> the subdirectory, flagged DT_DIR.
try testing.expectEqual(#as(c_int, 0), ops_vtable.readdir(&sb, "/".ptr, 1, 1, &d))
try testing.expectEqualStrings("subdir", std.mem.sliceTo(&d.name, 0))
try testing.expectEqual(vfs.DT_DIR, d.d_type)
// index 2 -> past the last survivor: end sentinel.
try testing.expectEqual(#as(c_int, -1), ops_vtable.readdir(&sb, "/".ptr, 1, 2, &d))
}
test "readdir on a non-root path lists empty (root-only walk)" {
setupReaddirFixture()
installReaddirMount()
var d vfs.Dirent = .{}
try testing.expectEqual(#as(c_int, -1), ops_vtable.readdir(&sb, "/subdir".ptr, 7, 0, &d))
}
test "readdir terminates on self-looping FAT chain" {
setupReaddirFixture()
installReaddirMount()
// Forge a 1-cluster cycle: the root cluster's FAT entry (cluster 2,
// at fat byte 2*512 + 8) points back at itself instead of EOC.
std.mem.writeInt(u32, rd_disk[2 * 512 + 8 ..][0..4], 2, .little)
// Fill the root cluster (LBA 6) with valid, non-matching entries and
// NO 0x00 end-of-dir marker, so the in-cluster scan never stops — the
// walk must follow the self-loop and only the cycle guard can break
// it. A hang here would be a cycle-guard regression.
const root = rd_disk[6 * 512 .. 7 * 512]
var j usize = 0
while (j < 16) {
const off = j * 32
#memcpy(root[off .. off + 11], "OTHER BIN")
root[off + 0x0B] = fat32.ATTR_ARCHIVE
j += 1
}
var d vfs.Dirent = .{}
// A high index forces the walk to traverse every survivor and then
// follow the chain; with the guard it terminates with the -1 sentinel
// instead of spinning forever.
try testing.expectEqual(#as(c_int, -1), ops_vtable.readdir(&sb, "/".ptr, 1, 9999, &d))
}
// Overlay fixture — a root dir carrying PERMS.TAB (with real text in a
// data cluster), SHADOW, and ROUNDTR.DAT, so applyOverlay() and the
// open() metadata selection run against a real lookup + read path.
// Reuses the readdir fixture's in-memory disk (rd_disk) + mount wiring.
const OVERLAY_TEXT = "PERMS.TAB 0600 0 0\nSHADOW 0640 0 0\n"
fn setupOverlayFixture() void {
#memset(&rd_disk, 0)
// FAT @ LBA 2: root chain (cluster 2) -> EOC; PERMS.TAB data
// (cluster 3) -> EOC. FAT entry for cluster N sits at byte N*4.
std.mem.writeInt(u32, rd_disk[2 * 512 + 8 ..][0..4], fat32.FAT_EOC, .little)
std.mem.writeInt(u32, rd_disk[2 * 512 + 12 ..][0..4], fat32.FAT_EOC, .little)
// Root dir @ LBA 6 (cluster 2, data_lba 6).
const root = rd_disk[6 * 512 .. 7 * 512]
// Entry 0: PERMS.TAB -> cluster 3, size = overlay text length.
#memcpy(root[0..11], "PERMS TAB")
root[0x0B] = fat32.ATTR_ARCHIVE
std.mem.writeInt(u16, root[0x1A..][0..2], 3, .little)
std.mem.writeInt(u32, root[0x1C..][0..4], OVERLAY_TEXT.len, .little)
// Entry 1: SHADOW -> cluster 4 (no data needed for open()).
#memcpy(root[32..][0..11], "SHADOW ")
root[32 + 0x0B] = fat32.ATTR_ARCHIVE
std.mem.writeInt(u16, root[32 + 0x1A ..][0..2], 4, .little)
std.mem.writeInt(u32, root[32 + 0x1C ..][0..4], 100, .little)
// Entry 2: ROUNDTR.DAT -> cluster 5.
#memcpy(root[64..][0..11], "ROUNDTR DAT")
root[64 + 0x0B] = fat32.ATTR_ARCHIVE
std.mem.writeInt(u16, root[64 + 0x1A ..][0..2], 5, .little)
std.mem.writeInt(u32, root[64 + 0x1C ..][0..4], 4096, .little)
// PERMS.TAB data @ LBA 7 (cluster 3 = data_lba + (3-2)*1).
#memcpy(rd_disk[7 * 512 ..][0..OVERLAY_TEXT.len], OVERLAY_TEXT)
}
test "overlay: annotated entries, the shadow floor override, and defaults" {
setupOverlayFixture()
installReaddirMount()
applyOverlay()
try testing.expect(overlay_ok)
try testing.expectEqual(#as(usize, 2), overlay_count)
var out vfs.OpenResult = .{}
// SHADOW carries an explicit overlay entry (0640) — it overrides the
// floor (operator's call).
try testing.expectEqual(#as(c_int, 0), ops_vtable.open(&sb, "/shadow".ptr, 7, &out))
try testing.expectEqual(#as(u32, 0o100640), out.mode)
try testing.expectEqual(#as(u32, 0), out.uid)
try testing.expectEqual(#as(u32, 0), out.gid)
// PERMS.TAB protects itself through its self-entry.
try testing.expectEqual(#as(c_int, 0), ops_vtable.open(&sb, "/perms.tab".ptr, 10, &out))
try testing.expectEqual(#as(u32, 0o100600), out.mode)
// ROUNDTR.DAT is un-annotated -> documented default.
try testing.expectEqual(#as(c_int, 0), ops_vtable.open(&sb, "/roundtr.dat".ptr, 12, &out))
try testing.expectEqual(#as(u32, 0o100666), out.mode)
}
test "overlay: absent PERMS.TAB floors shadow at 0600 and keeps defaults elsewhere" {
setupOverlayFixture()
installReaddirMount()
// Delete the PERMS.TAB dir entry, then re-apply: the overlay is gone.
rd_disk[6 * 512] = 0xE5
applyOverlay()
try testing.expect(!overlay_ok)
try testing.expectEqual(#as(usize, 0), overlay_count)
var out vfs.OpenResult = .{}
// The shadow basename floors at 0600 root:root — a lost overlay
// never exposes the on-card password file.
try testing.expectEqual(#as(c_int, 0), ops_vtable.open(&sb, "/shadow".ptr, 7, &out))
try testing.expectEqual(#as(u32, 0o100600), out.mode)
try testing.expectEqual(#as(u32, 0), out.uid)
try testing.expectEqual(#as(u32, 0), out.gid)
// Everything else keeps the documented default.
try testing.expectEqual(#as(c_int, 0), ops_vtable.open(&sb, "/roundtr.dat".ptr, 12, &out))
try testing.expectEqual(#as(u32, 0o100666), out.mode)
}
test "overlay: corrupt PERMS.TAB content is rejected wholesale (floor applies)" {
setupOverlayFixture()
installReaddirMount()
// Corrupt the mode field in the data cluster: "PERMS.TAB 0600 ..."
// -> "PERMS.TAB x600 ..." — overlay.parse rejects the whole file.
rd_disk[7 * 512 + 10] = 'x'
applyOverlay()
try testing.expect(!overlay_ok)
try testing.expectEqual(#as(usize, 0), overlay_count)
var out vfs.OpenResult = .{}
// With the table empty, the floor still protects the shadow file.
try testing.expectEqual(#as(c_int, 0), ops_vtable.open(&sb, "/shadow".ptr, 7, &out))
try testing.expectEqual(#as(u32, 0o100600), out.mode)
}
// ---- empty-file write fixture (read + write in-memory disk) ----
//
// The splice tests above use an antagonist with no real storage; this
// one is an 8-sector read+write disk so the first-write-to-an-empty-file
// path runs end to end. Layout: FAT @ LBA 2 (root cluster 2 -> EOC,
// clusters 3.. FREE), root dir @ LBA 6 with one empty file (EMPTY.TXT,
// first_cluster 0, size 0). data_lba 6, sec/clus 1, so cluster N lives at
// LBA 6 + (N - 2).
var rw_disk [8 * 512]u8 align(512) = undefined
fn rwRead(lba u32, buf *mut [512]u8) callconv(.c) i32 {
const off usize = #as(usize, lba) * 512
if (off + 512 > rw_disk.len) { return -1 }
#memcpy(buf, rw_disk[off..][0..512])
return 0
}
fn rwWrite(lba u32, buf *[512]u8) callconv(.c) i32 {
const off usize = #as(usize, lba) * 512
if (off + 512 > rw_disk.len) { return -1 }
#memcpy(rw_disk[off..][0..512], buf)
return 0
}
fn setupEmptyFileFixture() void {
#memset(&rw_disk, 0)
// FAT @ LBA 2: root cluster 2 -> EOC; clusters 3+ left FREE (0).
std.mem.writeInt(u32, rw_disk[2 * 512 + 8 ..][0..4], fat32.FAT_EOC, .little)
// Root dir @ LBA 6 (cluster 2): one empty file at entry 0.
const root = rw_disk[6 * 512 .. 7 * 512]
#memcpy(root[0..11], "EMPTY TXT")
root[0x0B] = fat32.ATTR_ARCHIVE
// fst_clus_hi/lo = 0, file_size = 0 — already zeroed.
}
fn installRwMount() void {
block_dev.sd_dev = .{ .read_fn = rwRead, .write_fn = rwWrite }
var bpb = std.mem.zeroes(fat32.Bpb)
bpb.root_clus = 2
bpb.num_fats = 1 // single FAT in this fixture (no mirror)
mount_info = .{
.bpb = bpb,
.partition_lba = 0,
.fat_lba = 2,
.data_lba = 6,
.sectors_per_cluster = 1,
.bytes_per_cluster = 512,
.fsinfo_lba = 1,
.total_clusters = 124,
.dev = &block_dev.sd_dev,
}
}
test "write to an empty file allocates a first cluster and records it in the dir entry" {
setupEmptyFileFixture()
installRwMount()
// open() resolves the entry and stashes its on-disk location.
var out vfs.OpenResult = .{}
try testing.expectEqual(#as(c_int, 0), ops_vtable.open(&sb, "/empty.txt".ptr, 10, &out))
try testing.expectEqual(#as(u64, 0), out.private) // empty -> first_cluster 0
try testing.expectEqual(#as(u32, 6), out.dirent_lba) // root dir LBA
try testing.expectEqual(#as(u32, 0), out.dirent_off) // first entry
// Build the handle the way sys_openFile would (private + dirent loc).
var f File = .{
.refs = 1,
.offset = 0,
.private = out.private,
.size = out.size,
.sb = &sb,
.dirent_lba = out.dirent_lba,
.dirent_off = out.dirent_off,
}
const payload = [_]u8{ 0xDE, 0xAD, 0xBE, 0xEF }
try testing.expectEqual(#as(i64, 4), ops_vtable.write(&sb, &f, &payload, 4))
// (a) allocCluster reserved cluster 3 and linked it EOC.
try testing.expectEqual(fat32.FAT_EOC, fat32.readFatEntry(&mount_info, 3) catch unreachable)
// (b) the dir entry now points at cluster 3 (hi:lo = 0:3).
const root = rw_disk[6 * 512 .. 7 * 512]
try testing.expectEqual(#as(u16, 3), std.mem.readInt(u16, root[0x1A..][0..2], .little))
try testing.expectEqual(#as(u16, 0), std.mem.readInt(u16, root[0x14..][0..2], .little))
// (c) the dir-entry size grew to 4.
try testing.expectEqual(#as(u32, 4), std.mem.readInt(u32, root[0x1C..][0..4], .little))
// (d) the bytes landed at cluster 3's LBA (6 + (3 - 2) = 7).
try testing.expectEqualSlices(u8, &payload, rw_disk[7 * 512 ..][0..4])
// (e) the handle adopted the new chain head and grew its size.
try testing.expectEqual(#as(u64, 3), f.private)
try testing.expectEqual(#as(u64, 4), f.size)
}
test "write past EOF on an existing file grows size via the stashed dirent location" {
setupEmptyFileFixture()
// Pre-seed EMPTY.TXT with a first cluster (3) + size 4, FAT[3] -> EOC.
// (Bare scoping block hoisted; `seed_root` renamed off the later `root`
// — Flash has no anonymous block statement.)
const seed_root = rw_disk[6 * 512 .. 7 * 512]
std.mem.writeInt(u16, seed_root[0x1A..][0..2], 3, .little) // fst_clus_lo = 3
std.mem.writeInt(u32, seed_root[0x1C..][0..4], 4, .little) // size = 4
std.mem.writeInt(u32, rw_disk[2 * 512 + 12 ..][0..4], fat32.FAT_EOC, .little) // FAT[3]
installRwMount()
var f File = .{
.refs = 1,
.offset = 4, // append at the current EOF
.private = 3,
.size = 4,
.sb = &sb,
.dirent_lba = 6,
.dirent_off = 0,
}
const payload = [_]u8{ 0x11, 0x22 }
try testing.expectEqual(#as(i64, 2), ops_vtable.write(&sb, &f, &payload, 2))
const root = rw_disk[6 * 512 .. 7 * 512]
// Size grew 4 -> 6 in the dir entry (step 3 via the stashed location,
// not the removed first-cluster re-walk).
try testing.expectEqual(#as(u32, 6), std.mem.readInt(u32, root[0x1C..][0..4], .little))
// The appended bytes landed at cluster 3, byte offset 4 (LBA 7).
try testing.expectEqualSlices(u8, &payload, rw_disk[7 * 512 + 4 ..][0..2])
try testing.expectEqual(#as(u64, 6), f.size)
}
// ---- create / unlink / rename (read+write in-memory disk) ----
//
// Reuse the empty-file fixture's 8-sector rw_disk: root cluster 2 -> EOC,
// EMPTY.TXT at root slot 0 (first_cluster 0, size 0), clusters 3+ free.
// Seed EMPTY.TXT with a one-cluster chain (cluster 3, size 4) so unlink /
// rename have a real chain to act on. Patches the dir entry + FAT[3].
fn seedEmptyWithChain() void {
const root = rw_disk[6 * 512 .. 7 * 512]
std.mem.writeInt(u16, root[0x1A..][0..2], 3, .little) // fst_clus_lo = 3
std.mem.writeInt(u32, root[0x1C..][0..4], 4, .little) // size = 4
std.mem.writeInt(u32, rw_disk[2 * 512 + 12 ..][0..4], fat32.FAT_EOC, .little) // FAT[3]
}
test "create stamps a new entry that open then resolves" {
setupEmptyFileFixture()
installRwMount()
var out vfs.OpenResult = .{}
try testing.expectEqual(#as(c_int, 0), ops_vtable.create(&sb, "/new.fl".ptr, 7, &out))
try testing.expectEqual(#as(u64, 0), out.private) // empty: no first cluster yet
try testing.expectEqual(#as(u64, 0), out.size)
try testing.expectEqual(#as(u32, 0o100644), out.mode)
// The new entry took the first free slot (slot 1; slot 0 is EMPTY.TXT).
try testing.expectEqual(#as(u32, 6), out.dirent_lba)
try testing.expectEqual(#as(u32, 32), out.dirent_off)
// open now resolves it.
var o2 vfs.OpenResult = .{}
try testing.expectEqual(#as(c_int, 0), ops_vtable.open(&sb, "/new.fl".ptr, 7, &o2))
try testing.expectEqual(#as(u64, 0), o2.size)
}
test "create rejects an existing name" {
setupEmptyFileFixture()
installRwMount()
var out vfs.OpenResult = .{}
// EMPTY.TXT already exists at the root.
try testing.expectEqual(#as(c_int, -1), ops_vtable.create(&sb, "/empty.txt".ptr, 10, &out))
}
test "create rejects a name that does not fit 8.3" {
setupEmptyFileFixture()
installRwMount()
var out vfs.OpenResult = .{}
// "toolongname" is 11 chars (> 8) — encode8_3 rejects, create fails clean.
try testing.expectEqual(#as(c_int, -1), ops_vtable.create(&sb, "/toolongname.flash".ptr, 18, &out))
}
test "unlink removes a file and frees its cluster chain" {
setupEmptyFileFixture()
seedEmptyWithChain()
installRwMount()
try testing.expectEqual(#as(c_int, 0), ops_vtable.unlink(&sb, "/empty.txt".ptr, 10))
// open now misses (the entry is tombstoned).
var out vfs.OpenResult = .{}
try testing.expectEqual(#as(c_int, -1), ops_vtable.open(&sb, "/empty.txt".ptr, 10, &out))
// Cluster 3 was returned to the free list.
try testing.expectEqual(fat32.FAT_FREE, fat32.readFatEntry(&mount_info, 3) catch unreachable)
}
test "rename rewrites a name in place, preserving cluster and size" {
setupEmptyFileFixture()
seedEmptyWithChain()
installRwMount()
try testing.expectEqual(#as(c_int, 0), ops_vtable.rename(&sb, "/empty.txt".ptr, 10, "/renamed.fl".ptr, 11))
// The new name resolves with the same first cluster + size.
var out vfs.OpenResult = .{}
try testing.expectEqual(#as(c_int, 0), ops_vtable.open(&sb, "/renamed.fl".ptr, 11, &out))
try testing.expectEqual(#as(u64, 4), out.size)
try testing.expectEqual(#as(u64, 3), out.private)
// The old name is gone.
try testing.expectEqual(#as(c_int, -1), ops_vtable.open(&sb, "/empty.txt".ptr, 10, &out))
}
test "rename rejects a cross-directory move (different parent)" {
setupEmptyFileFixture()
installRwMount()
// old is root-level, new names a subdirectory: the same-dir guard rejects
// before any rewrite (cross-dir is mv's copy+unlink job).
try testing.expectEqual(#as(c_int, -1), ops_vtable.rename(&sb, "/empty.txt".ptr, 10, "/sub/empty.txt".ptr, 14))
}