exfat: the engine write path (S4 step 3)

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.
This commit is contained in:
Daniel Samson
2026-08-10 03:11:05 +01:00
parent 62eb2a748a
commit e240341bfb
+548
View File
@@ -489,6 +489,427 @@ pub const FileSystem = struct {
} }
return got; 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 { fn clampU32(value: u64) u32 {
@@ -535,6 +956,10 @@ const test_clusters = 64;
const test_fat_sector = 8; const test_fat_sector = 8;
const test_heap_sector = 9; const test_heap_sector = 9;
const test_read_bytes = 1024; // the two-cluster test files are 1024 bytes 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 { fn testCluster(cluster: u32) usize {
return (test_heap_sector + (cluster - 2)) * sector_size; 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); 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); 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);
}