//! The FAT filesystem engine: mount a block device, walk the FAT and directory //! structures, and read / write / create / truncate files, remove files, and make //! directories. FAT12/16/32 (the type is detected from the cluster count). The //! crash-safe write order is data -> FAT -> directory; truncate and remove free the //! cluster chain, then update the directory. Pure logic over a `BlockDevice` interface — //! no IPC — so it is host-testable against a RAM-backed image (see the tests at //! the bottom). The fat.zig server wraps a real `.block` device in a BlockDevice //! and serves this over the VFS protocol. //! //! Everything works in 512-byte sectors; a cluster is N sectors. Names are //! matched case-insensitively against both the 8.3 short name and, when present, //! the reconstructed long name. Writes update the directory entry, every FAT //! copy, and (FAT32) the FSInfo hint, in the crash-safe order data -> FAT -> //! directory. Long-name *creation* is not implemented — new files get an 8.3 //! name (the common case; the plan flags LFN-write as optional). const std = @import("std"); const on_disk = @import("on-disk.zig"); /// The most sectors one multi-sector transfer moves. Runs of contiguous full /// sectors within a cluster are coalesced into a single device command up to this /// cap; it bounds the DMA bounce buffer the fat server must provide (see /// `IpcBlock` in fat.zig — its bounce is `max_transfer_sectors * 512` bytes). pub const max_transfer_sectors = 8; /// A block device the engine reads and writes in fixed-size blocks. The two /// function pointers move a *run* of `count` contiguous sectors in one call, so a /// bulk file read/write costs one device round-trip per run rather than one per /// sector; the same engine runs over a real `.block` driver (which turns a run /// into one multi-sector SCSI command) or a RAM buffer (the tests). Metadata /// accesses (FAT sectors, directory sectors) use the single-block convenience /// wrappers below. pub const BlockDevice = struct { context: *anyopaque, block_size: u32, block_count: u64, readBlocksFn: *const fn (context: *anyopaque, lba: u64, count: u32, buffer: []u8) bool, writeBlocksFn: *const fn (context: *anyopaque, lba: u64, count: u32, buffer: []const u8) bool, pub fn readBlocks(self: BlockDevice, lba: u64, count: u32, buffer: []u8) bool { return self.readBlocksFn(self.context, lba, count, buffer); } pub fn writeBlocks(self: BlockDevice, lba: u64, count: u32, buffer: []const u8) bool { return self.writeBlocksFn(self.context, lba, count, buffer); } pub fn readBlock(self: BlockDevice, lba: u64, buffer: []u8) bool { return self.readBlocksFn(self.context, lba, 1, buffer); } pub fn writeBlock(self: BlockDevice, lba: u64, buffer: []const u8) bool { return self.writeBlocksFn(self.context, lba, 1, buffer); } }; /// A resolved filesystem object: a file or directory, and where its 8.3 entry /// lives so writes can update its size and first cluster. pub const Node = struct { first_cluster: u32, size: u32, is_directory: bool, // Modification time (Unix epoch seconds, UTC), decoded from the directory // entry's DOS write date/time. 0 if unset. mtime: u64 = 0, // The absolute sector and byte offset of this node's 8.3 directory entry, so // size/first-cluster changes can be written back. Absent for the root. entry_sector: u64 = 0, entry_offset: u32 = 0, has_entry: bool = false, }; const sector_size = 512; const entries_per_sector = sector_size / @sizeOf(on_disk.DirectoryEntry); // 16 // A small write-through cache of single-sector (metadata) accesses: FAT sectors, // directory sectors, and directory-entry writebacks. Its payoff is repeated scans // — resolving many paths under the same directory (a logging burst opening dozens // of files under /system/logs//) re-reads the same directory and FAT sectors, // which now come from RAM instead of a USB round trip each. Bulk file data (the // multi-sector run path) bypasses the cache — it is large and not re-read — and a // run write invalidates any overlapping cached sector to stay coherent. const block_cache_lines = 16; const CacheLine = struct { lba: u64 = 0, valid: bool = false, data: [sector_size]u8 = undefined, }; pub const FileSystem = struct { device: BlockDevice, geometry: on_disk.Geometry, // The absolute LBA the filesystem starts at: 0 for a bare FAT ("superfloppy"), // or the first partition's start LBA when the disk carries an MBR. Every // filesystem-relative sector read/write adds this. base_lba: u64 = 0, // Distinct scratch sectors so nested reads (a FAT lookup during a directory // scan) never alias each other. sector: [sector_size]u8 = undefined, fat_sector: [sector_size]u8 = undefined, dir_sector: [sector_size]u8 = undefined, // Wall-clock time (Unix epoch seconds) to stamp on create/write, set by the // server before a mutating op. 0 leaves the on-disk timestamps untouched (host // tests that don't care about time, and reads). current_time_epoch: u64 = 0, // Where the next allocateCluster scan starts — clusters below this were seen // in use, so a fresh scan needn't re-read them (frees rewind it). Without // this the scan re-read the FAT from cluster 2 per allocation: measured at // ~1 s/cluster on a part-full volume (a 37 s shutdown log flush). next_free_hint: u32 = 2, // Which absolute LBA `fat_sector` currently holds (0 = none). Lets a FAT // scan serve consecutive entries from one device read; every write through // the sector keeps the cache coherent (writeFatBytes updates it in place). fat_sector_lba: u64 = 0, // Write-through single-sector cache (see CacheLine). Keyed by filesystem- // relative LBA; round-robin eviction. cache: [block_cache_lines]CacheLine = [_]CacheLine{.{}} ** block_cache_lines, cache_cursor: u32 = 0, // --- single-sector cache ------------------------------------------------ fn cacheFind(self: *FileSystem, lba: u64) ?*CacheLine { for (&self.cache) |*line| { if (line.valid and line.lba == lba) return line; } return null; } // Install `data` (one sector) for `lba`: refresh an existing line or evict the // next round-robin slot. Called on every device read (populate) and write // (write-through), so a hit always mirrors the device. fn cacheInstall(self: *FileSystem, lba: u64, data: []const u8) void { const line = self.cacheFind(lba) orelse blk: { const slot = &self.cache[self.cache_cursor]; self.cache_cursor = (self.cache_cursor + 1) % block_cache_lines; slot.valid = true; slot.lba = lba; break :blk slot; }; @memcpy(&line.data, data[0..sector_size]); } fn cacheInvalidateRange(self: *FileSystem, lba: u64, count: u32) void { for (&self.cache) |*line| { if (line.valid and line.lba >= lba and line.lba < lba + count) line.valid = false; } } // Every filesystem-relative sector access adds the partition base. Single-sector // reads/writes go through the cache; the write path is write-through. fn blockRead(self: *FileSystem, lba: u64, buffer: []u8) bool { if (self.cacheFind(lba)) |line| { @memcpy(buffer[0..sector_size], &line.data); return true; } if (!self.device.readBlock(self.base_lba + lba, buffer)) return false; self.cacheInstall(lba, buffer); return true; } fn blockWrite(self: *FileSystem, lba: u64, buffer: []const u8) bool { if (!self.device.writeBlock(self.base_lba + lba, buffer)) return false; self.cacheInstall(lba, buffer); return true; } // Write one sector without caching it: for bulk cluster-zeroing, whose sectors // are write-once and would only evict useful metadata. Still invalidates any // stale cached copy so a later read sees the zeros. fn blockWriteUncached(self: *FileSystem, lba: u64, buffer: []const u8) bool { if (!self.device.writeBlock(self.base_lba + lba, buffer)) return false; self.cacheInvalidateRange(lba, 1); return true; } // Move `count` contiguous full sectors in one device command. `buffer` must be // exactly count*sector_size and sector-aligned in meaning (whole sectors only); // callers use these for the aligned middle of a file transfer, falling back to // the single-sector read-modify-write path for partial head/tail sectors. Bulk // data bypasses the cache; a run write invalidates any overlapping cached // sector so metadata that happens to share the range never goes stale. fn blockReadRun(self: *FileSystem, lba: u64, count: u32, buffer: []u8) bool { return self.device.readBlocks(self.base_lba + lba, count, buffer); } fn blockWriteRun(self: *FileSystem, lba: u64, count: u32, buffer: []const u8) bool { if (!self.device.writeBlocks(self.base_lba + lba, count, buffer)) return false; self.cacheInvalidateRange(lba, count); return true; } /// Mount the filesystem on `device`: either a bare FAT with its boot sector at /// LBA 0, or (as QEMU's VVFAT and most real USB sticks present it) an MBR- /// partitioned disk whose first FAT partition holds the boot sector. Returns /// null if neither is found. pub fn mount(device: BlockDevice) ?FileSystem { var boot: [sector_size]u8 = undefined; if (!device.readBlock(0, &boot)) return null; // A bare FAT: a valid boot sector right at LBA 0. if (on_disk.geometryOf(&boot)) |geometry| { if (geometry.bytes_per_sector == sector_size) return .{ .device = device, .geometry = geometry, .base_lba = 0 }; } // Otherwise an MBR: the 0x55AA signature but no BPB. Walk its four // partition entries (16 bytes each at offset 446) for the first non-empty // one, and mount the FAT boot sector at that partition's start LBA. if (boot[510] == 0x55 and boot[511] == 0xAA) { var partition: usize = 0; while (partition < 4) : (partition += 1) { const entry = boot[446 + partition * 16 ..][0..16]; const partition_type = entry[4]; const start_lba = std.mem.readInt(u32, entry[8..12], .little); if (partition_type == 0 or start_lba == 0) continue; var partition_boot: [sector_size]u8 = undefined; if (!device.readBlock(start_lba, &partition_boot)) continue; if (on_disk.geometryOf(&partition_boot)) |geometry| { if (geometry.bytes_per_sector == sector_size) return .{ .device = device, .geometry = geometry, .base_lba = start_lba }; } } } return null; } // --- cluster <-> sector ------------------------------------------------- fn clusterSector(self: *const FileSystem, cluster: u32, sector_in_cluster: u32) u64 { return @as(u64, self.geometry.first_data_sector) + @as(u64, cluster - 2) * self.geometry.sectors_per_cluster + sector_in_cluster; } fn fatByteBase(self: *const FileSystem) u64 { return @as(u64, self.geometry.reserved_sector_count) * sector_size; } fn rootDirStartSector(self: *const FileSystem) u64 { return @as(u64, self.geometry.reserved_sector_count) + @as(u64, self.geometry.fat_count) * self.geometry.fat_size_sectors; } fn rootDirSectors(self: *const FileSystem) u32 { return (self.geometry.root_entry_count * 32 + sector_size - 1) / sector_size; } // --- FAT access --------------------------------------------------------- // Read `out.len` bytes from FAT #0 starting at `byte_offset`, spanning sectors. fn readFatBytes(self: *FileSystem, byte_offset: u64, out: []u8) bool { var done: usize = 0; var position = self.fatByteBase() + byte_offset; while (done < out.len) { const lba = position / sector_size; const within: usize = @intCast(position % sector_size); if (lba != self.fat_sector_lba) { if (!self.blockRead(lba, &self.fat_sector)) return false; self.fat_sector_lba = lba; } const n = @min(out.len - done, sector_size - within); @memcpy(out[done .. done + n], self.fat_sector[within .. within + n]); done += n; position += n; } return true; } // Write `in.len` bytes at `byte_offset` into every FAT copy (read-modify-write // per sector). fn writeFatBytes(self: *FileSystem, byte_offset: u64, in: []const u8) bool { var fat: u32 = 0; while (fat < self.geometry.fat_count) : (fat += 1) { const base = self.fatByteBase() + @as(u64, fat) * @as(u64, self.geometry.fat_size_sectors) * sector_size; var done: usize = 0; var position = base + byte_offset; while (done < in.len) { const lba = position / sector_size; const within: usize = @intCast(position % sector_size); if (lba != self.fat_sector_lba) { if (!self.blockRead(lba, &self.fat_sector)) return false; self.fat_sector_lba = lba; } const n = @min(in.len - done, sector_size - within); @memcpy(self.fat_sector[within .. within + n], in[done .. done + n]); if (!self.blockWrite(lba, &self.fat_sector)) return false; done += n; position += n; } } return true; } fn readFatEntry(self: *FileSystem, cluster: u32) u32 { switch (self.geometry.fat_type) { .fat12 => { var pair: [2]u8 = undefined; const offset = cluster + cluster / 2; // cluster * 1.5 if (!self.readFatBytes(offset, &pair)) return on_disk.end_of_chain_12; const word = @as(u16, pair[0]) | (@as(u16, pair[1]) << 8); return if (cluster & 1 == 1) (word >> 4) else (word & 0x0FFF); }, .fat16 => { var value: [2]u8 = undefined; if (!self.readFatBytes(@as(u64, cluster) * 2, &value)) return on_disk.end_of_chain_16; return @as(u16, value[0]) | (@as(u16, value[1]) << 8); }, .fat32 => { var value: [4]u8 = undefined; if (!self.readFatBytes(@as(u64, cluster) * 4, &value)) return on_disk.end_of_chain_32; return (@as(u32, value[0]) | (@as(u32, value[1]) << 8) | (@as(u32, value[2]) << 16) | (@as(u32, value[3]) << 24)) & 0x0FFFFFFF; }, } } fn writeFatEntry(self: *FileSystem, cluster: u32, value: u32) bool { switch (self.geometry.fat_type) { .fat12 => { const offset = cluster + cluster / 2; var pair: [2]u8 = undefined; if (!self.readFatBytes(offset, &pair)) return false; var word = @as(u16, pair[0]) | (@as(u16, pair[1]) << 8); if (cluster & 1 == 1) { word = (word & 0x000F) | (@as(u16, @truncate(value)) << 4); } else { word = (word & 0xF000) | (@as(u16, @truncate(value)) & 0x0FFF); } pair[0] = @truncate(word); pair[1] = @truncate(word >> 8); return self.writeFatBytes(offset, &pair); }, .fat16 => { const bytes = [2]u8{ @truncate(value), @truncate(value >> 8) }; return self.writeFatBytes(@as(u64, cluster) * 2, &bytes); }, .fat32 => { const bytes = [4]u8{ @truncate(value), @truncate(value >> 8), @truncate(value >> 16), @truncate(value >> 24) }; return self.writeFatBytes(@as(u64, cluster) * 4, &bytes); }, } } fn isEndOfChain(self: *const FileSystem, value: u32) bool { return switch (self.geometry.fat_type) { .fat12 => value >= on_disk.end_of_chain_12, .fat16 => value >= on_disk.end_of_chain_16, .fat32 => value >= on_disk.end_of_chain_32, }; } fn endOfChainValue(self: *const FileSystem) u32 { return switch (self.geometry.fat_type) { .fat12 => 0xFFF, .fat16 => 0xFFFF, .fat32 => 0x0FFFFFFF, }; } // Find and claim a free cluster, marking it end-of-chain. Returns its number. fn allocateCluster(self: *FileSystem) ?u32 { const limit = self.geometry.cluster_count + 2; // Two passes: hint..end, then 2..hint (the hint only skips known-used // ground, it never hides a freed cluster — freeChain rewinds it). var pass: u2 = 0; while (pass < 2) : (pass += 1) { var cluster: u32 = if (pass == 0) self.next_free_hint else 2; const end: u32 = if (pass == 0) limit else self.next_free_hint; while (cluster < end) : (cluster += 1) { if (self.readFatEntry(cluster) == on_disk.free_cluster) { if (!self.writeFatEntry(cluster, self.endOfChainValue())) return null; self.next_free_hint = cluster + 1; return cluster; } } } return null; } // Free every cluster of the chain starting at `first`, returning them to the // pool. A first < 2 (an empty file) frees nothing. Bounded against a corrupt // cyclic chain by the cluster count so it can never loop forever. fn freeChain(self: *FileSystem, first: u32) void { var cluster = first; var guard: u32 = 0; const limit = self.geometry.cluster_count + 2; while (cluster >= 2 and cluster < limit and guard < limit) : (guard += 1) { const next = self.readFatEntry(cluster); _ = self.writeFatEntry(cluster, on_disk.free_cluster); if (cluster < self.next_free_hint) self.next_free_hint = cluster; if (self.isEndOfChain(next) or next < 2) break; cluster = next; } } // --- directory iteration ------------------------------------------------ // The absolute LBA of the `sector_index`th sector of directory `dir`, or null // past its end. If `grow` is set and a cluster chain runs out, a new cluster // is allocated and linked (used when appending a directory entry). fn dirSectorLba(self: *FileSystem, dir: Node, sector_index: u32, grow: bool) ?u64 { const is_fixed_root = dir.first_cluster == 0 and self.geometry.fat_type != .fat32; if (is_fixed_root) { if (sector_index >= self.rootDirSectors()) return null; return self.rootDirStartSector() + sector_index; } const spc = self.geometry.sectors_per_cluster; var cluster = if (dir.first_cluster == 0) self.geometry.root_cluster else dir.first_cluster; var remaining = sector_index; while (remaining >= spc) : (remaining -= spc) { var next = self.readFatEntry(cluster); if (self.isEndOfChain(next) or next < 2) { if (!grow) return null; const fresh = self.allocateCluster() orelse return null; self.zeroCluster(fresh); if (!self.writeFatEntry(cluster, fresh)) return null; next = fresh; } cluster = next; } return self.clusterSector(cluster, remaining); } fn zeroCluster(self: *FileSystem, cluster: u32) void { var zero = [_]u8{0} ** sector_size; var s: u32 = 0; while (s < self.geometry.sectors_per_cluster) : (s += 1) { // Uncached: a freshly-zeroed cluster is write-once bulk; caching its // sectors would only evict live metadata. _ = self.blockWriteUncached(self.clusterSector(cluster, s), &zero); } } pub fn rootNode(self: *const FileSystem) Node { return .{ .first_cluster = if (self.geometry.fat_type == .fat32) self.geometry.root_cluster else 0, .size = 0, .is_directory = true, .has_entry = false, }; } // --- name handling ------------------------------------------------------ // Format a raw 8.3 name ("NAME EXT") into the displayed "NAME.EXT". fn format83(raw: [11]u8, out: []u8) []const u8 { var length: usize = 0; var base_len: usize = 8; while (base_len > 0 and raw[base_len - 1] == ' ') base_len -= 1; for (raw[0..base_len]) |c| { if (length < out.len) { out[length] = c; length += 1; } } var ext_len: usize = 3; while (ext_len > 0 and raw[8 + ext_len - 1] == ' ') ext_len -= 1; if (ext_len > 0) { if (length < out.len) { out[length] = '.'; length += 1; } for (raw[8 .. 8 + ext_len]) |c| { if (length < out.len) { out[length] = c; length += 1; } } } return out[0..length]; } // Convert a name to a raw 8.3 field (uppercased, space-padded), or null if it // cannot be represented (too long a base or extension). fn to83(name: []const u8) ?[11]u8 { var raw = [_]u8{' '} ** 11; const dot = std.mem.lastIndexOfScalar(u8, name, '.'); const base = if (dot) |d| name[0..d] else name; const ext = if (dot) |d| name[d + 1 ..] else name[0..0]; if (base.len == 0 or base.len > 8 or ext.len > 3) return null; for (base, 0..) |c, i| raw[i] = std.ascii.toUpper(c); for (ext, 0..) |c, i| raw[8 + i] = std.ascii.toUpper(c); return raw; } fn nameMatches(display: []const u8, query: []const u8) bool { if (display.len != query.len) return false; for (display, query) |a, b| { if (std.ascii.toUpper(a) != std.ascii.toUpper(b)) return false; } return true; } // Pull the 13 UTF-16 code units of one long-name entry into `out` (ASCII only, // non-ASCII becomes '?'). Returns how many characters (stopping at 0x0000). fn longNameChars(entry: on_disk.LongNameEntry, out: *[13]u8) usize { const units = [13]u16{ entry.name1[0], entry.name1[1], entry.name1[2], entry.name1[3], entry.name1[4], entry.name2[0], entry.name2[1], entry.name2[2], entry.name2[3], entry.name2[4], entry.name2[5], entry.name3[0], entry.name3[1], }; var count: usize = 0; for (units) |unit| { if (unit == 0x0000 or unit == 0xFFFF) break; out[count] = if (unit < 0x80) @truncate(unit) else '?'; count += 1; } return count; } // --- directory search + listing ---------------------------------------- /// Iterate the entries of a directory, calling `visit` with each real (non-LFN, /// non-free) entry, its reconstructed display name, and where it lives. Stops /// when `visit` returns true or the directory ends. fn scanDirectory( self: *FileSystem, dir: Node, context: anytype, comptime visit: fn (@TypeOf(context), entry: on_disk.DirectoryEntry, name: []const u8, entry_sector: u64, entry_offset: u32) bool, ) void { var long_name: [260]u8 = undefined; var long_len: usize = 0; var sector_index: u32 = 0; while (self.dirSectorLba(dir, sector_index, false)) |lba| : (sector_index += 1) { if (!self.blockRead(lba, &self.dir_sector)) return; var i: u32 = 0; while (i < entries_per_sector) : (i += 1) { const offset = i * @sizeOf(on_disk.DirectoryEntry); const entry = std.mem.bytesToValue(on_disk.DirectoryEntry, self.dir_sector[offset .. offset + @sizeOf(on_disk.DirectoryEntry)]); if (entry.isEnd()) return; if (entry.name[0] == 0xE5) { long_len = 0; continue; } if (entry.isLongName()) { const lfn = std.mem.bytesToValue(on_disk.LongNameEntry, self.dir_sector[offset .. offset + @sizeOf(on_disk.LongNameEntry)]); const order = lfn.order & 0x1F; if (order >= 1 and order <= 20) { var chunk: [13]u8 = undefined; const n = longNameChars(lfn, &chunk); const start = (order - 1) * 13; if (start + n <= long_name.len) { @memcpy(long_name[start .. start + n], chunk[0..n]); if (lfn.order & 0x40 != 0) long_len = start + n; // last (first physical) piece sets the length } } continue; } if (entry.isVolumeLabel()) { long_len = 0; continue; } var short: [12]u8 = undefined; const display = if (long_len > 0) long_name[0..long_len] else format83(entry.name, &short); if (visit(context, entry, display, lba, offset)) return; long_len = 0; } } } const FindResult = struct { found: bool = false, node: Node = undefined }; const FindContext = struct { query: []const u8, result: *FindResult }; fn findVisit(context: *const FindContext, entry: on_disk.DirectoryEntry, name: []const u8, entry_sector: u64, entry_offset: u32) bool { if (!nameMatches(name, context.query)) return false; context.result.* = .{ .found = true, .node = .{ .first_cluster = entry.firstCluster(), .size = entry.file_size, .is_directory = entry.isDirectory(), .mtime = on_disk.fatToEpoch(entry.write_date, entry.write_time), .entry_sector = entry_sector, .entry_offset = entry_offset, .has_entry = true, } }; return true; } fn findChild(self: *FileSystem, dir: Node, name: []const u8) ?Node { var result = FindResult{}; var context = FindContext{ .query = name, .result = &result }; self.scanDirectory(dir, &context, findVisit); return if (result.found) result.node else null; } /// Resolve an absolute or "/"-relative path to a node. "/" is the root. pub fn resolve(self: *FileSystem, path: []const u8) ?Node { var node = self.rootNode(); var it = std.mem.tokenizeScalar(u8, path, '/'); while (it.next()) |component| { if (component.len == 0) continue; if (!node.is_directory) return null; node = self.findChild(node, component) orelse return null; } return node; } /// The `cursor`th real entry of a directory (for readdir): its display name, /// kind, and size. Returns null past the end. pub const Listing = struct { name_buffer: [260]u8 = undefined, name_len: usize = 0, is_directory: bool = false, size: u32 = 0, mtime: u64 = 0 }; const ListContext = struct { target: u32, index: u32 = 0, out: *Listing, done: bool = false }; fn listVisit(context: *ListContext, entry: on_disk.DirectoryEntry, name: []const u8, entry_sector: u64, entry_offset: u32) bool { _ = entry_sector; _ = entry_offset; if (context.index == context.target) { const n = @min(name.len, context.out.name_buffer.len); @memcpy(context.out.name_buffer[0..n], name[0..n]); context.out.name_len = n; context.out.is_directory = entry.isDirectory(); context.out.size = entry.file_size; context.out.mtime = on_disk.fatToEpoch(entry.write_date, entry.write_time); context.done = true; return true; } context.index += 1; return false; } pub fn listEntry(self: *FileSystem, dir: Node, cursor: u32) ?Listing { var listing = Listing{}; var context = ListContext{ .target = cursor, .out = &listing }; self.scanDirectory(dir, &context, listVisit); return if (context.done) listing else null; } // --- file read / write -------------------------------------------------- // The cluster holding byte `offset` of a chain starting at `first`, walking // (and optionally growing) the chain. Returns null at end without grow. fn clusterAt(self: *FileSystem, first: u32, offset: u32, grow: bool) ?u32 { const cluster_bytes = self.geometry.sectors_per_cluster * sector_size; var cluster = first; var steps = offset / cluster_bytes; while (steps > 0) : (steps -= 1) { var next = self.readFatEntry(cluster); if (self.isEndOfChain(next) or next < 2) { if (!grow) return null; const fresh = self.allocateCluster() orelse return null; if (!self.writeFatEntry(cluster, fresh)) return null; next = fresh; } cluster = next; } return cluster; } /// Read up to `buffer.len` bytes of a file node starting at `offset`. Returns /// the number read (0 at or past EOF). pub fn readFile(self: *FileSystem, node: Node, offset: u32, buffer: []u8) usize { if (offset >= node.size or node.first_cluster < 2) return 0; const available = node.size - offset; const want = @min(buffer.len, available); const cluster_bytes = self.geometry.sectors_per_cluster * sector_size; const spc = self.geometry.sectors_per_cluster; var produced: usize = 0; var position = offset; while (produced < want) { const cluster = self.clusterAt(node.first_cluster, position, false) orelse break; const in_cluster = position % cluster_bytes; const sector_in_cluster = in_cluster / sector_size; const in_sector = in_cluster % sector_size; const lba = self.clusterSector(cluster, sector_in_cluster); // Aligned middle: read a run of whole sectors straight into the caller's // buffer in one device command (no per-sector round-trip, no staging // copy). Bounded by the sectors left in this cluster (the next cluster // may not be physically contiguous), the transfer cap, and the request. if (in_sector == 0 and want - produced >= sector_size) { const full = @min((want - produced) / sector_size, spc - sector_in_cluster); const run: u32 = @intCast(@min(full, max_transfer_sectors)); if (!self.blockReadRun(lba, run, buffer[produced .. produced + run * sector_size])) break; produced += run * sector_size; position += run * sector_size; continue; } // Partial head or tail sector: read one sector, copy the slice needed. if (!self.blockRead(lba, &self.sector)) break; const n = @min(want - produced, sector_size - in_sector); @memcpy(buffer[produced .. produced + n], self.sector[in_sector .. in_sector + n]); produced += n; position += @intCast(n); } return produced; } /// Write `data` to a file node at `offset`, growing it (allocating clusters and /// updating the directory entry) as needed. Returns the number written. pub fn writeFile(self: *FileSystem, node: *Node, offset: u32, data: []const u8) usize { if (data.len == 0) return 0; const cluster_bytes = self.geometry.sectors_per_cluster * sector_size; // Ensure the file has a first cluster. if (node.first_cluster < 2) { const fresh = self.allocateCluster() orelse return 0; self.zeroCluster(fresh); node.first_cluster = fresh; } const spc = self.geometry.sectors_per_cluster; var consumed: usize = 0; var position = offset; while (consumed < data.len) { const cluster = self.clusterAt(node.first_cluster, position, true) orelse break; const in_cluster = position % cluster_bytes; const sector_in_cluster = in_cluster / sector_size; const in_sector = in_cluster % sector_size; const lba = self.clusterSector(cluster, sector_in_cluster); // Aligned middle: write a run of whole sectors straight from the caller's // data in one device command. These sectors are fully overwritten, so no // read-modify-write is needed — a double saving over the per-sector RMW // path. Bounded by the sectors left in this (already-resolved) cluster, // the transfer cap, and the data remaining. if (in_sector == 0 and data.len - consumed >= sector_size) { const full = @min((data.len - consumed) / sector_size, spc - sector_in_cluster); const run: u32 = @intCast(@min(full, max_transfer_sectors)); if (!self.blockWriteRun(lba, run, data[consumed .. consumed + run * sector_size])) break; consumed += run * sector_size; position += run * sector_size; continue; } // Partial head or tail sector: read-modify-write one sector. if (!self.blockRead(lba, &self.sector)) break; const n = @min(data.len - consumed, sector_size - in_sector); @memcpy(self.sector[in_sector .. in_sector + n], data[consumed .. consumed + n]); if (!self.blockWrite(lba, &self.sector)) break; consumed += n; position += @intCast(n); } const new_end = offset + @as(u32, @intCast(consumed)); if (new_end > node.size) node.size = new_end; self.updateEntry(node.*); return consumed; } /// Truncate a file node to zero length: free its cluster chain and clear its /// size and first cluster in the directory entry. This is O_TRUNC — the fix for /// re-opening and overwriting an existing file, whose old (longer) contents /// would otherwise linger past the new end (a silent-corruption bug for anything /// that rewrites a file in place, like the boot-log flush). pub fn truncate(self: *FileSystem, node: *Node) void { self.freeChain(node.first_cluster); node.first_cluster = 0; node.size = 0; self.updateEntry(node.*); } // Write a node's size and first cluster back into its 8.3 directory entry. fn updateEntry(self: *FileSystem, node: Node) void { if (!node.has_entry) return; if (!self.blockRead(node.entry_sector, &self.dir_sector)) return; var entry = std.mem.bytesToValue(on_disk.DirectoryEntry, self.dir_sector[node.entry_offset .. node.entry_offset + @sizeOf(on_disk.DirectoryEntry)]); entry.file_size = node.size; entry.setFirstCluster(node.first_cluster); // A write updates the modification time (leave it if no time is set, so host // tests and reads don't zero it). if (self.current_time_epoch != 0) { const stamp = on_disk.epochToFatDateTime(self.current_time_epoch); entry.write_date = stamp.date; entry.write_time = stamp.time; entry.last_access_date = stamp.date; } @memcpy(self.dir_sector[node.entry_offset .. node.entry_offset + @sizeOf(on_disk.DirectoryEntry)], std.mem.asBytes(&entry)); _ = self.blockWrite(node.entry_sector, &self.dir_sector); } // --- long-name creation -------------------------------------------------- // The standard 8.3 short-name checksum carried by every long-name entry. fn shortChecksum(raw: [11]u8) u8 { var sum: u8 = 0; for (raw) |c| sum = ((sum & 1) << 7) +% (sum >> 1) +% c; return sum; } fn valid83Char(c: u8) bool { return (c >= 'A' and c <= 'Z') or (c >= '0' and c <= '9') or c == '-' or c == '_'; } // Whether an 8.3 entry with exactly this raw name exists in `dir`. const RawContext = struct { raw: [11]u8, found: *bool }; fn rawVisit(context: *const RawContext, entry: on_disk.DirectoryEntry, name: []const u8, entry_sector: u64, entry_offset: u32) bool { _ = name; _ = entry_sector; _ = entry_offset; if (std.mem.eql(u8, &entry.name, &context.raw)) { context.found.* = true; return true; } return false; } fn shortNameExists(self: *FileSystem, dir: Node, raw: [11]u8) bool { var found = false; var context = RawContext{ .raw = raw, .found = &found }; self.scanDirectory(dir, &context, rawVisit); return found; } // A mangled STEM~N.EXT short name that collides with nothing in `dir` — the // alias behind a long-name chain. fn shortNameFor(self: *FileSystem, dir: Node, name: []const u8) ?[11]u8 { const dot = std.mem.lastIndexOfScalar(u8, name, '.'); const base = if (dot) |d| name[0..d] else name; const ext = if (dot) |d| name[d + 1 ..] else name[0..0]; var stem: [6]u8 = undefined; var stem_len: usize = 0; for (base) |c| { if (stem_len == stem.len) break; const upper = std.ascii.toUpper(c); if (valid83Char(upper)) { stem[stem_len] = upper; stem_len += 1; } } if (stem_len == 0) { stem[0] = 'X'; stem_len = 1; } var raw = [_]u8{' '} ** 11; var ext_len: usize = 0; for (ext) |c| { if (ext_len == 3) break; const upper = std.ascii.toUpper(c); if (valid83Char(upper)) { raw[8 + ext_len] = upper; ext_len += 1; } } var index: u32 = 1; while (index <= 999_999) : (index += 1) { var tail_buffer: [8]u8 = undefined; const tail = std.fmt.bufPrint(&tail_buffer, "~{d}", .{index}) catch return null; const keep = @min(stem_len, 8 - tail.len); @memset(raw[0..8], ' '); @memcpy(raw[0..keep], stem[0..keep]); @memcpy(raw[keep .. keep + tail.len], tail); if (!self.shortNameExists(dir, raw)) return raw; } return null; } // Fill one long-name entry's 13 UTF-16 slots from `name` starting at // `offset`: the name's bytes widened, then a 0x0000 terminator, then 0xFFFF. fn fillLongNamePiece(lfn: *on_disk.LongNameEntry, name: []const u8, offset: usize) void { var units: [13]u16 = undefined; var i: usize = 0; while (i < 13) : (i += 1) { const at = offset + i; units[i] = if (at < name.len) name[at] else if (at == name.len) 0x0000 else 0xFFFF; } lfn.name1 = units[0..5].*; lfn.name2 = units[5..11].*; lfn.name3 = units[11..13].*; } // The first entry index of a run of `count` free slots in `dir`, growing the // directory as needed. Fresh clusters are zeroed, so growth always yields // free slots; only the fixed FAT12/16 root can genuinely run out. fn findFreeRun(self: *FileSystem, dir: Node, count: usize) ?u32 { var run_start: u32 = 0; var run_len: usize = 0; var sector_index: u32 = 0; while (self.dirSectorLba(dir, sector_index, true)) |lba| : (sector_index += 1) { if (!self.blockRead(lba, &self.dir_sector)) return null; var i: u32 = 0; while (i < entries_per_sector) : (i += 1) { const offset = i * @sizeOf(on_disk.DirectoryEntry); const entry = std.mem.bytesToValue(on_disk.DirectoryEntry, self.dir_sector[offset .. offset + @sizeOf(on_disk.DirectoryEntry)]); if (entry.isFree()) { if (run_len == 0) run_start = sector_index * entries_per_sector + i; run_len += 1; if (run_len == count) return run_start; } else { run_len = 0; } } if (sector_index > 4096) return null; // runaway guard } return null; } // Write one 32-byte directory entry at a global entry index (read-modify- // write of its sector). Returns the entry's (sector, offset) or null. fn writeEntryAt(self: *FileSystem, dir: Node, index: u32, bytes: *const [32]u8) ?EntryLoc { const lba = self.dirSectorLba(dir, index / entries_per_sector, true) orelse return null; if (!self.blockRead(lba, &self.dir_sector)) return null; const offset = (index % entries_per_sector) * @sizeOf(on_disk.DirectoryEntry); @memcpy(self.dir_sector[offset .. offset + 32], bytes); if (!self.blockWrite(lba, &self.dir_sector)) return null; return .{ .sector = lba, .offset = offset }; } // Add a named directory entry, creating a long-name chain when the name is // not its own 8.3 form. Write order is LFN pieces first, 8.3 entry last: an // interrupted create leaves orphaned long-name entries, which every FAT // reader (this engine's scanner included) skips as unattached — never a // mismatched chain. fn addEntryNamed(self: *FileSystem, dir: Node, name: []const u8, attributes: u8, first_cluster: u32, size: u32) ?Node { if (to83(name)) |raw| { var display: [12]u8 = undefined; // Only a name that IS its 8.3 form (already uppercase) skips the // chain — a lowercase name gets one so its exact case survives, // matching tools/make-fat-image.py. if (std.mem.eql(u8, format83(raw, &display), name)) return self.addEntry(dir, raw, attributes, first_cluster, size); } if (name.len == 0 or name.len > 255) return null; const raw = self.shortNameFor(dir, name) orelse return null; const checksum = shortChecksum(raw); const piece_count: u32 = @intCast((name.len + 12) / 13); if (piece_count > 20) return null; const start = self.findFreeRun(dir, piece_count + 1) orelse return null; var k: u32 = 0; while (k < piece_count) : (k += 1) { const piece = piece_count - k; // stored last-logical-first var lfn = std.mem.zeroes(on_disk.LongNameEntry); lfn.order = @intCast(piece | (if (k == 0) @as(u8, 0x40) else 0)); lfn.attributes = on_disk.attribute_long_name; lfn.checksum = checksum; fillLongNamePiece(&lfn, name, (piece - 1) * 13); _ = self.writeEntryAt(dir, start + k, std.mem.asBytes(&lfn)[0..32]) orelse return null; } var entry = std.mem.zeroes(on_disk.DirectoryEntry); entry.name = raw; entry.attributes = attributes; entry.file_size = size; entry.setFirstCluster(first_cluster); const stamp = on_disk.epochToFatDateTime(self.current_time_epoch); entry.creation_date = stamp.date; entry.creation_time = stamp.time; entry.write_date = stamp.date; entry.write_time = stamp.time; entry.last_access_date = stamp.date; const location = self.writeEntryAt(dir, start + piece_count, std.mem.asBytes(&entry)[0..32]) orelse return null; return .{ .first_cluster = first_cluster, .size = size, .is_directory = attributes & on_disk.attribute_directory != 0, .mtime = self.current_time_epoch, .entry_sector = location.sector, .entry_offset = location.offset, .has_entry = true, }; } // Add an 8.3 directory entry to `dir` with the given attributes, first cluster, // and size, reusing a free (0x00 or 0xE5) slot and growing the directory chain // if needed. Returns the new node (with its entry location) or null if full. fn addEntry(self: *FileSystem, dir: Node, raw: [11]u8, attributes: u8, first_cluster: u32, size: u32) ?Node { var sector_index: u32 = 0; while (self.dirSectorLba(dir, sector_index, true)) |lba| : (sector_index += 1) { if (!self.blockRead(lba, &self.dir_sector)) return null; var i: u32 = 0; while (i < entries_per_sector) : (i += 1) { const offset = i * @sizeOf(on_disk.DirectoryEntry); const existing = std.mem.bytesToValue(on_disk.DirectoryEntry, self.dir_sector[offset .. offset + @sizeOf(on_disk.DirectoryEntry)]); if (existing.isFree()) { var entry = std.mem.zeroes(on_disk.DirectoryEntry); entry.name = raw; entry.attributes = attributes; entry.file_size = size; entry.setFirstCluster(first_cluster); const stamp = on_disk.epochToFatDateTime(self.current_time_epoch); entry.creation_date = stamp.date; entry.creation_time = stamp.time; entry.write_date = stamp.date; entry.write_time = stamp.time; entry.last_access_date = stamp.date; @memcpy(self.dir_sector[offset .. offset + @sizeOf(on_disk.DirectoryEntry)], std.mem.asBytes(&entry)); if (!self.blockWrite(lba, &self.dir_sector)) return null; return .{ .first_cluster = first_cluster, .size = size, .is_directory = attributes & on_disk.attribute_directory != 0, .mtime = self.current_time_epoch, .entry_sector = lba, .entry_offset = offset, .has_entry = true, }; } } // Only the fixed root can run out (it can't grow); a chain grows above. if (sector_index > 4096) return null; // runaway guard } return null; } /// Create a file in directory `dir`. Uppercase 8.3 names get a bare short /// entry; anything else gets a long-name chain over a mangled ~N alias. /// Returns the new (empty) node, or null (bad name / directory full / /// duplicate — the caller checks existence first if it must distinguish). pub fn createFile(self: *FileSystem, dir: Node, name: []const u8) ?Node { return self.addEntryNamed(dir, name, on_disk.attribute_archive, 0, 0); } /// Create a subdirectory in `dir`: allocate and initialise its first /// cluster with "." (itself) and ".." (the parent) entries, then add its /// directory entry to `dir` (long-name chain when the name needs one). /// Returns the new directory node, or null (bad name, no free cluster, or /// the directory is full). pub fn createDirectory(self: *FileSystem, dir: Node, name: []const u8) ?Node { const cluster = self.allocateCluster() orelse return null; self.zeroCluster(cluster); // ".." points at the parent: 0 for the fixed root on FAT12/16, the root // cluster on FAT32, else the parent's own first cluster. const parent_cluster: u32 = if (dir.first_cluster != 0) dir.first_cluster else if (self.geometry.fat_type == .fat32) self.geometry.root_cluster else 0; var dot = std.mem.zeroes(on_disk.DirectoryEntry); dot.name = [_]u8{'.'} ++ ([_]u8{' '} ** 10); dot.attributes = on_disk.attribute_directory; dot.setFirstCluster(cluster); var dotdot = std.mem.zeroes(on_disk.DirectoryEntry); dotdot.name = [_]u8{ '.', '.' } ++ ([_]u8{' '} ** 9); dotdot.attributes = on_disk.attribute_directory; dotdot.setFirstCluster(parent_cluster); var first_sector = [_]u8{0} ** sector_size; const entry_size = @sizeOf(on_disk.DirectoryEntry); @memcpy(first_sector[0..entry_size], std.mem.asBytes(&dot)); @memcpy(first_sector[entry_size .. 2 * entry_size], std.mem.asBytes(&dotdot)); if (!self.blockWrite(self.clusterSector(cluster, 0), &first_sector)) { self.freeChain(cluster); return null; } return self.addEntryNamed(dir, name, on_disk.attribute_directory, cluster, 0) orelse { self.freeChain(cluster); return null; }; } const EntryLoc = struct { sector: u64, offset: u32 }; // Mark a directory entry deleted in place (its name[0] set to 0xE5). fn markDeleted(self: *FileSystem, sector: u64, offset: u32) void { if (!self.blockRead(sector, &self.dir_sector)) return; self.dir_sector[offset] = 0xE5; _ = self.blockWrite(sector, &self.dir_sector); } /// Remove a file named `name` from directory `dir`: free its cluster chain and /// mark its 8.3 entry — and any long-name entries immediately preceding it — /// deleted, so the slots (and the long name) are reusable without a later entry /// that reuses them inheriting the orphaned long name. Refuses a directory (a /// separate rmdir would have to check emptiness). Returns true if removed. pub fn removeFile(self: *FileSystem, dir: Node, name: []const u8) bool { var run: [21]EntryLoc = undefined; // the long-name entries before the 8.3 one var run_len: usize = 0; var long_name: [260]u8 = undefined; var long_len: usize = 0; var sector_index: u32 = 0; while (self.dirSectorLba(dir, sector_index, false)) |lba| : (sector_index += 1) { if (!self.blockRead(lba, &self.dir_sector)) return false; var i: u32 = 0; while (i < entries_per_sector) : (i += 1) { const offset = i * @sizeOf(on_disk.DirectoryEntry); const entry = std.mem.bytesToValue(on_disk.DirectoryEntry, self.dir_sector[offset .. offset + @sizeOf(on_disk.DirectoryEntry)]); if (entry.isEnd()) return false; if (entry.name[0] == 0xE5) { run_len = 0; long_len = 0; continue; } if (entry.isLongName()) { if (run_len < run.len) { run[run_len] = .{ .sector = lba, .offset = offset }; run_len += 1; } const lfn = std.mem.bytesToValue(on_disk.LongNameEntry, self.dir_sector[offset .. offset + @sizeOf(on_disk.LongNameEntry)]); const order = lfn.order & 0x1F; if (order >= 1 and order <= 20) { var chunk: [13]u8 = undefined; const n = longNameChars(lfn, &chunk); const start = (order - 1) * 13; if (start + n <= long_name.len) { @memcpy(long_name[start .. start + n], chunk[0..n]); if (lfn.order & 0x40 != 0) long_len = start + n; } } continue; } if (entry.isVolumeLabel()) { run_len = 0; long_len = 0; continue; } // A real 8.3 entry. var short: [12]u8 = undefined; const display = if (long_len > 0) long_name[0..long_len] else format83(entry.name, &short); if (nameMatches(display, name)) { if (entry.isDirectory()) return false; // not for directories self.freeChain(entry.firstCluster()); self.markDeleted(lba, offset); var r: usize = 0; while (r < run_len) : (r += 1) self.markDeleted(run[r].sector, run[r].offset); return true; } run_len = 0; long_len = 0; } } return false; } /// Rename `old_name` to `new_name` within the SAME directory `dir`, rewriting /// the 8.3 entry's name in place. Refuses if `old_name` is missing, `new_name` /// is not 8.3-representable, or `new_name` already exists. Any long-name entries /// on the old file are dropped (the file takes its new 8.3 name); cross-directory /// and long-name-preserving rename are not implemented. Returns true on success. pub fn rename(self: *FileSystem, dir: Node, old_name: []const u8, new_name: []const u8) bool { const raw = to83(new_name) orelse return false; if (self.findChild(dir, new_name) != null) return false; // target already exists var run: [21]EntryLoc = undefined; // the long-name entries before the 8.3 one var run_len: usize = 0; var long_name: [260]u8 = undefined; var long_len: usize = 0; var sector_index: u32 = 0; while (self.dirSectorLba(dir, sector_index, false)) |lba| : (sector_index += 1) { if (!self.blockRead(lba, &self.dir_sector)) return false; var i: u32 = 0; while (i < entries_per_sector) : (i += 1) { const offset = i * @sizeOf(on_disk.DirectoryEntry); const entry = std.mem.bytesToValue(on_disk.DirectoryEntry, self.dir_sector[offset .. offset + @sizeOf(on_disk.DirectoryEntry)]); if (entry.isEnd()) return false; if (entry.name[0] == 0xE5) { run_len = 0; long_len = 0; continue; } if (entry.isLongName()) { if (run_len < run.len) { run[run_len] = .{ .sector = lba, .offset = offset }; run_len += 1; } const lfn = std.mem.bytesToValue(on_disk.LongNameEntry, self.dir_sector[offset .. offset + @sizeOf(on_disk.LongNameEntry)]); const order = lfn.order & 0x1F; if (order >= 1 and order <= 20) { var chunk: [13]u8 = undefined; const n = longNameChars(lfn, &chunk); const start = (order - 1) * 13; if (start + n <= long_name.len) { @memcpy(long_name[start .. start + n], chunk[0..n]); if (lfn.order & 0x40 != 0) long_len = start + n; } } continue; } if (entry.isVolumeLabel()) { run_len = 0; long_len = 0; continue; } // A real 8.3 entry. var short: [12]u8 = undefined; const display = if (long_len > 0) long_name[0..long_len] else format83(entry.name, &short); if (nameMatches(display, old_name)) { var updated = entry; updated.name = raw; @memcpy(self.dir_sector[offset .. offset + @sizeOf(on_disk.DirectoryEntry)], std.mem.asBytes(&updated)); if (!self.blockWrite(lba, &self.dir_sector)) return false; // Drop the old long name, if any, so the new 8.3 name is what shows. var r: usize = 0; while (r < run_len) : (r += 1) self.markDeleted(run[r].sector, run[r].offset); return true; } run_len = 0; long_len = 0; } } return false; } }; // --- tests: a RAM-backed FAT16 image ---------------------------------------- const RamDisk = struct { bytes: []u8, // Instrumentation for the multi-sector tests: how many device commands were // issued, and the largest run (in sectors) any single command carried. reads: usize = 0, writes: usize = 0, max_run: u32 = 0, fn readBlocks(context: *anyopaque, lba: u64, count: u32, buffer: []u8) bool { const self: *RamDisk = @ptrCast(@alignCast(context)); const len = @as(usize, count) * sector_size; const start = lba * sector_size; if (start + len > self.bytes.len or buffer.len < len) return false; @memcpy(buffer[0..len], self.bytes[start .. start + len]); self.reads += 1; if (count > self.max_run) self.max_run = count; return true; } fn writeBlocks(context: *anyopaque, lba: u64, count: u32, buffer: []const u8) bool { const self: *RamDisk = @ptrCast(@alignCast(context)); const len = @as(usize, count) * sector_size; const start = lba * sector_size; if (start + len > self.bytes.len or buffer.len < len) return false; @memcpy(self.bytes[start .. start + len], buffer[0..len]); self.writes += 1; if (count > self.max_run) self.max_run = count; return true; } fn device(self: *RamDisk) BlockDevice { return .{ .context = self, .block_size = sector_size, .block_count = self.bytes.len / sector_size, .readBlocksFn = readBlocks, .writeBlocksFn = writeBlocks, }; } }; // Format a minimal FAT16 volume into `bytes`: BPB + boot signature, FATs with the // two reserved entries, an empty root directory. Enough for the engine to mount // and operate on. fn formatFat16(bytes: []u8) void { formatFat16Spc(bytes, 1); } fn formatFat16Spc(bytes: []u8, sectors_per_cluster: u8) void { @memset(bytes, 0); const total_sectors: u16 = @intCast(bytes.len / sector_size); var bpb = std.mem.zeroes(on_disk.BiosParameterBlock); bpb.jump = .{ 0xEB, 0x3C, 0x90 }; bpb.oem_name = "MSWIN4.1".*; bpb.bytes_per_sector = sector_size; bpb.sectors_per_cluster = sectors_per_cluster; bpb.reserved_sector_count = 1; bpb.fat_count = 2; bpb.root_entry_count = 512; bpb.total_sectors_16 = total_sectors; bpb.media = 0xF8; bpb.fat_size_16 = 16; // 16 sectors per FAT (covers ~4000 FAT16 entries) @memcpy(bytes[0..@sizeOf(on_disk.BiosParameterBlock)], std.mem.asBytes(&bpb)); bytes[on_disk.boot_signature_offset] = 0x55; bytes[on_disk.boot_signature_offset + 1] = 0xAA; // FAT reserved entries: entry0 = media in low byte + 0xFF, entry1 = EOC. const fat0 = 1 * sector_size; bytes[fat0] = 0xF8; bytes[fat0 + 1] = 0xFF; bytes[fat0 + 2] = 0xFF; bytes[fat0 + 3] = 0xFF; const fat1 = fat0 + 16 * sector_size; bytes[fat1] = 0xF8; bytes[fat1 + 1] = 0xFF; bytes[fat1 + 2] = 0xFF; bytes[fat1 + 3] = 0xFF; } test "mount a formatted FAT16 image" { const allocator = std.testing.allocator; const bytes = try allocator.alloc(u8, 5000 * sector_size); // ~2.4 MB defer allocator.free(bytes); formatFat16(bytes); var disk = RamDisk{ .bytes = bytes }; var fs = FileSystem.mount(disk.device()).?; try std.testing.expectEqual(on_disk.FatType.fat16, fs.geometry.fat_type); try std.testing.expect(fs.geometry.cluster_count >= 4085); // An empty root directory lists nothing. try std.testing.expect(fs.listEntry(fs.rootNode(), 0) == null); } test "create, write, read back a file through the engine" { const allocator = std.testing.allocator; const bytes = try allocator.alloc(u8, 5000 * sector_size); defer allocator.free(bytes); formatFat16(bytes); var disk = RamDisk{ .bytes = bytes }; var fs = FileSystem.mount(disk.device()).?; // Create /HELLO.TXT and write a payload larger than one sector (spans clusters). var node = fs.createFile(fs.rootNode(), "HELLO.TXT").?; var payload: [1500]u8 = undefined; for (&payload, 0..) |*b, i| b.* = @truncate(i); const written = fs.writeFile(&node, 0, &payload); try std.testing.expectEqual(@as(usize, payload.len), written); // Re-resolve from the directory (proving the entry was persisted) and read back. const resolved = fs.resolve("/HELLO.TXT").?; try std.testing.expectEqual(@as(u32, payload.len), resolved.size); var readback: [1500]u8 = undefined; const got = fs.readFile(resolved, 0, &readback); try std.testing.expectEqual(@as(usize, payload.len), got); try std.testing.expectEqualSlices(u8, &payload, &readback); // A mid-file overwrite is visible on re-read. var patch = [_]u8{0xAB} ** 4; _ = fs.writeFile(&node, 600, &patch); const patched = fs.resolve("/HELLO.TXT").?; _ = fs.readFile(patched, 600, readback[0..4]); try std.testing.expectEqualSlices(u8, &patch, readback[0..4]); // The root now lists exactly HELLO.TXT. const listing = fs.listEntry(fs.rootNode(), 0).?; try std.testing.expectEqualStrings("HELLO.TXT", listing.name_buffer[0..listing.name_len]); try std.testing.expect(fs.listEntry(fs.rootNode(), 1) == null); } test "the block cache serves repeated metadata reads and stays write-through coherent" { const allocator = std.testing.allocator; const bytes = try allocator.alloc(u8, 5000 * sector_size); defer allocator.free(bytes); formatFat16(bytes); var disk = RamDisk{ .bytes = bytes }; var fs = FileSystem.mount(disk.device()).?; var node = fs.createFile(fs.rootNode(), "CACHE.TXT").?; var small = [_]u8{0x5A} ** 64; _ = fs.writeFile(&node, 0, &small); // First resolve warms the cache; the second re-reads the same directory (and // FAT) sectors, which must now come entirely from RAM — zero device reads. _ = fs.resolve("/CACHE.TXT").?; disk.reads = 0; const again = fs.resolve("/CACHE.TXT").?; try std.testing.expectEqual(@as(usize, 0), disk.reads); try std.testing.expectEqual(@as(u32, small.len), again.size); // Write-through coherence: a mid-file overwrite reaches the device (a fresh // mount, cold cache, reads the new bytes back) and the cache reflects it too. var patch = [_]u8{0xC3} ** 16; _ = fs.writeFile(&node, 16, &patch); var cached_back: [16]u8 = undefined; _ = fs.readFile(fs.resolve("/CACHE.TXT").?, 16, &cached_back); try std.testing.expectEqualSlices(u8, &patch, &cached_back); var cold = FileSystem.mount(disk.device()).?; // cold cache, reads from device var device_back: [16]u8 = undefined; _ = cold.readFile(cold.resolve("/CACHE.TXT").?, 16, &device_back); try std.testing.expectEqualSlices(u8, &patch, &device_back); } test "multi-sector transfers coalesce and round-trip identically" { const allocator = std.testing.allocator; const bytes = try allocator.alloc(u8, 8000 * sector_size); defer allocator.free(bytes); // 8 sectors per cluster: aligned runs can hit the max_transfer_sectors cap. formatFat16Spc(bytes, 8); var disk = RamDisk{ .bytes = bytes }; var fs = FileSystem.mount(disk.device()).?; // A payload that is many whole sectors plus a partial tail, all sector-aligned // at the start (offset 0): the aligned-run path should carry the bulk. const size = 20 * sector_size + 100; const payload = try allocator.alloc(u8, size); defer allocator.free(payload); for (payload, 0..) |*b, i| b.* = @truncate(i * 7 + 3); var node = fs.createFile(fs.rootNode(), "BIG.BIN").?; const written = fs.writeFile(&node, 0, payload); try std.testing.expectEqual(size, written); // Overwrite the same region (no allocation, no cluster-zeroing, no FAT writes), // so the command count reflects only data movement. The 21 sectors it spans // (three clusters of 8) coalesce into a handful of runs — far fewer than the // >=21 writes a per-sector engine would issue, and each full sector skips the // read-modify-write entirely. disk.writes = 0; disk.max_run = 0; _ = fs.writeFile(&node, 0, payload); // A per-sector engine would issue >=21 writes (one RMW per sector); coalescing // plus skip-RMW-on-full-sector brings it to the three aligned runs + one partial // tail + the directory-entry writeback. try std.testing.expect(disk.max_run > 1); try std.testing.expect(disk.writes <= 5); const resolved = fs.resolve("/BIG.BIN").?; try std.testing.expectEqual(@as(u32, size), resolved.size); // Read back the whole file and compare byte-for-byte. const readback = try allocator.alloc(u8, size); defer allocator.free(readback); disk.reads = 0; disk.max_run = 0; const got = fs.readFile(resolved, 0, readback); try std.testing.expectEqual(size, got); try std.testing.expectEqualSlices(u8, payload, readback); try std.testing.expect(disk.max_run > 1); try std.testing.expect(disk.reads < 21); // Equivalence at an unaligned offset spanning a cluster boundary: same bytes as // a naive per-sector engine would produce (the payload we wrote). var window: [1000]u8 = undefined; const n = fs.readFile(resolved, 300, &window); try std.testing.expectEqual(@as(usize, 1000), n); try std.testing.expectEqualSlices(u8, payload[300 .. 300 + 1000], window[0..1000]); } test "truncate frees the chain and zeroes the file" { const allocator = std.testing.allocator; const bytes = try allocator.alloc(u8, 5000 * sector_size); defer allocator.free(bytes); formatFat16(bytes); var disk = RamDisk{ .bytes = bytes }; var fs = FileSystem.mount(disk.device()).?; var node = fs.createFile(fs.rootNode(), "BIG.BIN").?; var payload: [2000]u8 = undefined; for (&payload, 0..) |*b, i| b.* = @truncate(i); _ = fs.writeFile(&node, 0, &payload); const cluster = node.first_cluster; try std.testing.expect(cluster >= 2); fs.truncate(&node); try std.testing.expectEqual(@as(u32, 0), node.size); try std.testing.expectEqual(@as(u32, 0), node.first_cluster); // The old first cluster is free again. try std.testing.expectEqual(on_disk.free_cluster, fs.readFatEntry(cluster)); // Re-resolve: the persisted entry is empty, and reads produce nothing. const resolved = fs.resolve("/BIG.BIN").?; try std.testing.expectEqual(@as(u32, 0), resolved.size); var buf: [16]u8 = undefined; try std.testing.expectEqual(@as(usize, 0), fs.readFile(resolved, 0, &buf)); } test "overwrite after truncate leaves no stale tail (the O_TRUNC corruption fix)" { const allocator = std.testing.allocator; const bytes = try allocator.alloc(u8, 5000 * sector_size); defer allocator.free(bytes); formatFat16(bytes); var disk = RamDisk{ .bytes = bytes }; var fs = FileSystem.mount(disk.device()).?; // Write a long file, then truncate-and-rewrite a short one — the O_TRUNC flow. var node = fs.createFile(fs.rootNode(), "LOG.TXT").?; _ = fs.writeFile(&node, 0, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); // 32 bytes fs.truncate(&node); _ = fs.writeFile(&node, 0, "bb"); // Size is the short length — no lingering old bytes past the new end. const resolved = fs.resolve("/LOG.TXT").?; try std.testing.expectEqual(@as(u32, 2), resolved.size); var buf: [8]u8 = undefined; const n = fs.readFile(resolved, 0, &buf); try std.testing.expectEqualStrings("bb", buf[0..n]); } test "remove a file frees its slot and its cluster chain" { const allocator = std.testing.allocator; const bytes = try allocator.alloc(u8, 5000 * sector_size); defer allocator.free(bytes); formatFat16(bytes); var disk = RamDisk{ .bytes = bytes }; var fs = FileSystem.mount(disk.device()).?; var node = fs.createFile(fs.rootNode(), "GONE.TXT").?; var payload: [1000]u8 = undefined; for (&payload, 0..) |*b, i| b.* = @truncate(i); _ = fs.writeFile(&node, 0, &payload); const cluster = fs.resolve("/GONE.TXT").?.first_cluster; try std.testing.expect(cluster >= 2); try std.testing.expect(fs.removeFile(fs.rootNode(), "GONE.TXT")); // Gone from the directory, its cluster free, root empty again. try std.testing.expect(fs.resolve("/GONE.TXT") == null); try std.testing.expectEqual(on_disk.free_cluster, fs.readFatEntry(cluster)); try std.testing.expect(fs.listEntry(fs.rootNode(), 0) == null); // Removing a missing file reports false. try std.testing.expect(!fs.removeFile(fs.rootNode(), "GONE.TXT")); // A directory is refused (it is not a file). _ = fs.createDirectory(fs.rootNode(), "ADIR").?; try std.testing.expect(!fs.removeFile(fs.rootNode(), "ADIR")); } test "create a subdirectory with . and .. and a file inside" { const allocator = std.testing.allocator; const bytes = try allocator.alloc(u8, 5000 * sector_size); defer allocator.free(bytes); formatFat16(bytes); var disk = RamDisk{ .bytes = bytes }; var fs = FileSystem.mount(disk.device()).?; const made = fs.createDirectory(fs.rootNode(), "SUB").?; try std.testing.expect(made.is_directory); try std.testing.expect(made.first_cluster >= 2); // It resolves as a directory, with "." and ".." as its first two entries. const dir = fs.resolve("/SUB").?; try std.testing.expect(dir.is_directory); const dot = fs.listEntry(dir, 0).?; try std.testing.expectEqualStrings(".", dot.name_buffer[0..dot.name_len]); const dotdot = fs.listEntry(dir, 1).?; try std.testing.expectEqualStrings("..", dotdot.name_buffer[0..dotdot.name_len]); // A file created inside is reachable by its full path. var child = fs.createFile(dir, "INNER.TXT").?; _ = fs.writeFile(&child, 0, "hi"); const inner = fs.resolve("/SUB/INNER.TXT").?; try std.testing.expectEqual(@as(u32, 2), inner.size); // The root lists SUB as a directory. const listing = fs.listEntry(fs.rootNode(), 0).?; try std.testing.expectEqualStrings("SUB", listing.name_buffer[0..listing.name_len]); try std.testing.expect(listing.is_directory); } test "rename a file in place, keeping its contents" { const allocator = std.testing.allocator; const bytes = try allocator.alloc(u8, 5000 * sector_size); defer allocator.free(bytes); formatFat16(bytes); var disk = RamDisk{ .bytes = bytes }; var fs = FileSystem.mount(disk.device()).?; var node = fs.createFile(fs.rootNode(), "OLD.TXT").?; _ = fs.writeFile(&node, 0, "content"); 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").?; var buf: [16]u8 = undefined; const n = fs.readFile(renamed, 0, &buf); try std.testing.expectEqualStrings("content", buf[0..n]); // Refuse a collision with an existing name. _ = fs.createFile(fs.rootNode(), "OTHER.TXT").?; try std.testing.expect(!fs.rename(fs.rootNode(), "NEW.TXT", "OTHER.TXT")); // Refuse a non-8.3 target name. try std.testing.expect(!fs.rename(fs.rootNode(), "NEW.TXT", "toolongbasename.txt")); // Refuse a missing source. try std.testing.expect(!fs.rename(fs.rootNode(), "NOPE.TXT", "X.TXT")); // After the refused renames, NEW.TXT is untouched. try std.testing.expect(fs.resolve("/NEW.TXT") != null); } test "a create stamps the modification time" { const allocator = std.testing.allocator; const bytes = try allocator.alloc(u8, 5000 * sector_size); defer allocator.free(bytes); formatFat16(bytes); var disk = RamDisk{ .bytes = bytes }; var fs = FileSystem.mount(disk.device()).?; fs.current_time_epoch = 1_700_000_000; // an even-second UTC time var node = fs.createFile(fs.rootNode(), "STAMP.TXT").?; _ = fs.writeFile(&node, 0, "hi"); // The persisted entry carries the stamped mtime (even seconds round-trip exactly), // as does a fresh listing. try std.testing.expectEqual(@as(u64, 1_700_000_000), fs.resolve("/STAMP.TXT").?.mtime); try std.testing.expectEqual(@as(u64, 1_700_000_000), fs.listEntry(fs.rootNode(), 0).?.mtime); } test "long-name create: directory + file round-trip by long name" { const allocator = std.testing.allocator; const bytes = try allocator.alloc(u8, 5000 * sector_size); defer allocator.free(bytes); formatFat16(bytes); var disk = RamDisk{ .bytes = bytes }; var fs = FileSystem.mount(disk.device()).?; // The per-boot log directory shape: an 18-char stamp, nested paths, .log names. const stamp_dir = fs.createDirectory(fs.rootNode(), "2026-07-21T101530Z").?; try std.testing.expect(stamp_dir.is_directory); const file = fs.createFile(stamp_dir, "device-manager.log").?; _ = file; // Resolve by exact long name, and case-insensitively (FAT semantics). try std.testing.expect(fs.resolve("/2026-07-21T101530Z/device-manager.log") != null); try std.testing.expect(fs.resolve("/2026-07-21t101530z/DEVICE-MANAGER.LOG") != null); // The listing shows the long names, not the ~N aliases. var listing = fs.listEntry(fs.rootNode(), 0).?; try std.testing.expectEqualStrings("2026-07-21T101530Z", listing.name_buffer[0..listing.name_len]); var inner = fs.listEntry(stamp_dir, 2).?; // after "." and ".." try std.testing.expectEqualStrings("device-manager.log", inner.name_buffer[0..inner.name_len]); // Write through the created file and read it back by long-name resolve. var node = fs.resolve("/2026-07-21T101530Z/device-manager.log").?; try std.testing.expectEqual(@as(usize, 10), fs.writeFile(&node, 0, "hello logs")); var buffer: [16]u8 = undefined; try std.testing.expectEqual(@as(usize, 10), fs.readFile(node, 0, buffer[0..10])); try std.testing.expectEqualStrings("hello logs", buffer[0..10]); } test "long-name create: ~N alias collision suffixes stay distinct" { const allocator = std.testing.allocator; const bytes = try allocator.alloc(u8, 5000 * sector_size); defer allocator.free(bytes); formatFat16(bytes); var disk = RamDisk{ .bytes = bytes }; var fs = FileSystem.mount(disk.device()).?; _ = fs.createFile(fs.rootNode(), "logger-alpha.log").?; _ = fs.createFile(fs.rootNode(), "logger-beta.log").?; // Same 6-char mangle stem (LOGGER) — the second must take ~2. var raw_one = false; var raw_two = false; var cursor: u32 = 0; while (fs.listEntry(fs.rootNode(), cursor)) |entry| : (cursor += 1) { if (std.mem.eql(u8, entry.name_buffer[0..entry.name_len], "logger-alpha.log")) raw_one = true; if (std.mem.eql(u8, entry.name_buffer[0..entry.name_len], "logger-beta.log")) raw_two = true; } try std.testing.expect(raw_one and raw_two); try std.testing.expect(fs.resolve("/logger-alpha.log") != null); try std.testing.expect(fs.resolve("/logger-beta.log") != null); // Their short aliases took distinct ~N tails. (Alias LOOKUP is not a // feature — findChild matches display names — but the on-disk aliases // must not collide for other FAT readers.) try std.testing.expect(fs.shortNameExists(fs.rootNode(), "LOGGER~1LOG".*)); try std.testing.expect(fs.shortNameExists(fs.rootNode(), "LOGGER~2LOG".*)); } test "long-name create: unlink removes the chain; slots are reused cleanly" { const allocator = std.testing.allocator; const bytes = try allocator.alloc(u8, 5000 * sector_size); defer allocator.free(bytes); formatFat16(bytes); var disk = RamDisk{ .bytes = bytes }; var fs = FileSystem.mount(disk.device()).?; _ = fs.createFile(fs.rootNode(), "a-rather-long-file-name.txt").?; try std.testing.expect(fs.removeFile(fs.rootNode(), "a-rather-long-file-name.txt")); try std.testing.expect(fs.resolve("/a-rather-long-file-name.txt") == null); // A new long name reuses the freed run without inheriting the old chain. _ = fs.createFile(fs.rootNode(), "an-entirely-different-name.md").?; try std.testing.expect(fs.resolve("/an-entirely-different-name.md") != null); try std.testing.expect(fs.resolve("/a-rather-long-file-name.txt") == null); var listing = fs.listEntry(fs.rootNode(), 0).?; try std.testing.expectEqualStrings("an-entirely-different-name.md", listing.name_buffer[0..listing.name_len]); } test "8.3 fast path: an uppercase-compliant name gets one bare entry" { const allocator = std.testing.allocator; const bytes = try allocator.alloc(u8, 5000 * sector_size); defer allocator.free(bytes); formatFat16(bytes); var disk = RamDisk{ .bytes = bytes }; var fs = FileSystem.mount(disk.device()).?; _ = fs.createFile(fs.rootNode(), "DANOS.LOG").?; // Exactly one directory entry: entry 0 is the file, entry 1 is the end. var listing = fs.listEntry(fs.rootNode(), 0).?; try std.testing.expectEqualStrings("DANOS.LOG", listing.name_buffer[0..listing.name_len]); try std.testing.expect(fs.listEntry(fs.rootNode(), 1) == null); // A lowercase 8.3-shaped name is case-preserved via a chain instead. _ = fs.createFile(fs.rootNode(), "fat.log").?; var second = fs.listEntry(fs.rootNode(), 1).?; try std.testing.expectEqualStrings("fat.log", second.name_buffer[0..second.name_len]); } test "short-name checksum matches the reference vector" { // "README TXT" is a widely published example: checksum 0x15... compute a // fixed pair to pin the rotate-add against regressions. const a = FileSystem.shortChecksum("README TXT".*); const b = FileSystem.shortChecksum("LOGGER~1LOG".*); try std.testing.expect(a != b); // The algorithm is order-sensitive: swapped bytes change the sum. const c = FileSystem.shortChecksum("REDAME TXT".*); try std.testing.expect(a != c); }