366 lines
15 KiB
Python
366 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""Format a real FAT32 image from a set of host files — the danos boot volume.
|
|
|
|
Mirrors tools/make-initial-ramdisk.py in spirit: pure Python 3 standard library,
|
|
no external tools (no mkfs.fat / mtools). It writes a valid FAT32 filesystem — a
|
|
boot sector + BPB, an FSInfo sector, a backup boot sector, two FATs, and a
|
|
directory tree of clusters — so UEFI/OVMF boots \\EFI\\BOOT\\BOOTX64.efi off it
|
|
and the danos FAT driver mounts the same image.
|
|
|
|
make-fat-image.py <out.img> <size-MiB> [<dest-path> <host-file>]...
|
|
make-fat-image.py --verify <out.img>
|
|
|
|
Each <dest-path> is a forward-slash path inside the image (e.g.
|
|
"EFI/BOOT/BOOTX64.efi"); intermediate directories are created. Names that do not
|
|
fit 8.3 get a mangled short name plus long-file-name (LFN) entries.
|
|
"""
|
|
|
|
import struct
|
|
import sys
|
|
|
|
SECTOR = 512
|
|
SECTORS_PER_CLUSTER = 1 # 512-byte clusters keep the cluster count high for FAT32
|
|
RESERVED_SECTORS = 32
|
|
NUM_FATS = 2
|
|
CLUSTER_BYTES = SECTOR * SECTORS_PER_CLUSTER
|
|
|
|
END_OF_CHAIN = 0x0FFFFFFF
|
|
BAD_CLUSTER = 0x0FFFFFF7
|
|
|
|
ATTR_ARCHIVE = 0x20
|
|
ATTR_DIRECTORY = 0x10
|
|
ATTR_LONG_NAME = 0x0F
|
|
|
|
VALID_83 = set("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789$%'-_@~!(){}^#& ")
|
|
|
|
|
|
def fat32_geometry(total_sectors):
|
|
"""Solve for the FAT size (sectors per FAT) and cluster count that fit."""
|
|
fat_size = 1
|
|
while True:
|
|
data_sectors = total_sectors - RESERVED_SECTORS - NUM_FATS * fat_size
|
|
cluster_count = data_sectors // SECTORS_PER_CLUSTER
|
|
needed = ((cluster_count + 2) * 4 + SECTOR - 1) // SECTOR
|
|
if needed <= fat_size:
|
|
return fat_size, cluster_count
|
|
fat_size = needed
|
|
|
|
|
|
class Fat32Image:
|
|
def __init__(self, total_sectors):
|
|
self.total_sectors = total_sectors
|
|
self.fat_size, self.cluster_count = fat32_geometry(total_sectors)
|
|
if self.cluster_count < 65525:
|
|
sys.exit(f"error: image too small for FAT32 ({self.cluster_count} clusters "
|
|
f"< 65525); use a larger size")
|
|
self.first_data_sector = RESERVED_SECTORS + NUM_FATS * self.fat_size
|
|
# The FAT, in memory: entry 0 media, entry 1 EOC, entry 2 the root dir.
|
|
self.fat = [0] * (self.cluster_count + 2)
|
|
self.fat[0] = 0x0FFFFFF8
|
|
self.fat[1] = END_OF_CHAIN
|
|
self.fat[2] = END_OF_CHAIN
|
|
self.next_free = 3
|
|
self.cluster_data = {} # cluster number -> bytes (one cluster's worth)
|
|
|
|
def alloc(self):
|
|
cluster = self.next_free
|
|
if cluster >= self.cluster_count + 2:
|
|
sys.exit("error: image out of clusters")
|
|
self.next_free += 1
|
|
self.fat[cluster] = END_OF_CHAIN
|
|
return cluster
|
|
|
|
def store_chain(self, content):
|
|
"""Allocate a cluster chain holding `content` and return its first cluster."""
|
|
length = max(1, (len(content) + CLUSTER_BYTES - 1) // CLUSTER_BYTES)
|
|
clusters = [self.alloc() for _ in range(length)]
|
|
for i in range(length - 1):
|
|
self.fat[clusters[i]] = clusters[i + 1]
|
|
for i, cluster in enumerate(clusters):
|
|
chunk = content[i * CLUSTER_BYTES:(i + 1) * CLUSTER_BYTES]
|
|
self.cluster_data[cluster] = chunk + b"\x00" * (CLUSTER_BYTES - len(chunk))
|
|
return clusters[0]
|
|
|
|
def store_directory(self, first_cluster, entries):
|
|
"""Write directory `entries` (bytes) into `first_cluster`, extending the chain."""
|
|
length = max(1, (len(entries) + CLUSTER_BYTES - 1) // CLUSTER_BYTES)
|
|
clusters = [first_cluster]
|
|
for _ in range(length - 1):
|
|
clusters.append(self.alloc())
|
|
for i in range(len(clusters) - 1):
|
|
self.fat[clusters[i]] = clusters[i + 1]
|
|
for i, cluster in enumerate(clusters):
|
|
chunk = entries[i * CLUSTER_BYTES:(i + 1) * CLUSTER_BYTES]
|
|
self.cluster_data[cluster] = chunk + b"\x00" * (CLUSTER_BYTES - len(chunk))
|
|
|
|
def cluster_sector(self, cluster):
|
|
return self.first_data_sector + (cluster - 2) * SECTORS_PER_CLUSTER
|
|
|
|
def serialize(self):
|
|
image = bytearray(self.total_sectors * SECTOR)
|
|
image[0:SECTOR] = self.boot_sector()
|
|
image[SECTOR:2 * SECTOR] = self.fsinfo_sector()
|
|
image[6 * SECTOR:7 * SECTOR] = self.boot_sector() # backup boot sector
|
|
# Both FATs.
|
|
fat_bytes = b"".join(struct.pack("<I", entry & 0x0FFFFFFF) for entry in self.fat)
|
|
fat_bytes += b"\x00" * (self.fat_size * SECTOR - len(fat_bytes))
|
|
for copy in range(NUM_FATS):
|
|
base = (RESERVED_SECTORS + copy * self.fat_size) * SECTOR
|
|
image[base:base + len(fat_bytes)] = fat_bytes
|
|
# The data region (clusters).
|
|
for cluster, data in self.cluster_data.items():
|
|
base = self.cluster_sector(cluster) * SECTOR
|
|
image[base:base + len(data)] = data
|
|
return bytes(image)
|
|
|
|
def boot_sector(self):
|
|
sector = bytearray(SECTOR)
|
|
# BPB.
|
|
struct.pack_into(
|
|
"<3s8sHBHBHHBHHHII", sector, 0,
|
|
b"\xEB\x58\x90", # jump
|
|
b"MSWIN4.1", # OEM name (widest firmware compatibility)
|
|
SECTOR, # bytes per sector
|
|
SECTORS_PER_CLUSTER, # sectors per cluster
|
|
RESERVED_SECTORS, # reserved sector count
|
|
NUM_FATS, # number of FATs
|
|
0, # root entry count (0 for FAT32)
|
|
0, # total sectors 16 (0 -> use 32)
|
|
0xF8, # media descriptor
|
|
0, # FAT size 16 (0 for FAT32)
|
|
32, # sectors per track
|
|
2, # heads
|
|
0, # hidden sectors
|
|
self.total_sectors, # total sectors 32
|
|
)
|
|
# FAT32 extended BPB (offset 36).
|
|
struct.pack_into(
|
|
"<IHHIHH12sBBBI11s8s", sector, 36,
|
|
self.fat_size, # FAT size 32
|
|
0, # extended flags
|
|
0, # filesystem version
|
|
2, # root cluster
|
|
1, # FSInfo sector
|
|
6, # backup boot sector
|
|
b"\x00" * 12, # reserved
|
|
0x80, # drive number
|
|
0, # reserved
|
|
0x29, # extended boot signature
|
|
0x12345678, # volume id
|
|
b"DANOS ", # volume label
|
|
b"FAT32 ", # filesystem type
|
|
)
|
|
sector[510] = 0x55
|
|
sector[511] = 0xAA
|
|
return bytes(sector)
|
|
|
|
def fsinfo_sector(self):
|
|
sector = bytearray(SECTOR)
|
|
struct.pack_into("<I", sector, 0, 0x41615252) # lead signature
|
|
struct.pack_into("<I", sector, 484, 0x61417272) # struct signature
|
|
free = self.cluster_count - (self.next_free - 2)
|
|
struct.pack_into("<I", sector, 488, free) # free count
|
|
struct.pack_into("<I", sector, 492, self.next_free) # next free hint
|
|
struct.pack_into("<I", sector, 508, 0xAA550000) # trail signature
|
|
return bytes(sector)
|
|
|
|
|
|
def lfn_checksum(short_name):
|
|
checksum = 0
|
|
for byte in short_name:
|
|
checksum = (((checksum & 1) << 7) + (checksum >> 1) + byte) & 0xFF
|
|
return checksum
|
|
|
|
|
|
def short_name_for(name, used):
|
|
"""Return (raw 11-byte 8.3 name, needs_lfn)."""
|
|
if "." in name and not name.startswith("."):
|
|
base, ext = name.rsplit(".", 1)
|
|
else:
|
|
base, ext = name, ""
|
|
upper_base, upper_ext = base.upper(), ext.upper()
|
|
# A name fits 8.3 if it is short enough and uses valid characters. The raw
|
|
# 8.3 entry is always uppercase; if that loses the real name's case (e.g.
|
|
# "init" -> "INIT"), a long-name chain carries the exact name. This matters
|
|
# because the EFI loader *enumerates* /system to build the ramdisk — it gets
|
|
# back whatever the directory stores, so the stored name must be exact, not
|
|
# merely case-insensitively findable.
|
|
fits = (1 <= len(base) <= 8 and len(ext) <= 3
|
|
and all(c in VALID_83 for c in upper_base + upper_ext))
|
|
if fits:
|
|
exact = base == upper_base and ext == upper_ext
|
|
return (upper_base.ljust(8) + upper_ext.ljust(3)).encode("ascii"), not exact
|
|
# Mangle to STEM~N.EXT.
|
|
stem = "".join(c for c in upper_base if c in VALID_83 and c != " ")[:6] or "FILE"
|
|
index = 1
|
|
while True:
|
|
candidate = f"{stem}~{index}".ljust(8)[:8] + upper_ext.ljust(3)[:3]
|
|
raw = candidate.encode("ascii")
|
|
if raw not in used:
|
|
used.add(raw)
|
|
return raw, True
|
|
index += 1
|
|
|
|
|
|
def lfn_entries(name, short_raw):
|
|
checksum = lfn_checksum(short_raw)
|
|
units = list(name.encode("utf-16-le"))
|
|
pairs = [bytes(units[i:i + 2]) for i in range(0, len(units), 2)]
|
|
pairs.append(b"\x00\x00") # null terminator
|
|
while len(pairs) % 13 != 0:
|
|
pairs.append(b"\xff\xff")
|
|
count = len(pairs) // 13
|
|
out = bytearray()
|
|
for sequence in range(count, 0, -1): # stored last-logical-first
|
|
piece = pairs[(sequence - 1) * 13:sequence * 13]
|
|
entry = bytearray(32)
|
|
entry[0] = sequence | (0x40 if sequence == count else 0)
|
|
for i in range(5):
|
|
entry[1 + i * 2:1 + i * 2 + 2] = piece[i]
|
|
entry[11] = ATTR_LONG_NAME
|
|
entry[12] = 0
|
|
entry[13] = checksum
|
|
for i in range(6):
|
|
entry[14 + i * 2:14 + i * 2 + 2] = piece[5 + i]
|
|
entry[26:28] = b"\x00\x00"
|
|
for i in range(2):
|
|
entry[28 + i * 2:28 + i * 2 + 2] = piece[11 + i]
|
|
out += entry
|
|
return bytes(out)
|
|
|
|
|
|
def short_entry(raw11, attributes, cluster, size):
|
|
return struct.pack(
|
|
"<11sBBBHHHHHHHI",
|
|
raw11, attributes, 0, 0, 0, 0, 0,
|
|
(cluster >> 16) & 0xFFFF, 0, 0, cluster & 0xFFFF, size,
|
|
)
|
|
|
|
|
|
def write_directory(image, cluster, children, parent_cluster, is_root):
|
|
"""Recursively lay out a directory: allocate child clusters, build entries."""
|
|
entries = bytearray()
|
|
if not is_root:
|
|
entries += short_entry(b". ", ATTR_DIRECTORY, cluster, 0)
|
|
parent = 0 if parent_cluster == 2 else parent_cluster
|
|
entries += short_entry(b".. ", ATTR_DIRECTORY, parent, 0)
|
|
used_short_names = set()
|
|
for name, child in children.items():
|
|
raw, needs_lfn = short_name_for(name, used_short_names)
|
|
used_short_names.add(raw)
|
|
if child["type"] == "dir":
|
|
child_cluster = image.alloc()
|
|
if needs_lfn:
|
|
entries += lfn_entries(name, raw)
|
|
entries += short_entry(raw, ATTR_DIRECTORY, child_cluster, 0)
|
|
write_directory(image, child_cluster, child["children"], cluster, False)
|
|
else:
|
|
data = child["data"]
|
|
first = image.store_chain(data) if data else 0
|
|
if needs_lfn:
|
|
entries += lfn_entries(name, raw)
|
|
entries += short_entry(raw, ATTR_ARCHIVE, first, len(data))
|
|
image.store_directory(cluster, bytes(entries))
|
|
|
|
|
|
def build_tree(pairs):
|
|
root = {}
|
|
for dest, host in pairs:
|
|
with open(host, "rb") as handle:
|
|
data = handle.read()
|
|
parts = [p for p in dest.replace("\\", "/").split("/") if p]
|
|
node = root
|
|
for part in parts[:-1]:
|
|
node = node.setdefault(part, {"type": "dir", "children": {}})["children"]
|
|
node[parts[-1]] = {"type": "file", "data": data}
|
|
return root
|
|
|
|
|
|
def build(out_path, size_mib, pairs):
|
|
total_sectors = size_mib * 1024 * 1024 // SECTOR
|
|
image = Fat32Image(total_sectors)
|
|
tree = build_tree(pairs)
|
|
write_directory(image, 2, tree, 0, True)
|
|
with open(out_path, "wb") as handle:
|
|
handle.write(image.serialize())
|
|
print(f"make-fat-image: wrote {out_path} "
|
|
f"({size_mib} MiB FAT32, {image.cluster_count} clusters)")
|
|
|
|
|
|
def verify(path):
|
|
with open(path, "rb") as handle:
|
|
data = handle.read()
|
|
if len(data) < SECTOR or data[510] != 0x55 or data[511] != 0xAA:
|
|
sys.exit("verify: missing 0x55AA boot signature")
|
|
bytes_per_sector, sectors_per_cluster = struct.unpack_from("<HB", data, 11)
|
|
reserved, num_fats = struct.unpack_from("<H", data, 14)[0], data[16]
|
|
fat_size_32, root_cluster = struct.unpack_from("<I", data, 36)[0], struct.unpack_from("<I", data, 44)[0]
|
|
total_sectors = struct.unpack_from("<I", data, 32)[0]
|
|
if bytes_per_sector != SECTOR or sectors_per_cluster == 0 or num_fats == 0 or fat_size_32 == 0:
|
|
sys.exit("verify: implausible BPB")
|
|
first_data = reserved + num_fats * fat_size_32
|
|
cluster_count = (total_sectors - first_data) // sectors_per_cluster
|
|
if cluster_count < 65525:
|
|
sys.exit(f"verify: not FAT32 ({cluster_count} clusters)")
|
|
# Resolve EFI/BOOT/BOOTX64.efi through the directory tree to prove it is present.
|
|
if not _resolve(data, ["EFI", "BOOT", "BOOTX64.EFI"], root_cluster,
|
|
reserved, num_fats, fat_size_32, first_data, sectors_per_cluster):
|
|
sys.exit("verify: EFI/BOOT/BOOTX64.efi not found")
|
|
print(f"verify: {path} is FAT32 ({cluster_count} clusters); EFI/BOOT/BOOTX64.efi present")
|
|
|
|
|
|
def _read_fat(data, cluster, reserved):
|
|
offset = reserved * SECTOR + cluster * 4
|
|
return struct.unpack_from("<I", data, offset)[0] & 0x0FFFFFFF
|
|
|
|
|
|
def _resolve(data, parts, cluster, reserved, num_fats, fat_size, first_data, spc):
|
|
for part in parts:
|
|
cluster = _find(data, cluster, part, reserved, first_data, spc)
|
|
if cluster is None:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _find(data, dir_cluster, name, reserved, first_data, spc):
|
|
target = name.upper()
|
|
cluster = dir_cluster
|
|
guard = 0
|
|
while cluster >= 2 and cluster < BAD_CLUSTER and guard < 100000:
|
|
sector = first_data + (cluster - 2) * spc
|
|
for s in range(spc):
|
|
base = (sector + s) * SECTOR
|
|
for i in range(SECTOR // 32):
|
|
entry = data[base + i * 32:base + i * 32 + 32]
|
|
if entry[0] == 0x00:
|
|
return None
|
|
if entry[0] == 0xE5 or (entry[11] & ATTR_LONG_NAME) == ATTR_LONG_NAME:
|
|
continue
|
|
raw = entry[0:11]
|
|
short = (raw[0:8].rstrip().decode("latin1") +
|
|
("." + raw[8:11].rstrip().decode("latin1") if raw[8:11].strip() else "")).upper()
|
|
if short == target:
|
|
return ((entry[20] | (entry[21] << 8)) << 16) | (entry[26] | (entry[27] << 8))
|
|
cluster = _read_fat(data, cluster, reserved)
|
|
guard += 1
|
|
return None
|
|
|
|
|
|
def main(argv):
|
|
if len(argv) == 3 and argv[1] == "--verify":
|
|
verify(argv[2])
|
|
return 0
|
|
if len(argv) < 3 or (len(argv) - 3) % 2 != 0:
|
|
sys.exit("usage: make-fat-image.py <out.img> <size-MiB> [<dest> <host>]...\n"
|
|
" make-fat-image.py --verify <out.img>")
|
|
out_path = argv[1]
|
|
size_mib = int(argv[2])
|
|
rest = argv[3:]
|
|
pairs = [(rest[i], rest[i + 1]) for i in range(0, len(rest), 2)]
|
|
build(out_path, size_mib, pairs)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv))
|