From e240341bfb8de6d16f4c88d4026afcb040f8f963 Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:11:05 +0100 Subject: [PATCH] exfat: the engine write path (S4 step 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create / write / truncate / remove / mkdir / rename, completing the engine's pub-fn contract with the shared harness. The allocation BITMAP is the authority: setAllocated IS the allocation (a set bit), and the 32-bit FAT only records a fragmented chain's order — forgetting the bit would hand a live cluster out twice, so the write path never touches the FAT without also owning the bit. This engine writes FAT-linked (no_fat_chain=0) files: createFile makes an empty set; writeFile allocates+links+zeroes clusters (so a sparse gap reads zero and valid_data_length can honestly equal data_length) and rewrites the Stream entry; a contiguous file opened for growth is first threaded through the FAT. truncate frees the chain; removeFile clears each set entry's InUse bit and frees the chain (a non-empty directory is refused); rename re-homes the same clusters under a new name set. createDirectory allocates one zeroed cluster — exFAT directories carry no "." / ".." entries. Every entry-set mutation recomputes the set checksum. Offsets clamp to the vfs u32 surface. Ten engine host tests now (read + write across clusters, truncate+reuse, remove, subdir+inner file, rename); 17 total with on-disk. bounds green. --- system/services/exfat/engine.zig | 548 +++++++++++++++++++++++++++++++ 1 file changed, 548 insertions(+) diff --git a/system/services/exfat/engine.zig b/system/services/exfat/engine.zig index da0ec56..d7108aa 100644 --- a/system/services/exfat/engine.zig +++ b/system/services/exfat/engine.zig @@ -489,6 +489,427 @@ pub const FileSystem = struct { } return got; } + + // --- allocation: the bitmap is the authority ---------------------------- + + fn bitmapByteAndMask(cluster: u32) struct { byte: u64, mask: u8 } { + const bit = cluster - on_disk.first_data_cluster; + return .{ .byte = bit / 8, .mask = @as(u8, 1) << @intCast(bit % 8) }; + } + + fn bitmapLocate(self: *FileSystem, byte: u64) ?struct { lba: u64, within: usize } { + const cluster_bytes = self.clusterBytes(); + const cluster = self.clusterOfChain(self.bitmap_first_cluster, false, @intCast(byte / cluster_bytes)) orelse return null; + const in_cluster: u32 = @intCast(byte % cluster_bytes); + return .{ .lba = self.clusterSector(cluster, in_cluster / sector_size), .within = in_cluster % sector_size }; + } + + /// Set or clear a cluster's allocation bit. Setting the bit IS the allocation — + /// the bitmap, not the FAT, is what says a cluster is in use; forgetting it + /// would corrupt free space (a later allocation could hand the cluster out + /// again). The FAT only records the ORDER of a fragmented chain. + fn setAllocated(self: *FileSystem, cluster: u32, used: bool) bool { + if (!self.validCluster(cluster)) return false; + const bb = bitmapByteAndMask(cluster); + const loc = self.bitmapLocate(bb.byte) orelse return false; + if (!self.blockRead(loc.lba, &self.sector)) return false; + if (used) self.sector[loc.within] |= bb.mask else self.sector[loc.within] &= ~bb.mask; + return self.blockWrite(loc.lba, &self.sector); + } + + fn isAllocated(self: *FileSystem, cluster: u32) bool { + const bb = bitmapByteAndMask(cluster); + const loc = self.bitmapLocate(bb.byte) orelse return true; // unknown: never hand it out + if (!self.blockRead(loc.lba, &self.sector)) return true; + return self.sector[loc.within] & bb.mask != 0; + } + + /// Allocate one free cluster (mark its bit), or null if the volume is full. + fn allocateCluster(self: *FileSystem) ?u32 { + var cluster: u32 = on_disk.first_data_cluster; + const end = self.geometry.cluster_count + on_disk.first_data_cluster; + while (cluster < end) : (cluster += 1) { + if (!self.isAllocated(cluster)) { + return if (self.setAllocated(cluster, true)) cluster else null; + } + } + return null; + } + + fn writeFatEntry(self: *FileSystem, cluster: u32, value: u32) bool { + const byte = @as(u64, self.geometry.fat_offset_sectors) * sector_size + @as(u64, cluster) * 4; + const lba = byte / sector_size; + const within: usize = @intCast(byte % sector_size); + if (!self.blockRead(lba, &self.sector)) return false; + std.mem.writeInt(u32, self.sector[within..][0..4], value, .little); + return self.blockWrite(lba, &self.sector); + } + + fn zeroCluster(self: *FileSystem, cluster: u32) bool { + const zero = [_]u8{0} ** sector_size; + var s: u32 = 0; + while (s < self.geometry.sectors_per_cluster) : (s += 1) { + if (!self.blockWrite(self.clusterSector(cluster, s), &zero)) return false; + } + return true; + } + + /// Free a chain: clear each cluster's bitmap bit. Contiguous chains are freed by + /// count; FAT-linked chains are walked (a corrupt cycle is bounded by the + /// cluster count). + fn freeChain(self: *FileSystem, first: u32, no_fat_chain: bool, cluster_count: u32) void { + if (!self.validCluster(first)) return; + if (no_fat_chain) { + var c = first; + var n = cluster_count; + while (n > 0 and self.validCluster(c)) : (n -= 1) { + _ = self.setAllocated(c, false); + c += 1; + } + return; + } + var cluster = first; + var guard: u32 = 0; + while (self.validCluster(cluster) and guard < self.geometry.cluster_count) : (guard += 1) { + const next = self.readFatEntry(cluster); + _ = self.setAllocated(cluster, false); + if (self.isEndOfChain(next) or !self.validCluster(next)) break; + cluster = next; + } + } + + // --- entry-set read / write (checksum recomputed on every mutation) ------ + + const set_buffer_bytes = (2 + (name_maximum + name_units_per_entry - 1) / name_units_per_entry) * on_disk.entry_bytes; + + fn writeEntry(self: *FileSystem, dir_first: u32, dir_no_fat_chain: bool, index: u64, entry: []const u8) bool { + const cluster_bytes = self.clusterBytes(); + const byte = index * on_disk.entry_bytes; + const cluster = self.clusterOfChain(dir_first, dir_no_fat_chain, @intCast(byte / cluster_bytes)) orelse return false; + const in_cluster: u32 = @intCast(byte % cluster_bytes); + const lba = self.clusterSector(cluster, in_cluster / sector_size); + const within = in_cluster % sector_size; + if (!self.blockRead(lba, &self.sector)) return false; + @memcpy(self.sector[within..][0..on_disk.entry_bytes], entry[0..on_disk.entry_bytes]); + return self.blockWrite(lba, &self.sector); + } + + fn readSet(self: *FileSystem, node: Node, buf: []u8) usize { + const count = @as(usize, node.secondary_count) + 1; + const total = count * on_disk.entry_bytes; + if (buf.len < total) return 0; + var i: usize = 0; + while (i < count) : (i += 1) { + var entry: [on_disk.entry_bytes]u8 = undefined; + if (!self.entryAt(node.parent_first_cluster, node.parent_no_fat_chain, node.entry_index + i, &entry)) return 0; + @memcpy(buf[i * on_disk.entry_bytes ..][0..on_disk.entry_bytes], &entry); + } + return total; + } + + /// Write `buf` (a whole set) back, recomputing the set checksum first. + fn writeSet(self: *FileSystem, node: Node, buf: []u8) bool { + std.mem.writeInt(u16, buf[2..4], on_disk.setChecksum(buf), .little); + var i: usize = 0; + const count = @as(usize, node.secondary_count) + 1; + while (i < count) : (i += 1) { + if (!self.writeEntry(node.parent_first_cluster, node.parent_no_fat_chain, node.entry_index + i, buf[i * on_disk.entry_bytes ..][0..on_disk.entry_bytes])) return false; + } + return true; + } + + /// Rewrite a node's Stream entry (sizes, first cluster, chain flag) and the + /// File entry's modified timestamp, recomputing the set checksum. + fn updateStream(self: *FileSystem, node: Node) void { + var buf: [set_buffer_bytes]u8 = undefined; + const total = self.readSet(node, &buf); + if (total == 0) return; + var file = std.mem.bytesToValue(on_disk.FileEntry, buf[0..on_disk.entry_bytes]); + file.last_modified_timestamp = on_disk.epochToTimestamp(self.current_time_epoch); + @memcpy(buf[0..on_disk.entry_bytes], std.mem.asBytes(&file)); + var stream = std.mem.bytesToValue(on_disk.StreamExtensionEntry, buf[on_disk.entry_bytes..][0..on_disk.entry_bytes]); + stream.first_cluster = node.first_cluster; + stream.data_length = node.size; + stream.valid_data_length = node.valid_data_length; + stream.general_secondary_flags = on_disk.secondary_flag_allocation_possible | + (if (node.no_fat_chain) on_disk.secondary_flag_no_fat_chain else 0); + @memcpy(buf[on_disk.entry_bytes..][0..on_disk.entry_bytes], std.mem.asBytes(&stream)); + _ = self.writeSet(node, buf[0..total]); + } + + // --- directory-entry placement ------------------------------------------ + + /// Ensure the directory's cluster chain holds at least `entry_index+1` entries, + /// extending it (a fresh zeroed FAT-linked cluster) as needed. Assumes a + /// FAT-linked directory (the root and directories this engine creates). + fn ensureDirCapacity(self: *FileSystem, dir: Node, entry_index: u64) bool { + const cluster_bytes = self.clusterBytes(); + const clusters_needed: u32 = @intCast(((entry_index + 1) * on_disk.entry_bytes + cluster_bytes - 1) / cluster_bytes); + if (dir.no_fat_chain) { + // A contiguous directory cannot be grown here; it fits only if it + // already spans enough clusters. + const have: u32 = @intCast((@as(u64, dir.size) + cluster_bytes - 1) / cluster_bytes); + return clusters_needed <= have; + } + var cluster = dir.first_cluster; + if (!self.validCluster(cluster)) return false; + var have: u32 = 1; + while (have < clusters_needed) : (have += 1) { + const next = self.readFatEntry(cluster); + if (self.isEndOfChain(next) or !self.validCluster(next)) { + const fresh = self.allocateCluster() orelse return false; + if (!self.zeroCluster(fresh)) return false; + if (!self.writeFatEntry(cluster, fresh)) return false; + if (!self.writeFatEntry(fresh, on_disk.end_of_chain)) return false; + cluster = fresh; + } else { + cluster = next; + } + } + return true; + } + + /// Place a `count`-entry set at the directory's first end-of-directory marker, + /// growing the directory if needed. Returns the starting linear entry index. + /// (Deleted-run reuse is a later refinement; this appends.) + fn appendEntrySet(self: *FileSystem, dir: Node, set: []const u8, count: u8) ?u64 { + var index: u64 = 0; + while (index < directory_entry_scan_maximum) : (index += 1) { + var entry: [on_disk.entry_bytes]u8 = undefined; + if (!self.entryAt(dir.first_cluster, dir.no_fat_chain, index, &entry)) break; // ran off the chain — grow + if (entry[0] == on_disk.entry_type_end_of_directory) break; + } + // Room for the set AND a following end marker (freshly-allocated clusters + // are zeroed, so the marker is already there once capacity is ensured). + if (!self.ensureDirCapacity(dir, index + count)) return null; + var i: u64 = 0; + while (i < count) : (i += 1) { + if (!self.writeEntry(dir.first_cluster, dir.no_fat_chain, index + i, set[@intCast(i * on_disk.entry_bytes)..][0..on_disk.entry_bytes])) return null; + } + return index; + } + + /// Assemble a File+Stream+Name set for `name` into `buf`, returning the entry + /// count. Names are stored in their original case; the Stream's hash is over + /// the up-cased name. + fn buildFileSet(self: *FileSystem, name: []const u8, is_directory: bool, first_cluster: u32, data_length: u64, no_fat_chain: bool, buf: []u8) u8 { + const name_entries: u8 = @intCast((name.len + name_units_per_entry - 1) / name_units_per_entry); + const count: u8 = 2 + name_entries; + @memset(buf[0 .. @as(usize, count) * on_disk.entry_bytes], 0); + + var file = std.mem.zeroes(on_disk.FileEntry); + file.entry_type = on_disk.entry_type_file; + file.secondary_count = 1 + name_entries; + file.file_attributes = if (is_directory) on_disk.attribute_directory else on_disk.attribute_archive; + const stamp = on_disk.epochToTimestamp(self.current_time_epoch); + file.create_timestamp = stamp; + file.last_modified_timestamp = stamp; + file.last_accessed_timestamp = stamp; + @memcpy(buf[0..on_disk.entry_bytes], std.mem.asBytes(&file)); + + var stream = std.mem.zeroes(on_disk.StreamExtensionEntry); + stream.entry_type = on_disk.entry_type_stream_extension; + stream.general_secondary_flags = on_disk.secondary_flag_allocation_possible | + (if (no_fat_chain) on_disk.secondary_flag_no_fat_chain else 0); + stream.name_length = @intCast(name.len); + var upname: [name_maximum]u16 = undefined; + for (name, 0..) |c, i| upname[i] = self.fold(c); + stream.name_hash = on_disk.nameHash(upname[0..name.len]); + stream.valid_data_length = data_length; + stream.first_cluster = first_cluster; + stream.data_length = data_length; + @memcpy(buf[on_disk.entry_bytes..][0..on_disk.entry_bytes], std.mem.asBytes(&stream)); + + var e: u8 = 0; + while (e < name_entries) : (e += 1) { + var name_entry = std.mem.zeroes(on_disk.FileNameEntry); + name_entry.entry_type = on_disk.entry_type_file_name; + var u: usize = 0; + while (u < name_units_per_entry) : (u += 1) { + const idx = @as(usize, e) * name_units_per_entry + u; + if (idx < name.len) name_entry.file_name[u] = name[idx]; + } + @memcpy(buf[(2 + @as(usize, e)) * on_disk.entry_bytes ..][0..on_disk.entry_bytes], std.mem.asBytes(&name_entry)); + } + std.mem.writeInt(u16, buf[2..4], on_disk.setChecksum(buf[0 .. @as(usize, count) * on_disk.entry_bytes]), .little); + return count; + } + + // --- write / create / truncate / remove / rename ------------------------ + + /// Convert a contiguous file to a FAT-linked one (this engine's writes are + /// FAT-linked), by threading its existing clusters through the FAT. + fn ensureFatChain(self: *FileSystem, node: *Node) bool { + if (!node.no_fat_chain) return true; + const cluster_bytes = self.clusterBytes(); + const clusters: u32 = if (node.size == 0) 0 else @intCast((@as(u64, node.size) + cluster_bytes - 1) / cluster_bytes); + var i: u32 = 0; + while (i + 1 < clusters) : (i += 1) { + if (!self.writeFatEntry(node.first_cluster + i, node.first_cluster + i + 1)) return false; + } + if (clusters > 0 and !self.writeFatEntry(node.first_cluster + clusters - 1, on_disk.end_of_chain)) return false; + node.no_fat_chain = false; + return true; + } + + /// Ensure a FAT-linked file spans at least `clusters_needed` clusters, zeroing + /// each freshly allocated one (so a sparse gap reads as zero and + /// valid_data_length can equal data_length honestly). + fn ensureFileClusters(self: *FileSystem, node: *Node, clusters_needed: u32) bool { + if (clusters_needed == 0) return true; + const cluster_bytes = self.clusterBytes(); + var have: u32 = if (self.validCluster(node.first_cluster)) @intCast((@as(u64, node.size) + cluster_bytes - 1) / cluster_bytes) else 0; + if (have >= clusters_needed) return true; + var last: u32 = 0; + if (have > 0) last = self.clusterOfChain(node.first_cluster, node.no_fat_chain, have - 1) orelse return false; + while (have < clusters_needed) : (have += 1) { + const fresh = self.allocateCluster() orelse return false; + if (!self.zeroCluster(fresh)) return false; + if (!self.writeFatEntry(fresh, on_disk.end_of_chain)) return false; + if (have == 0) node.first_cluster = fresh else if (!self.writeFatEntry(last, fresh)) return false; + last = fresh; + } + return true; + } + + /// Write `data` at `offset`, growing the file (bitmap allocation + FAT links) + /// as needed. Returns bytes written. Clamped to the vfs u32 offset surface. + pub fn writeFile(self: *FileSystem, node: *Node, offset: u32, data: []const u8) usize { + if (data.len == 0) return 0; + const write_len: u32 = @intCast(@min(data.len, @as(usize, std.math.maxInt(u32) - offset))); + if (write_len == 0) return 0; + if (node.no_fat_chain and !self.ensureFatChain(node)) return 0; + const cluster_bytes = self.clusterBytes(); + const clusters_needed = (offset + write_len + cluster_bytes - 1) / cluster_bytes; + if (!self.ensureFileClusters(node, clusters_needed)) return 0; + + var produced: usize = 0; + var position = offset; + while (produced < write_len) { + const cluster = self.clusterOfChain(node.first_cluster, node.no_fat_chain, position / cluster_bytes) orelse break; + const in_cluster = position % cluster_bytes; + const lba = self.clusterSector(cluster, in_cluster / sector_size); + const in_sector = in_cluster % sector_size; + const n = @min(write_len - produced, sector_size - in_sector); + if (in_sector == 0 and n == sector_size) { + if (!self.blockWrite(lba, data[produced .. produced + sector_size])) break; + } else { + if (!self.blockRead(lba, &self.sector)) break; + @memcpy(self.sector[in_sector .. in_sector + n], data[produced .. produced + n]); + if (!self.blockWrite(lba, &self.sector)) break; + } + produced += n; + position += @intCast(n); + } + const written_end = offset + @as(u32, @intCast(produced)); + if (written_end > node.size) node.size = written_end; + // Every allocated cluster is zeroed, so all bytes up to size are valid. + node.valid_data_length = node.size; + self.updateStream(node.*); + return produced; + } + + /// Empty a file: free its chain and zero its stream. + pub fn truncate(self: *FileSystem, node: *Node) void { + if (self.validCluster(node.first_cluster)) { + const cluster_bytes = self.clusterBytes(); + const clusters: u32 = @intCast(@max(@as(u64, 1), (@as(u64, node.size) + cluster_bytes - 1) / cluster_bytes)); + self.freeChain(node.first_cluster, node.no_fat_chain, clusters); + } + node.first_cluster = 0; + node.size = 0; + node.valid_data_length = 0; + node.no_fat_chain = false; + self.updateStream(node.*); + } + + pub fn createFile(self: *FileSystem, dir: Node, name: []const u8) ?Node { + if (name.len == 0 or name.len > name_maximum) return null; + if (self.findChild(dir, name) != null) return null; + var set_buf: [set_buffer_bytes]u8 = undefined; + const count = self.buildFileSet(name, false, 0, 0, false, &set_buf); + const index = self.appendEntrySet(dir, set_buf[0 .. @as(usize, count) * on_disk.entry_bytes], count) orelse return null; + return .{ + .first_cluster = 0, + .size = 0, + .is_directory = false, + .no_fat_chain = false, + .valid_data_length = 0, + .parent_first_cluster = dir.first_cluster, + .parent_no_fat_chain = dir.no_fat_chain, + .entry_index = index, + .secondary_count = count - 1, + .has_entry = true, + }; + } + + pub fn createDirectory(self: *FileSystem, dir: Node, name: []const u8) ?Node { + if (name.len == 0 or name.len > name_maximum) return null; + if (self.findChild(dir, name) != null) return null; + const cluster = self.allocateCluster() orelse return null; + if (!self.zeroCluster(cluster)) return null; + if (!self.writeFatEntry(cluster, on_disk.end_of_chain)) return null; // exFAT dirs have no . / .. entries + const cluster_bytes = self.clusterBytes(); + var set_buf: [set_buffer_bytes]u8 = undefined; + const count = self.buildFileSet(name, true, cluster, cluster_bytes, false, &set_buf); + const index = self.appendEntrySet(dir, set_buf[0 .. @as(usize, count) * on_disk.entry_bytes], count) orelse { + self.freeChain(cluster, false, 1); + return null; + }; + return .{ + .first_cluster = cluster, + .size = cluster_bytes, + .is_directory = true, + .no_fat_chain = false, + .valid_data_length = cluster_bytes, + .parent_first_cluster = dir.first_cluster, + .parent_no_fat_chain = dir.no_fat_chain, + .entry_index = index, + .secondary_count = count - 1, + .has_entry = true, + }; + } + + /// Mark a set's entries deleted (clear the InUse bit) and free its data. A + /// non-empty directory is refused. + fn deleteSet(self: *FileSystem, dir: Node, node: Node) bool { + var i: u64 = 0; + while (i <= node.secondary_count) : (i += 1) { + var entry: [on_disk.entry_bytes]u8 = undefined; + if (!self.entryAt(dir.first_cluster, dir.no_fat_chain, node.entry_index + i, &entry)) return false; + entry[0] &= ~on_disk.entry_type_in_use_bit; + if (!self.writeEntry(dir.first_cluster, dir.no_fat_chain, node.entry_index + i, &entry)) return false; + } + return true; + } + + pub fn removeFile(self: *FileSystem, dir: Node, name: []const u8) bool { + const node = self.findChild(dir, name) orelse return false; + if (node.is_directory and self.listEntry(node, 0) != null) return false; // not empty + if (self.validCluster(node.first_cluster)) { + const cluster_bytes = self.clusterBytes(); + const clusters: u32 = @intCast(@max(@as(u64, 1), (@as(u64, node.size) + cluster_bytes - 1) / cluster_bytes)); + self.freeChain(node.first_cluster, node.no_fat_chain, clusters); + } + return self.deleteSet(dir, node); + } + + /// Rename within a directory: place a new set under `new_name` pointing at the + /// same data, then delete the old set (the clusters move, they are not freed). + pub fn rename(self: *FileSystem, dir: Node, old_name: []const u8, new_name: []const u8) bool { + if (new_name.len == 0 or new_name.len > name_maximum) return false; + const node = self.findChild(dir, old_name) orelse return false; + if (self.findChild(dir, new_name) != null) return false; + var set_buf: [set_buffer_bytes]u8 = undefined; + const count = self.buildFileSet(new_name, node.is_directory, node.first_cluster, node.size, node.no_fat_chain, &set_buf); + // Preserve the original valid-data-length (buildFileSet set it to data_length). + var stream = std.mem.bytesToValue(on_disk.StreamExtensionEntry, set_buf[on_disk.entry_bytes..][0..on_disk.entry_bytes]); + stream.valid_data_length = node.valid_data_length; + @memcpy(set_buf[on_disk.entry_bytes..][0..on_disk.entry_bytes], std.mem.asBytes(&stream)); + std.mem.writeInt(u16, set_buf[2..4], on_disk.setChecksum(set_buf[0 .. @as(usize, count) * on_disk.entry_bytes]), .little); + if (self.appendEntrySet(dir, set_buf[0 .. @as(usize, count) * on_disk.entry_bytes], count) == null) return false; + return self.deleteSet(dir, node); + } }; fn clampU32(value: u64) u32 { @@ -535,6 +956,10 @@ const test_clusters = 64; const test_fat_sector = 8; const test_heap_sector = 9; const test_read_bytes = 1024; // the two-cluster test files are 1024 bytes +const test_notes_bytes = 1500; // a three-cluster write in the create/write test +const test_tail_bytes = 4; +const test_inner_bytes = 300; +const test_rename_bytes = 800; fn testCluster(cluster: u32) usize { return (test_heap_sector + (cluster - 2)) * sector_size; @@ -728,3 +1153,126 @@ test "read a fragmented file follows the FAT across non-contiguous clusters" { try std.testing.expectEqual(@as(usize, 10), mid); for (readback[0..10], 600..) |byte, i| try std.testing.expectEqual(@as(u8, @truncate(i +% 100)), byte); } + +test "create, write across clusters, and read back (FAT-linked)" { + const allocator = std.testing.allocator; + const bytes = try allocator.alloc(u8, 128 * sector_size); + defer allocator.free(bytes); + var disk = RamDisk{ .bytes = bytes }; + formatExfat(bytes); + var fs = FileSystem.mount(disk.device()).?; + fs.current_time_epoch = 1_700_000_000; + + var node = fs.createFile(fs.rootNode(), "NOTES.TXT").?; + var payload: [test_notes_bytes]u8 = undefined; // three 512-byte clusters + for (&payload, 0..) |*b, i| b.* = @truncate(i *% 7); + try std.testing.expectEqual(@as(usize, payload.len), fs.writeFile(&node, 0, &payload)); + + // Re-resolve from the directory (proving persistence) and read back. + const resolved = fs.resolve("/NOTES.TXT").?; + try std.testing.expectEqual(@as(u32, payload.len), resolved.size); + try std.testing.expect(!resolved.no_fat_chain); // this engine writes FAT-linked + var readback: [test_notes_bytes]u8 = undefined; + try std.testing.expectEqual(@as(usize, payload.len), fs.readFile(resolved, 0, &readback)); + try std.testing.expectEqualSlices(u8, &payload, &readback); + + // The root now lists NOTES.TXT alongside the seeded files. + try std.testing.expect(fs.resolve("/notes.txt") != null); // case-insensitive + const mtime = resolved.mtime; + try std.testing.expect(mtime != 0); // the write stamped it +} + +test "truncate frees the chain and empties the file" { + const allocator = std.testing.allocator; + const bytes = try allocator.alloc(u8, 128 * sector_size); + defer allocator.free(bytes); + var disk = RamDisk{ .bytes = bytes }; + formatExfat(bytes); + var fs = FileSystem.mount(disk.device()).?; + fs.current_time_epoch = 1_700_000_000; + + var node = fs.createFile(fs.rootNode(), "TMP").?; + var payload = [_]u8{0xCD} ** 1000; + _ = fs.writeFile(&node, 0, &payload); + const cluster = fs.resolve("/TMP").?.first_cluster; + try std.testing.expect(fs.validCluster(cluster)); + try std.testing.expect(fs.isAllocated(cluster)); + + fs.truncate(&node); + const empty = fs.resolve("/TMP").?; + try std.testing.expectEqual(@as(u32, 0), empty.size); + try std.testing.expect(!fs.isAllocated(cluster)); // its cluster is free again + + // Overwrite after truncate leaves no stale tail. + var fresh = [_]u8{0xEE} ** 4; + _ = fs.writeFile(&node, 0, &fresh); + var readback: [test_tail_bytes]u8 = undefined; + _ = fs.readFile(fs.resolve("/TMP").?, 0, &readback); + try std.testing.expectEqualSlices(u8, &fresh, &readback); +} + +test "remove deletes the entry and frees its chain" { + const allocator = std.testing.allocator; + const bytes = try allocator.alloc(u8, 128 * sector_size); + defer allocator.free(bytes); + var disk = RamDisk{ .bytes = bytes }; + formatExfat(bytes); + var fs = FileSystem.mount(disk.device()).?; + fs.current_time_epoch = 1_700_000_000; + + var node = fs.createFile(fs.rootNode(), "GONE").?; + _ = fs.writeFile(&node, 0, &[_]u8{0x11} ** 700); + const cluster = fs.resolve("/GONE").?.first_cluster; + try std.testing.expect(fs.removeFile(fs.rootNode(), "GONE")); + try std.testing.expect(fs.resolve("/GONE") == null); + try std.testing.expect(!fs.isAllocated(cluster)); + try std.testing.expect(!fs.removeFile(fs.rootNode(), "GONE")); // already gone +} + +test "create a subdirectory and a file inside it" { + const allocator = std.testing.allocator; + const bytes = try allocator.alloc(u8, 128 * sector_size); + defer allocator.free(bytes); + var disk = RamDisk{ .bytes = bytes }; + formatExfat(bytes); + var fs = FileSystem.mount(disk.device()).?; + fs.current_time_epoch = 1_700_000_000; + + const dir = fs.createDirectory(fs.rootNode(), "SUB").?; + try std.testing.expect(dir.is_directory); + // exFAT directories carry no "." / ".." — a fresh one lists nothing. + try std.testing.expect(fs.listEntry(dir, 0) == null); + + const resolved_dir = fs.resolve("/SUB").?; + var inner = fs.createFile(resolved_dir, "INNER.DAT").?; + _ = fs.writeFile(&inner, 0, &[_]u8{0xA5} ** 300); + const inner_node = fs.resolve("/SUB/INNER.DAT").?; + try std.testing.expectEqual(@as(u32, 300), inner_node.size); + var readback: [test_inner_bytes]u8 = undefined; + _ = fs.readFile(inner_node, 0, &readback); + for (readback) |b| try std.testing.expectEqual(@as(u8, 0xA5), b); + // The subdirectory now lists exactly its one file. + const listing = fs.listEntry(fs.resolve("/SUB").?, 0).?; + try std.testing.expectEqualStrings("INNER.DAT", listing.name_buffer[0..listing.name_len]); +} + +test "rename keeps the file's contents under the new name" { + const allocator = std.testing.allocator; + const bytes = try allocator.alloc(u8, 128 * sector_size); + defer allocator.free(bytes); + var disk = RamDisk{ .bytes = bytes }; + formatExfat(bytes); + var fs = FileSystem.mount(disk.device()).?; + fs.current_time_epoch = 1_700_000_000; + + var node = fs.createFile(fs.rootNode(), "OLD.TXT").?; + var payload = [_]u8{0x5A} ** 800; + _ = fs.writeFile(&node, 0, &payload); + try std.testing.expect(fs.rename(fs.rootNode(), "OLD.TXT", "NEW.TXT")); + try std.testing.expect(fs.resolve("/OLD.TXT") == null); + const renamed = fs.resolve("/NEW.TXT").?; + try std.testing.expectEqual(@as(u32, 800), renamed.size); + var readback: [test_rename_bytes]u8 = undefined; + _ = fs.readFile(renamed, 0, &readback); + try std.testing.expectEqualSlices(u8, &payload, &readback); +}