// 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)) }