From 2a6d60457736b653d3623602133a18e98ff53ef3 Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:22:19 +0100 Subject: [PATCH] =?UTF-8?q?tools:=20make-exfat-image.py=20=E2=80=94=20a=20?= =?UTF-8?q?real=20exFAT=20image=20builder=20(S4=20step=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure stdlib, no mkfs.exfat: writes a Main Boot Sector + its boot-region checksum + a backup region, the 32-bit FAT, an allocation bitmap, an up-case table (with its checksum), and a root directory carrying the bitmap/up-case/label entries plus a seeded HELLO.TXT file set. --serial sets the volume serial (the content id-path); --verify re-reads the VBR, recomputes the boot checksum, and validates each file set's checksum. Cross-checked against the engine: the Zig exFAT engine mounts a Python-produced image and reads HELLO.TXT through a case-insensitive resolve ("exfat hello danos"), proving the tool and engine agree on the layout — VBR, geometry, bitmap, up-case, and the entry-set checksum. No image is committed; the drill (step 8) generates one per run. --- tools/make-exfat-image.py | 289 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 tools/make-exfat-image.py diff --git a/tools/make-exfat-image.py b/tools/make-exfat-image.py new file mode 100644 index 0000000..9eaa3a3 --- /dev/null +++ b/tools/make-exfat-image.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +"""Format a real exFAT image from scratch — the danos exFAT test volume. + +Pure Python 3 standard library (no mkfs.exfat / mtools). It writes a valid exFAT +filesystem — a Main Boot Sector + its boot-region checksum + a backup region, the +32-bit FAT, an allocation bitmap, an up-case table (with its checksum), and a root +directory whose entry sets a real exFAT reader (and the danos exfat engine) mount +and walk. Mirrors tools/make-fat-image.py in spirit. + + make-exfat-image.py [--serial ] [--label ] + make-exfat-image.py --verify + +The image seeds one file, HELLO.TXT, so a mount can be proven by reading it. +""" + +import struct +import sys + +SECTOR = 512 +UPCASE_UNITS = 256 # a-z -> A-Z, the rest identity; covers ASCII names + + +def align_up(value, to): + return (value + to - 1) // to * to + + +def rotr16(v): + return ((v >> 1) | (v << 15)) & 0xFFFF + + +def rotr32(v): + return ((v >> 1) | (v << 31)) & 0xFFFFFFFF + + +def boot_checksum(region): + """32-bit rotate-right sum over the boot region, skipping VolumeFlags + (106,107) and PercentInUse (112) of the first sector.""" + checksum = 0 + for i, byte in enumerate(region): + if i in (106, 107, 112): + continue + checksum = (rotr32(checksum) + byte) & 0xFFFFFFFF + return checksum + + +def upcase_checksum(table_bytes): + checksum = 0 + for byte in table_bytes: + checksum = (rotr32(checksum) + byte) & 0xFFFFFFFF + return checksum + + +def set_checksum(entries): + """16-bit rotate-right sum over a directory-entry set, skipping its own two + checksum bytes (offset 2..3 of the first entry).""" + checksum = 0 + for i, byte in enumerate(entries): + if i in (2, 3): + continue + checksum = (rotr16(checksum) + byte) & 0xFFFF + return checksum + + +def name_hash(upcased_units): + h = 0 + for unit in upcased_units: + h = (rotr16(h) + (unit & 0xFF)) & 0xFFFF + h = (rotr16(h) + (unit >> 8)) & 0xFFFF + return h + + +def ascii_upper(unit): + return unit - ord("a") + ord("A") if ord("a") <= unit <= ord("z") else unit + + +def solve_geometry(total_sectors, spc): + """Solve for cluster count / FAT length / heap offset that fit. The FAT sits + after the main + backup boot regions (24 sectors).""" + fat_offset = 24 + fat_length = 1 + while True: + heap_offset = align_up(fat_offset + fat_length, spc) + cluster_count = (total_sectors - heap_offset) // spc + needed = ((cluster_count + 2) * 4 + SECTOR - 1) // SECTOR + if needed <= fat_length: + return cluster_count, fat_offset, fat_length, heap_offset + fat_length = needed + + +class ExfatImage: + def __init__(self, size_mib, volume_id=0x1234ABCD, label="DANOS"): + self.total_sectors = size_mib * 1024 * 1024 // SECTOR + self.spc = 8 # 4 KiB clusters + self.volume_id = volume_id & 0xFFFFFFFF + self.label = label + self.cluster_count, self.fat_offset, self.fat_length, self.heap_offset = solve_geometry(self.total_sectors, self.spc) + if self.cluster_count < 16: + sys.exit(f"error: image too small for exFAT ({self.cluster_count} clusters)") + self.cluster_bytes = self.spc * SECTOR + self.root_cluster = 4 # 2=bitmap, 3=up-case, 4=root + self.image = bytearray(self.total_sectors * SECTOR) + + def cluster_offset(self, cluster): + return (self.heap_offset + (cluster - 2) * self.spc) * SECTOR + + def set_fat(self, cluster, value): + struct.pack_into(" A-Z. + upcase = bytearray(UPCASE_UNITS * 2) + for i in range(UPCASE_UNITS): + struct.pack_into("] [--label ] \n" + " make-exfat-image.py --verify ") + out_path = argv[1] + size_mib = int(argv[2]) + image = ExfatImage(size_mib, volume_id, label) + with open(out_path, "wb") as handle: + handle.write(image.serialize()) + print(f"make-exfat-image: wrote {out_path} " + f"({size_mib} MiB exFAT, {image.cluster_count} clusters, serial 0x{image.volume_id:08X})") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv))