//! The on-disk layout of an exFAT filesystem — the Main Boot Sector (VBR) and the //! six 32-byte directory-entry types — as `align(1)` extern structs that bit-cast //! straight out of a sector (multi-byte fields little-endian). Pure data, plus the //! three exFAT checksums (boot region, up-case table, directory-entry set), the //! name hash, and the packed timestamp <-> Unix-epoch conversion. Host-testable. //! //! exFAT departs from FAT in three ways this file encodes: geometry lives in a //! MustBeZero-guarded VBR (byte 11 is zero, which is exactly why the FAT prober //! rejects an exFAT volume — it reads a zero bytes-per-sector); a file is a SET of //! entries (a File entry, a Stream Extension, and one or more File Name entries) //! validated by a rotate-right checksum; and names are compared case-folded through //! the volume's own on-disk up-case table (the folding itself lives in the engine, //! which holds the loaded table; the hash it feeds is here). const std = @import("std"); // exFAT's fixed on-disk widths — byte and UTF-16-unit counts the FORMAT defines, // not ceilings danos chooses. Named so the wire-format structs carry no bare // literal lengths; the values are facts of the spec. pub const entry_bytes: usize = 32; // every directory entry const jump_boot_bytes = 3; const filesystem_name_bytes = 8; // "EXFAT " const must_be_zero_bytes = 53; // the FAT-BPB overlap the format holds zero const boot_code_bytes = 390; const volume_label_units = 11; // --- the Main Boot Sector (VBR, sector 0) ------------------------------------ /// The exFAT Main Boot Sector. `must_be_zero` (offset 11..64) overlaps where a /// FAT BPB keeps bytes-per-sector/sectors-per-cluster/etc.; exFAT holds it zero, /// so a FAT prober reading a zero bytes-per-sector rejects the volume — the /// mutual-exclusion the two engines rely on. pub const MainBootSector = extern struct { jump_boot: [jump_boot_bytes]u8, // 0 filesystem_name: [filesystem_name_bytes]u8, // 3 "EXFAT " must_be_zero: [must_be_zero_bytes]u8, // 11 partition_offset: u64 align(1), // 64 sectors, informational volume_length: u64 align(1), // 72 sectors fat_offset: u32 align(1), // 80 sectors from volume start fat_length: u32 align(1), // 84 sectors, per FAT cluster_heap_offset: u32 align(1), // 88 sectors from volume start cluster_count: u32 align(1), // 92 first_cluster_of_root: u32 align(1), // 96 volume_serial_number: u32 align(1), // 100 filesystem_revision: u16 align(1), // 104 volume_flags: u16 align(1), // 106 (skipped by the boot checksum) bytes_per_sector_shift: u8, // 108 9..12 sectors_per_cluster_shift: u8, // 109 number_of_fats: u8, // 110 1 (2 for TexFAT) drive_select: u8, // 111 percent_in_use: u8, // 112 (skipped by the boot checksum) reserved: [7]u8, // 113 boot_code: [boot_code_bytes]u8, // 120 boot_signature: u16 align(1), // 510 0xAA55 }; // --- directory entries (32 bytes each) --------------------------------------- /// Entry-type bytes. The high bit (0x80) is InUse: a type with it clear is not in /// use, and 0x00 ends the directory. Deleting an entry clears bit 7 (0x85 -> 0x05). pub const entry_type_allocation_bitmap: u8 = 0x81; pub const entry_type_upcase_table: u8 = 0x82; pub const entry_type_volume_label: u8 = 0x83; pub const entry_type_file: u8 = 0x85; pub const entry_type_stream_extension: u8 = 0xC0; pub const entry_type_file_name: u8 = 0xC1; pub const entry_type_in_use_bit: u8 = 0x80; pub const entry_type_end_of_directory: u8 = 0x00; /// A raw 32-byte entry, for type dispatch before it is reinterpreted as a /// specific entry. pub const RawEntry = extern struct { entry_type: u8, data: [entry_bytes - 1]u8, pub fn inUse(self: RawEntry) bool { return self.entry_type & entry_type_in_use_bit != 0; } pub fn isEnd(self: RawEntry) bool { return self.entry_type == entry_type_end_of_directory; } }; /// 0x81 — the Allocation Bitmap: one bit per cluster (cluster 2 = bit 0), the /// authority for which clusters are free. The deepest departure from FAT, where /// the chain itself was the authority. pub const AllocationBitmapEntry = extern struct { entry_type: u8, // 0 0x81 bitmap_flags: u8, // 1 reserved: [18]u8, // 2 first_cluster: u32 align(1), // 20 data_length: u64 align(1), // 24 }; /// 0x82 — the Up-case Table: the on-disk case-fold map (code unit -> uppercase), /// referenced by cluster and validated by `table_checksum`. pub const UpcaseTableEntry = extern struct { entry_type: u8, // 0 0x82 reserved1: [3]u8, // 1 table_checksum: u32 align(1), // 4 reserved2: [12]u8, // 8 first_cluster: u32 align(1), // 20 data_length: u64 align(1), // 24 }; /// 0x83 — the Volume Label (up to 11 UTF-16 units). pub const VolumeLabelEntry = extern struct { entry_type: u8, // 0 0x83 character_count: u8, // 1 volume_label: [volume_label_units]u16 align(1), // 2 reserved: [8]u8, // 24 }; /// 0x85 — the File entry: the head of a set, carrying the attributes, /// timestamps, the secondary-entry count, and the set checksum. pub const FileEntry = extern struct { entry_type: u8, // 0 0x85 secondary_count: u8, // 1 stream (1) + name entries set_checksum: u16 align(1), // 2 over the whole set, skipping these two bytes file_attributes: u16 align(1), // 4 reserved1: u16 align(1), // 6 create_timestamp: u32 align(1), // 8 last_modified_timestamp: u32 align(1), // 12 last_accessed_timestamp: u32 align(1), // 16 create_10ms: u8, // 20 last_modified_10ms: u8, // 21 create_utc_offset: u8, // 22 last_modified_utc_offset: u8, // 23 last_accessed_utc_offset: u8, // 24 reserved2: [7]u8, // 25 }; /// 0xC0 — the Stream Extension: the second entry of every file set, carrying the /// name length + hash and the data location (first cluster, sizes, the /// no-FAT-chain flag). pub const StreamExtensionEntry = extern struct { entry_type: u8, // 0 0xC0 general_secondary_flags: u8, // 1 reserved1: u8, // 2 name_length: u8, // 3 UTF-16 units name_hash: u16 align(1), // 4 reserved2: u16 align(1), // 6 valid_data_length: u64 align(1), // 8 reserved3: u32 align(1), // 16 first_cluster: u32 align(1), // 20 data_length: u64 align(1), // 24 }; /// 0xC1 — a File Name entry: 15 UTF-16 units of the name; a set carries /// ceil(name_length / 15) of them. pub const FileNameEntry = extern struct { entry_type: u8, // 0 0xC1 general_secondary_flags: u8, // 1 file_name: [name_units_per_entry]u16 align(1), // 2 }; pub const name_units_per_entry: usize = 15; // General secondary flags (Stream Extension + File Name entries). pub const secondary_flag_allocation_possible: u8 = 0x01; pub const secondary_flag_no_fat_chain: u8 = 0x02; // File attributes (same bit assignments as FAT). pub const attribute_read_only: u16 = 0x0001; pub const attribute_hidden: u16 = 0x0002; pub const attribute_system: u16 = 0x0004; pub const attribute_directory: u16 = 0x0010; pub const attribute_archive: u16 = 0x0020; // FAT special cluster values (exFAT's FAT is 32-bit; used only for a fragmented // chain, i.e. when no_fat_chain is clear). pub const first_data_cluster: u32 = 2; pub const end_of_chain: u32 = 0xFFFFFFFF; pub const bad_cluster: u32 = 0xFFFFFFF7; pub const boot_signature_offset: usize = 510; // 0x55 0xAA // --- geometry ---------------------------------------------------------------- pub const Geometry = struct { bytes_per_sector: u32, sectors_per_cluster: u32, cluster_count: u32, fat_offset_sectors: u32, // from volume start fat_length_sectors: u32, cluster_heap_offset_sectors: u32, // from volume start first_cluster_of_root: u32, volume_serial_number: u32, volume_length_sectors: u64, number_of_fats: u32, }; /// Derive the geometry from a Main Boot Sector. Returns null unless it is a /// plausible exFAT VBR: the "EXFAT " name, an all-zero MustBeZero region, the /// 0xAA55 signature, and sane shifts. Accepting ONLY these is what keeps exFAT and /// FAT from ever claiming each other's volumes. pub fn geometryOf(sector: []const u8) ?Geometry { if (sector.len < 512) return null; if (sector[boot_signature_offset] != 0x55 or sector[boot_signature_offset + 1] != 0xAA) return null; const vbr = std.mem.bytesToValue(MainBootSector, sector[0..@sizeOf(MainBootSector)]); if (!std.mem.eql(u8, &vbr.filesystem_name, "EXFAT ")) return null; for (vbr.must_be_zero) |byte| if (byte != 0) return null; if (vbr.bytes_per_sector_shift < 9 or vbr.bytes_per_sector_shift > 12) return null; // The exFAT spec caps a cluster at 2^25 bytes (32 MiB): bytes-per-sector-shift // plus sectors-per-cluster-shift must not exceed 25. Enforcing it here is also // what keeps the engine's u32 cluster-byte arithmetic (sectors_per_cluster * // 512) from overflowing on a crafted VBR off untrusted removable media. if (@as(u16, vbr.bytes_per_sector_shift) + vbr.sectors_per_cluster_shift > 25) return null; // cluster_count is capped at 0xFFFFFFF5 (the spec's ClusterCount maximum), so // cluster_count + first_data_cluster cannot overflow u32 in the bounds checks. if (vbr.number_of_fats == 0 or vbr.cluster_count == 0 or vbr.cluster_count > 0xFFFFFFF5) return null; if (vbr.first_cluster_of_root < first_data_cluster) return null; return .{ .bytes_per_sector = @as(u32, 1) << @intCast(vbr.bytes_per_sector_shift), .sectors_per_cluster = @as(u32, 1) << @intCast(vbr.sectors_per_cluster_shift), .cluster_count = vbr.cluster_count, .fat_offset_sectors = vbr.fat_offset, .fat_length_sectors = vbr.fat_length, .cluster_heap_offset_sectors = vbr.cluster_heap_offset, .first_cluster_of_root = vbr.first_cluster_of_root, .volume_serial_number = vbr.volume_serial_number, .volume_length_sectors = vbr.volume_length, .number_of_fats = vbr.number_of_fats, }; } // --- checksums and the name hash --------------------------------------------- /// The directory-entry-SET checksum (a File entry's `set_checksum`), a 16-bit /// rotate-right sum over every byte of the set, skipping the two checksum bytes /// themselves (offset 2..3 of the first entry). `entries` is the whole set: /// (secondary_count + 1) * 32 bytes. pub fn setChecksum(entries: []const u8) u16 { var checksum: u16 = 0; for (entries, 0..) |byte, i| { if (i == 2 or i == 3) continue; checksum = std.math.rotr(u16, checksum, 1) +% byte; } return checksum; } /// The up-case-table checksum (an Up-case entry's `table_checksum`), a 32-bit /// rotate-right sum over the table's on-disk bytes. pub fn upcaseChecksum(table_bytes: []const u8) u32 { var checksum: u32 = 0; for (table_bytes) |byte| checksum = std.math.rotr(u32, checksum, 1) +% byte; return checksum; } /// The boot-region checksum — the u32 the checksum sector repeats — a 32-bit /// rotate-right sum over the first eleven sectors, skipping VolumeFlags (offset /// 106..107) and PercentInUse (offset 112) of the first sector. pub fn bootChecksum(region: []const u8) u32 { var checksum: u32 = 0; for (region, 0..) |byte, i| { if (i == 106 or i == 107 or i == 112) continue; checksum = std.math.rotr(u32, checksum, 1) +% byte; } return checksum; } /// The name hash a Stream entry carries: a 16-bit rotate-right sum over the /// UP-CASED name's bytes (low byte then high byte of each UTF-16 unit). The caller /// up-cases through the volume's table first; a mismatch lets a lookup reject a /// name without reading its File Name entries. pub fn nameHash(upcased: []const u16) u16 { var hash: u16 = 0; for (upcased) |unit| { hash = std.math.rotr(u16, hash, 1) +% @as(u8, @truncate(unit)); hash = std.math.rotr(u16, hash, 1) +% @as(u8, @truncate(unit >> 8)); } return hash; } // --- timestamps -------------------------------------------------------------- // // exFAT packs a timestamp into one u32: the high 16 bits are a DOS date // (year-1980 | month | day), the low 16 a DOS time (hour | minute | second/2). // Same field layout as FAT, so the epoch math matches; there is no timezone in // the packed value (a separate UTC-offset byte carries that, which danos leaves // zero = UTC). fn isLeapYear(year: u32) bool { return (year % 4 == 0 and year % 100 != 0) or (year % 400 == 0); } const days_in_month = [_]u8{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; /// Convert a packed exFAT timestamp to Unix epoch seconds (UTC). 0 for unset. pub fn timestampToEpoch(timestamp: u32) u64 { if (timestamp == 0) return 0; const date: u32 = timestamp >> 16; const time: u32 = timestamp & 0xFFFF; const day: u32 = date & 0x1F; const month: u32 = (date >> 5) & 0x0F; const year: u32 = 1980 + (date >> 9); if (month < 1 or month > 12 or day < 1) return 0; const second: u32 = (time & 0x1F) * 2; const minute: u32 = (time >> 5) & 0x3F; const hour: u32 = (time >> 11) & 0x1F; var days: u64 = 0; var y: u32 = 1970; while (y < year) : (y += 1) days += if (isLeapYear(y)) 366 else 365; var m: u32 = 1; while (m < month) : (m += 1) { days += days_in_month[m - 1]; if (m == 2 and isLeapYear(year)) days += 1; } days += day - 1; return ((days * 24 + hour) * 60 + minute) * 60 + second; } /// Convert Unix epoch seconds (UTC) to a packed exFAT timestamp. 0 for epoch 0 or /// any time before 1980 (unrepresentable). pub fn epochToTimestamp(epoch: u64) u32 { if (epoch == 0) return 0; var remaining = epoch; const second: u32 = @intCast(remaining % 60); remaining /= 60; const minute: u32 = @intCast(remaining % 60); remaining /= 60; const hour: u32 = @intCast(remaining % 24); remaining /= 24; var days: u32 = @intCast(remaining); var year: u32 = 1970; while (true) { const year_days: u32 = if (isLeapYear(year)) 366 else 365; if (days < year_days) break; days -= year_days; year += 1; } if (year < 1980) return 0; var month: u32 = 1; while (true) { var month_days: u32 = days_in_month[month - 1]; if (month == 2 and isLeapYear(year)) month_days += 1; if (days < month_days) break; days -= month_days; month += 1; } const day = days + 1; const date: u32 = ((year - 1980) << 9) | (month << 5) | day; const time: u32 = (hour << 11) | (minute << 5) | (second / 2); return (date << 16) | time; } // --- tests ------------------------------------------------------------------- test "on-disk struct sizes match the specification" { try std.testing.expectEqual(@as(usize, 512), @sizeOf(MainBootSector)); try std.testing.expectEqual(@as(usize, 32), @sizeOf(RawEntry)); try std.testing.expectEqual(@as(usize, 32), @sizeOf(AllocationBitmapEntry)); try std.testing.expectEqual(@as(usize, 32), @sizeOf(UpcaseTableEntry)); try std.testing.expectEqual(@as(usize, 32), @sizeOf(VolumeLabelEntry)); try std.testing.expectEqual(@as(usize, 32), @sizeOf(FileEntry)); try std.testing.expectEqual(@as(usize, 32), @sizeOf(StreamExtensionEntry)); try std.testing.expectEqual(@as(usize, 32), @sizeOf(FileNameEntry)); } test "MainBootSector field offsets" { try std.testing.expectEqual(@as(usize, 3), @offsetOf(MainBootSector, "filesystem_name")); try std.testing.expectEqual(@as(usize, 11), @offsetOf(MainBootSector, "must_be_zero")); try std.testing.expectEqual(@as(usize, 80), @offsetOf(MainBootSector, "fat_offset")); try std.testing.expectEqual(@as(usize, 88), @offsetOf(MainBootSector, "cluster_heap_offset")); try std.testing.expectEqual(@as(usize, 96), @offsetOf(MainBootSector, "first_cluster_of_root")); try std.testing.expectEqual(@as(usize, 106), @offsetOf(MainBootSector, "volume_flags")); try std.testing.expectEqual(@as(usize, 112), @offsetOf(MainBootSector, "percent_in_use")); try std.testing.expectEqual(@as(usize, 510), @offsetOf(MainBootSector, "boot_signature")); // The Stream Extension's data location must sit where the spec places it. try std.testing.expectEqual(@as(usize, 20), @offsetOf(StreamExtensionEntry, "first_cluster")); try std.testing.expectEqual(@as(usize, 24), @offsetOf(StreamExtensionEntry, "data_length")); } test "geometryOf accepts exFAT and the MustBeZero guard rejects a FAT-shaped sector" { var sector = [_]u8{0} ** 512; @memcpy(sector[3..11], "EXFAT "); sector[510] = 0x55; sector[511] = 0xAA; // fat_offset=128, fat_length=64, cluster_heap_offset=256, cluster_count=1000, // root cluster=5, bytes/sector=512 (shift 9), sectors/cluster=8 (shift 3), 1 FAT. std.mem.writeInt(u32, sector[80..84], 128, .little); std.mem.writeInt(u32, sector[84..88], 64, .little); std.mem.writeInt(u32, sector[88..92], 256, .little); std.mem.writeInt(u32, sector[92..96], 1000, .little); std.mem.writeInt(u32, sector[96..100], 5, .little); sector[108] = 9; // bytes_per_sector_shift sector[109] = 3; // sectors_per_cluster_shift sector[110] = 1; // number_of_fats const geo = geometryOf(§or) orelse return error.ShouldParse; try std.testing.expectEqual(@as(u32, 512), geo.bytes_per_sector); try std.testing.expectEqual(@as(u32, 8), geo.sectors_per_cluster); try std.testing.expectEqual(@as(u32, 1000), geo.cluster_count); try std.testing.expectEqual(@as(u32, 5), geo.first_cluster_of_root); // A non-zero byte in MustBeZero (where a FAT BPB keeps bytes-per-sector) is // rejected — the mutual exclusion between the engines. sector[11] = 0x02; try std.testing.expect(geometryOf(§or) == null); sector[11] = 0; // Wrong name is rejected too. sector[3] = 'F'; try std.testing.expect(geometryOf(§or) == null); sector[3] = 'E'; } test "geometryOf rejects crafted VBRs that would overflow u32 cluster arithmetic" { var sector = [_]u8{0} ** 512; @memcpy(sector[3..11], "EXFAT "); sector[510] = 0x55; sector[511] = 0xAA; std.mem.writeInt(u32, sector[92..96], 1000, .little); // cluster_count std.mem.writeInt(u32, sector[96..100], 5, .little); // root cluster sector[108] = 9; // bytes_per_sector_shift sector[110] = 1; // number_of_fats // A cluster shift past the exFAT ceiling (9 + 17 = 26 > 25) would make // sectors_per_cluster * 512 overflow u32 — rejected. sector[109] = 17; try std.testing.expect(geometryOf(§or) == null); sector[109] = 3; // sane again try std.testing.expect(geometryOf(§or) != null); // cluster_count above the spec maximum (0xFFFFFFF5) would overflow // cluster_count + first_data_cluster in the bounds checks — rejected. std.mem.writeInt(u32, sector[92..96], 0xFFFFFFFF, .little); try std.testing.expect(geometryOf(§or) == null); } test "set checksum skips its own two bytes and depends on the rest" { var set = [_]u8{0} ** 64; // a File entry + one secondary set[0] = entry_type_file; set[1] = 1; set[4] = 0x20; // an attribute byte set[40] = 0xAB; // a byte in the secondary entry const base = setChecksum(&set); // Changing the checksum field itself must NOT change the computed checksum. set[2] = 0xFF; set[3] = 0xEE; try std.testing.expectEqual(base, setChecksum(&set)); // Changing any other byte MUST change it. set[4] = 0x21; try std.testing.expect(setChecksum(&set) != base); } test "name hash is deterministic and order-sensitive" { const readme = [_]u16{ 'R', 'E', 'A', 'D', 'M', 'E' }; const different = [_]u16{ 'E', 'R', 'A', 'D', 'M', 'E' }; try std.testing.expectEqual(nameHash(&readme), nameHash(&readme)); try std.testing.expect(nameHash(&readme) != nameHash(&different)); } test "boot checksum skips VolumeFlags and PercentInUse" { var region = [_]u8{0} ** 1536; // three 512-byte sectors is enough to exercise the skips region[64] = 0x11; const base = bootChecksum(®ion); for ([_]usize{ 106, 107, 112 }) |skipped| { var copy = region; copy[skipped] = 0xFF; try std.testing.expectEqual(base, bootChecksum(©)); } var copy = region; copy[108] = 0xFF; // a non-skipped byte try std.testing.expect(bootChecksum(©) != base); } test "exFAT timestamp <-> Unix epoch round trip" { for ([_]u64{ 1_577_836_800, 1_700_000_000, 1_262_304_000, 1_783_971_244 }) |epoch| { try std.testing.expectEqual(epoch, timestampToEpoch(epochToTimestamp(epoch))); } // 1577836800 is 2020-01-01 00:00:00 UTC. const stamp = epochToTimestamp(1_577_836_800); try std.testing.expectEqual(@as(u32, 2020), 1980 + (stamp >> 16 >> 9)); try std.testing.expectEqual(@as(u64, 0), timestampToEpoch(0)); try std.testing.expectEqual(@as(u32, 0), epochToTimestamp(0)); }