build: release-x86-64 — the flashable hybrid ISO (Etcher/dd + optical), built in-repo
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Wrap the FAT32 boot volume in a hybrid ISO — the flashable danos release image.
|
||||
|
||||
Mirrors tools/make-fat-image.py in spirit: pure Python 3 standard library, no
|
||||
external tools (no xorriso / mkisofs / isohybrid). The output is one file that
|
||||
boots both ways release media is consumed:
|
||||
|
||||
* Flashed raw to a USB stick (Etcher, dd): the ISO's system area carries an
|
||||
MBR whose single partition (type 0xEF, "EFI System") points at the FAT32
|
||||
image embedded in the ISO, so UEFI firmware finds the ESP and runs
|
||||
\\EFI\\BOOT\\BOOTX64.efi off it.
|
||||
* Burned to optical media: an El Torito boot catalog with an EFI platform
|
||||
entry points at the same embedded FAT image.
|
||||
|
||||
The ISO9660 filesystem itself is minimal but valid — a primary volume
|
||||
descriptor, the El Torito boot record, path tables, and a root directory that
|
||||
lists the boot catalog and the FAT image — so inspection tools can open it.
|
||||
|
||||
make-iso-image.py <out.iso> <esp.img>
|
||||
make-iso-image.py --verify <out.iso>
|
||||
|
||||
All timestamp fields are zero ("not specified") so the build is reproducible.
|
||||
"""
|
||||
|
||||
import struct
|
||||
import sys
|
||||
|
||||
ISO_SECTOR = 2048
|
||||
|
||||
# Fixed layout, in ISO sectors (LBA). Sectors 0-15 are the system area (the
|
||||
# hybrid MBR lives in its first 512 bytes); volume descriptors start at 16.
|
||||
PVD_LBA = 16 # primary volume descriptor
|
||||
BOOT_RECORD_LBA = 17 # El Torito boot record volume descriptor
|
||||
TERMINATOR_LBA = 18 # volume descriptor set terminator
|
||||
PATH_TABLE_L_LBA = 19
|
||||
PATH_TABLE_M_LBA = 20
|
||||
ROOT_DIR_LBA = 21 # root directory (one sector holds our four records)
|
||||
CATALOG_LBA = 22 # El Torito boot catalog
|
||||
ESP_LBA = 23 # the embedded FAT32 image starts here
|
||||
|
||||
MBR_PARTITION_TYPE_ESP = 0xEF
|
||||
|
||||
|
||||
def both16(value):
|
||||
"""ISO9660 both-byte-order encoding: little-endian then big-endian."""
|
||||
return struct.pack("<H", value) + struct.pack(">H", value)
|
||||
|
||||
|
||||
def both32(value):
|
||||
return struct.pack("<I", value) + struct.pack(">I", value)
|
||||
|
||||
|
||||
def directory_record(identifier, lba, size, flags):
|
||||
length = 33 + len(identifier)
|
||||
if length % 2:
|
||||
length += 1 # records are padded to even length
|
||||
record = bytearray(length)
|
||||
record[0] = length
|
||||
record[2:10] = both32(lba)
|
||||
record[10:18] = both32(size)
|
||||
# record[18:25] is the recording date; zero = unspecified (reproducible).
|
||||
record[25] = flags # 0x02 = directory
|
||||
record[28:32] = both16(1) # volume sequence number
|
||||
record[32] = len(identifier)
|
||||
record[33:33 + len(identifier)] = identifier
|
||||
return bytes(record)
|
||||
|
||||
|
||||
def primary_volume_descriptor(total_sectors, path_table_size):
|
||||
sector = bytearray(ISO_SECTOR)
|
||||
sector[0] = 1 # type: primary
|
||||
sector[1:6] = b"CD001"
|
||||
sector[6] = 1 # version
|
||||
sector[8:40] = b"DANOS".ljust(32) # system identifier
|
||||
sector[40:72] = b"DANOS".ljust(32) # volume identifier
|
||||
sector[80:88] = both32(total_sectors)
|
||||
sector[120:124] = both16(1) # volume set size
|
||||
sector[124:128] = both16(1) # volume sequence number
|
||||
sector[128:132] = both16(ISO_SECTOR) # logical block size
|
||||
sector[132:140] = both32(path_table_size)
|
||||
sector[140:144] = struct.pack("<I", PATH_TABLE_L_LBA)
|
||||
sector[148:152] = struct.pack(">I", PATH_TABLE_M_LBA)
|
||||
sector[156:190] = directory_record(b"\x00", ROOT_DIR_LBA, ISO_SECTOR, 0x02)
|
||||
sector[190:318] = b" " * 128 # volume set identifier
|
||||
sector[318:446] = b" " * 128 # publisher
|
||||
sector[446:574] = b" " * 128 # data preparer
|
||||
sector[574:702] = b"DANOS MAKE-ISO-IMAGE".ljust(128) # application
|
||||
sector[702:739] = b" " * 37 # copyright file
|
||||
sector[739:776] = b" " * 37 # abstract file
|
||||
sector[776:813] = b" " * 37 # bibliographic file
|
||||
unspecified_date = b"0" * 16 + b"\x00"
|
||||
for offset in (813, 830, 847, 864): # creation/modification/expiry/effective
|
||||
sector[offset:offset + 17] = unspecified_date
|
||||
sector[881] = 1 # file structure version
|
||||
return bytes(sector)
|
||||
|
||||
|
||||
def boot_record_descriptor():
|
||||
sector = bytearray(ISO_SECTOR)
|
||||
sector[0] = 0 # type: boot record
|
||||
sector[1:6] = b"CD001"
|
||||
sector[6] = 1
|
||||
sector[7:39] = b"EL TORITO SPECIFICATION".ljust(32, b"\x00")
|
||||
sector[71:75] = struct.pack("<I", CATALOG_LBA)
|
||||
return bytes(sector)
|
||||
|
||||
|
||||
def terminator_descriptor():
|
||||
sector = bytearray(ISO_SECTOR)
|
||||
sector[0] = 255
|
||||
sector[1:6] = b"CD001"
|
||||
sector[6] = 1
|
||||
return bytes(sector)
|
||||
|
||||
|
||||
def path_table(byte_order):
|
||||
# A single entry: the root directory.
|
||||
return (struct.pack("BB", 1, 0)
|
||||
+ struct.pack(byte_order + "I", ROOT_DIR_LBA)
|
||||
+ struct.pack(byte_order + "H", 1)
|
||||
+ b"\x00\x00") # identifier 0x00 + pad to even
|
||||
|
||||
|
||||
def root_directory(esp_size):
|
||||
# Records must be sorted by identifier; BOOT.CAT < EFI.IMG holds.
|
||||
entries = (directory_record(b"\x00", ROOT_DIR_LBA, ISO_SECTOR, 0x02)
|
||||
+ directory_record(b"\x01", ROOT_DIR_LBA, ISO_SECTOR, 0x02)
|
||||
+ directory_record(b"BOOT.CAT;1", CATALOG_LBA, ISO_SECTOR, 0)
|
||||
+ directory_record(b"EFI.IMG;1", ESP_LBA, esp_size, 0))
|
||||
return entries + b"\x00" * (ISO_SECTOR - len(entries))
|
||||
|
||||
|
||||
def boot_catalog(esp_size):
|
||||
# Validation entry: EFI platform (0xEF), checksummed so its 16-bit words sum
|
||||
# to zero, closed by the 0x55AA key bytes.
|
||||
validation = bytearray(32)
|
||||
validation[0] = 0x01
|
||||
validation[1] = 0xEF
|
||||
validation[4:28] = b"danos".ljust(24, b"\x00")
|
||||
validation[30] = 0x55
|
||||
validation[31] = 0xAA
|
||||
checksum = (-sum(struct.unpack("<16H", validation))) & 0xFFFF
|
||||
validation[28:30] = struct.pack("<H", checksum)
|
||||
# Initial/default entry: bootable, no emulation, image at ESP_LBA. The
|
||||
# sector-count field is 16-bit (units of 512 bytes) so it can't span a large
|
||||
# ESP; UEFI firmware sizes the FAT filesystem from its own BPB, and the
|
||||
# image's boot files sit well inside the capped span regardless.
|
||||
default = bytearray(32)
|
||||
default[0] = 0x88 # bootable
|
||||
default[1] = 0x00 # no emulation
|
||||
sector_count = min(0xFFFF, esp_size // 512)
|
||||
default[6:8] = struct.pack("<H", sector_count)
|
||||
default[8:12] = struct.pack("<I", ESP_LBA)
|
||||
catalog = bytes(validation) + bytes(default)
|
||||
return catalog + b"\x00" * (ISO_SECTOR - len(catalog))
|
||||
|
||||
|
||||
def hybrid_mbr(esp_size):
|
||||
"""The system-area MBR that makes the ISO flashable: one ESP partition."""
|
||||
mbr = bytearray(512)
|
||||
mbr[440:444] = b"dano" # disk signature (fixed: reproducible builds)
|
||||
start_lba = ESP_LBA * (ISO_SECTOR // 512)
|
||||
partition = struct.pack(
|
||||
"<B3sB3sII",
|
||||
0x80, # status: active (harmless; helps picky firmware)
|
||||
b"\xFE\xFF\xFF", # CHS start: maxed out, LBA is authoritative
|
||||
MBR_PARTITION_TYPE_ESP, # type: EFI System
|
||||
b"\xFE\xFF\xFF", # CHS end
|
||||
start_lba,
|
||||
esp_size // 512,
|
||||
)
|
||||
mbr[446:462] = partition
|
||||
mbr[510] = 0x55
|
||||
mbr[511] = 0xAA
|
||||
return bytes(mbr)
|
||||
|
||||
|
||||
def build(out_path, esp_path):
|
||||
with open(esp_path, "rb") as handle:
|
||||
esp = handle.read()
|
||||
if len(esp) % ISO_SECTOR:
|
||||
esp += b"\x00" * (ISO_SECTOR - len(esp) % ISO_SECTOR)
|
||||
esp_sectors = len(esp) // ISO_SECTOR
|
||||
total_sectors = ESP_LBA + esp_sectors
|
||||
|
||||
table_l = path_table("<")
|
||||
image = bytearray(total_sectors * ISO_SECTOR)
|
||||
image[0:512] = hybrid_mbr(len(esp))
|
||||
image[PVD_LBA * ISO_SECTOR:(PVD_LBA + 1) * ISO_SECTOR] = \
|
||||
primary_volume_descriptor(total_sectors, len(table_l))
|
||||
image[BOOT_RECORD_LBA * ISO_SECTOR:(BOOT_RECORD_LBA + 1) * ISO_SECTOR] = \
|
||||
boot_record_descriptor()
|
||||
image[TERMINATOR_LBA * ISO_SECTOR:(TERMINATOR_LBA + 1) * ISO_SECTOR] = \
|
||||
terminator_descriptor()
|
||||
image[PATH_TABLE_L_LBA * ISO_SECTOR:PATH_TABLE_L_LBA * ISO_SECTOR + len(table_l)] = table_l
|
||||
table_m = path_table(">")
|
||||
image[PATH_TABLE_M_LBA * ISO_SECTOR:PATH_TABLE_M_LBA * ISO_SECTOR + len(table_m)] = table_m
|
||||
image[ROOT_DIR_LBA * ISO_SECTOR:(ROOT_DIR_LBA + 1) * ISO_SECTOR] = root_directory(len(esp))
|
||||
image[CATALOG_LBA * ISO_SECTOR:(CATALOG_LBA + 1) * ISO_SECTOR] = boot_catalog(len(esp))
|
||||
image[ESP_LBA * ISO_SECTOR:] = esp
|
||||
|
||||
with open(out_path, "wb") as handle:
|
||||
handle.write(image)
|
||||
print(f"make-iso-image: wrote {out_path} "
|
||||
f"({total_sectors * ISO_SECTOR // (1024 * 1024)} MiB hybrid ISO, "
|
||||
f"ESP at LBA {ESP_LBA}, {esp_sectors} sectors)")
|
||||
|
||||
|
||||
def verify(path):
|
||||
with open(path, "rb") as handle:
|
||||
data = handle.read()
|
||||
# The hybrid MBR (the Etcher/dd boot path).
|
||||
if data[510] != 0x55 or data[511] != 0xAA:
|
||||
sys.exit("verify: missing MBR 0x55AA signature")
|
||||
status, _, part_type, _, part_start, part_sectors = \
|
||||
struct.unpack_from("<B3sB3sII", data, 446)
|
||||
if part_type != MBR_PARTITION_TYPE_ESP:
|
||||
sys.exit(f"verify: MBR partition type 0x{part_type:02X}, expected 0xEF (ESP)")
|
||||
# The ISO9660 descriptors (the optical boot path).
|
||||
if data[PVD_LBA * ISO_SECTOR + 1:PVD_LBA * ISO_SECTOR + 6] != b"CD001":
|
||||
sys.exit("verify: no primary volume descriptor")
|
||||
boot_record = data[BOOT_RECORD_LBA * ISO_SECTOR:(BOOT_RECORD_LBA + 1) * ISO_SECTOR]
|
||||
if not boot_record.startswith(b"\x00CD001") or \
|
||||
not boot_record[7:30].startswith(b"EL TORITO SPECIFICATION"):
|
||||
sys.exit("verify: no El Torito boot record")
|
||||
catalog_lba = struct.unpack_from("<I", boot_record, 71)[0]
|
||||
catalog = data[catalog_lba * ISO_SECTOR:(catalog_lba + 1) * ISO_SECTOR]
|
||||
if catalog[0] != 0x01 or catalog[1] != 0xEF or catalog[30:32] != b"\x55\xAA":
|
||||
sys.exit("verify: boot catalog validation entry is not an EFI entry")
|
||||
if sum(struct.unpack("<16H", catalog[0:32])) & 0xFFFF != 0:
|
||||
sys.exit("verify: boot catalog validation checksum is wrong")
|
||||
if catalog[32] != 0x88:
|
||||
sys.exit("verify: default catalog entry is not bootable")
|
||||
boot_lba = struct.unpack_from("<I", catalog, 40)[0]
|
||||
# Both paths must agree on where the ESP lives, and it must be a FAT32 image.
|
||||
if boot_lba * (ISO_SECTOR // 512) != part_start:
|
||||
sys.exit(f"verify: catalog boot image (LBA {boot_lba}) and MBR partition "
|
||||
f"(sector {part_start}) disagree")
|
||||
esp = data[boot_lba * ISO_SECTOR:]
|
||||
if len(esp) < part_sectors * 512:
|
||||
sys.exit("verify: MBR partition extends past the end of the file")
|
||||
if esp[510] != 0x55 or esp[511] != 0xAA or esp[82:90] != b"FAT32 ":
|
||||
sys.exit("verify: embedded image is not a FAT32 boot volume")
|
||||
print(f"verify: {path} is a hybrid ISO — MBR ESP partition (sector {part_start}, "
|
||||
f"{part_sectors} sectors, active={status == 0x80}) and El Torito EFI entry "
|
||||
f"both point at the embedded FAT32 image")
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) == 3 and argv[1] == "--verify":
|
||||
verify(argv[2])
|
||||
return 0
|
||||
if len(argv) != 3:
|
||||
sys.exit("usage: make-iso-image.py <out.iso> <esp.img>\n"
|
||||
" make-iso-image.py --verify <out.iso>")
|
||||
build(argv[1], argv[2])
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
Reference in New Issue
Block a user