Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

xtask/src/image_formats.rs

//! The three on-disk formats the appliance ISO is made of, written by hand.
//!
//! `cpio` (newc) for the initramfs, `FAT16` for the EFI System Partition, and
//! `ISO-9660` + El Torito for the ISO itself. Nothing here spawns `cpio`,
//! `mkfs.vfat`, `mtools`, `xorriso`, `genisoimage` or `grub-mkrescue`.
//!
//! # Why by hand
//!
//! Two reasons, and only the second is about dependencies.
//!
//! **`xtask` has none, deliberately** (`xtask/Cargo.toml`): a workspace member
//! takes part in resolver-2 feature unification, so a crate added here could
//! turn a feature on in a crate the appliance links, and the dependency guard's
//! job is not to be the thing it guards against.
//!
//! **This IS a twin, all three formats, and here is the whole pointer.** The
//! house rule says reuse; this file does not, so it owes an exact account of what it is a
//! copy of and what stops it being an edge. Measured 2026-08-07 — every one of
//! the three has a pure-Rust equivalent in `../edda`:
//!
//! | this file            | the fleet's own                  | reachable from gunnar? |
//! |----------------------|----------------------------------|------------------------|
//! | [`cpio_newc`]        | `korp_installer::initramfs`      | NO — `publish = false` |
//! | [`fat16`]            | `korp_installer::esp::build_esp_image` | NO — same crate  |
//! | [`iso9660_eltorito`] | `nornir_build_thing::IsoWriter`  | by REGISTRY, yes       |
//!
//! The first two have no registry route at all: `korp-installer` is
//! `publish = false`, so a path dependency into `../edda` is the ONLY way to reach
//! them. The appliance design argues against sibling path deps on the
//! principle that "Gunnar is meant to build standalone from a fresh clone" —
//! stated there about Skidbladnir specifically, and already suspended once by the
//! `[patch.crates-io]` pointing `nornir-git` at `../edda`, so treat it as the
//! standing preference it is rather than an absolute. The third IS a published
//! crate gunnar already uses from the registry (`gunnar-cli` deps
//! `nornir-build-thing`), so only the ISO-9660 leg is a candidate on that axis.
//!
//! What is absolute is the rule above, and it is the one that decides this file:
//! `xtask` is a WORKSPACE MEMBER, so anything it links unifies features with the
//! crates the appliance links. That is precisely why `installtest` — which is NOT
//! a member (see `Cargo.toml`'s `members`) — is free to path-dep `tunnr-vm` and
//! boot through `tunnr_vm::boot_test`, while nothing in `xtask` may.
//!
//! What would actually retire this file is therefore a design change, not a
//! refactor: move the ISO leg behind `crates/gunnar-build-thing` (which already
//! deps `nornir-build-thing` from the registry) and have `xtask` INVOKE it as a
//! tool rather than link it — which is the same "invoked as a tool, never as a
//! dependency" rule that put `xtask` at zero deps to begin with. See the twin
//! block in `.nornir/build-variants.toml` under the `gunnar-appliance-iso` cell.
//!
//! Reuse is honoured where it can be paid for in an *artefact* rather than an
//! edge: the first-stage UEFI loader is vendored as
//! `assets/boot/korp-boot-bootx64.efi` (see its README), because that one is six
//! hundred lines of `unsafe` and is already proven booting.
//!
//! **The tools would have to be preflighted anyway.** `mkfs.vfat` is in
//! `dosfstools`, `xorriso` in its own package, and neither is installed on
//! the desktop. An ISO build that fails on a fresh machine with "command not found" is
//! not a build; and `cargo xtask preflight` exists precisely so a missing tool
//! is a named failure rather than a skipped test.
//!
//! # What is asserted, and where
//!
//! Every writer here is a pure `Vec<u8>` producer, so the tests in
//! `xtask/tests/image_formats.rs` read the bytes back out: a cpio header
//! parsed field by field, a FAT directory entry located through the BPB, an
//! El Torito catalog checksum recomputed. The ISO is then booted, which is the
//! only assertion that covers all three at once.

use std::fmt::Write as _;

// ════════════════════════════════════════════════════════════════════════════
// cpio, `newc` (SVR4 without CRC): the initramfs
// ════════════════════════════════════════════════════════════════════════════

/// One member of the archive.
#[derive(Debug, Clone)]
pub struct CpioEntry {
    /// The path **without** a leading slash: `init`, `data`, `dev/console`.
    /// A leading slash makes the kernel's unpacker create a directory called
    /// `""` and is the single most common way to produce an initramfs that
    /// unpacks to nothing.
    pub name: String,
    /// `st_mode`: the file type bits **and** the permission bits.
    pub mode: u32,
    /// The file's contents. Empty for directories and device nodes.
    pub data: Vec<u8>,
    /// `(major, minor)` for a character device; `None` otherwise.
    pub rdev: Option<(u32, u32)>,
}

/// `S_IFREG`
pub const MODE_FILE: u32 = 0o100_000;
/// `S_IFDIR`
pub const MODE_DIR: u32 = 0o040_000;
/// `S_IFCHR`
pub const MODE_CHAR: u32 = 0o020_000;

impl CpioEntry {
    pub fn file(name: &str, mode: u32, data: Vec<u8>) -> CpioEntry {
        CpioEntry {
            name: name.to_string(),
            mode: MODE_FILE | mode,
            data,
            rdev: None,
        }
    }

    pub fn dir(name: &str, mode: u32) -> CpioEntry {
        CpioEntry {
            name: name.to_string(),
            mode: MODE_DIR | mode,
            data: Vec::new(),
            rdev: None,
        }
    }

    /// A character device node: `/dev/console` and `/dev/null`.
    ///
    /// `/dev/console` is not optional and its absence is silent. The kernel
    /// opens it to give `init` its stdin, stdout and stderr
    /// (`init/main.c`, `console_on_rootfs`); if it is missing, init starts with
    /// **file descriptors 0, 1 and 2 closed**, the boot log says only
    /// "Warning: unable to open an initial console", and the first socket the
    /// server opens becomes fd 0. Every `tracing` line then goes into that
    /// socket.
    pub fn chardev(name: &str, mode: u32, major: u32, minor: u32) -> CpioEntry {
        CpioEntry {
            name: name.to_string(),
            mode: MODE_CHAR | mode,
            data: Vec::new(),
            rdev: Some((major, minor)),
        }
    }
}

/// Encode a `newc` archive, ending with the `TRAILER!!!` record.
///
/// Deterministic: `mtime` 0, `uid`/`gid` 0, inode numbers assigned in order.
/// Two runs over the same inputs produce byte-identical output, which is what
/// makes the ISO's own hash meaningful.
pub fn cpio_newc(entries: &[CpioEntry]) -> Vec<u8> {
    let mut out = Vec::new();
    for (i, e) in entries.iter().enumerate() {
        let (major, minor) = e.rdev.unwrap_or((0, 0));
        write_cpio_header(
            &mut out,
            i as u32 + 1,
            e.mode,
            e.data.len() as u32,
            major,
            minor,
            &e.name,
        );
        out.extend_from_slice(&e.data);
        pad4(&mut out);
    }
    // The trailer's `nlink` is 1 and its name is exactly `TRAILER!!!`. A
    // trailer with nlink 0 is skipped by some unpackers and the archive then
    // appears truncated.
    write_cpio_header(&mut out, 0, 0, 0, 0, 0, "TRAILER!!!");
    out
}

#[allow(clippy::too_many_arguments)]
fn write_cpio_header(
    out: &mut Vec<u8>,
    ino: u32,
    mode: u32,
    filesize: u32,
    rdevmajor: u32,
    rdevminor: u32,
    name: &str,
) {
    let namesize = name.len() as u32 + 1; // the NUL counts
    let mut header = String::with_capacity(110);
    header.push_str("070701");
    for field in [
        ino, mode, 0, // uid
        0, // gid
        1, // nlink
        0, // mtime
        filesize, 0, // devmajor
        0, // devminor
        rdevmajor, rdevminor, namesize, 0, // check, always zero in newc
    ] {
        let _ = write!(header, "{field:08X}");
    }
    debug_assert_eq!(header.len(), 110, "a newc header is exactly 110 bytes");
    out.extend_from_slice(header.as_bytes());
    out.extend_from_slice(name.as_bytes());
    out.push(0);
    // The header + name is padded to four; the DATA is padded separately by the
    // caller. Conflating the two produces an archive whose second member is
    // misaligned and whose first member looks perfect.
    pad4(out);
}

fn pad4(out: &mut Vec<u8>) {
    while out.len() % 4 != 0 {
        out.push(0);
    }
}

// ════════════════════════════════════════════════════════════════════════════
// FAT16: the EFI System Partition
// ════════════════════════════════════════════════════════════════════════════

/// A file to place in the FAT volume, by its full path within it.
#[derive(Debug, Clone)]
pub struct FatFile {
    /// `EFI/BOOT/BOOTX64.EFI`, `vmlinuz`, `korp/cmdline`. Every component must
    /// fit 8.3. This writer emits no long-file-name entries, because every
    /// name the appliance needs already fits and an LFN checksum is a second
    /// way to be wrong.
    pub path: String,
    pub bytes: Vec<u8>,
}

const SECTOR: usize = 512;
const ROOT_ENTRIES: usize = 512;
const RESERVED_SECTORS: usize = 1;
const NUM_FATS: usize = 2;

/// Format a FAT16 volume holding `files`, sized to fit them.
///
/// # Why FAT16 and not FAT32
///
/// The volume is tens of megabytes. FAT32's minimum is 65 525 clusters, which
/// at the 512-byte sector this uses means a 32 MiB floor before the first file
/// and FAT16's ceiling is 65 524 clusters, so at 32 KiB clusters it reaches
/// 2 GiB. FAT16 covers the entire range this can ever need with a fixed-size
/// root directory and no `FSInfo` sector to keep consistent.
///
/// # The cluster-count trap
///
/// FAT type is **not** declared anywhere the driver trusts; it is *derived*
/// from the cluster count (`< 4085` is FAT12, `< 65525` is FAT16). The
/// `"FAT16   "` string at offset 0x36 is documentation, not a declaration. So a
/// small volume with a large cluster size silently becomes FAT12 and every
/// entry in the FAT is then read as twelve bits. [`fat16`] therefore searches
/// for a cluster size that lands the count strictly inside FAT16's range and
/// fails loudly if none does.
pub fn fat16(files: &[FatFile], volume_label: &str) -> Result<Vec<u8>, String> {
    // Directories are one cluster each; count them so the sizing is not an
    // underestimate that only fails on a full volume.
    let mut dirs: Vec<String> = Vec::new();
    for f in files {
        let mut prefix = String::new();
        let parts: Vec<&str> = f.path.split('/').collect();
        for component in &parts[..parts.len().saturating_sub(1)] {
            if !prefix.is_empty() {
                prefix.push('/');
            }
            prefix.push_str(component);
            if !dirs.contains(&prefix) {
                dirs.push(prefix.clone());
            }
        }
    }

    let payload: usize = files.iter().map(|f| f.bytes.len()).sum();

    // Try each legal cluster size, smallest first: less slack, and a cluster
    // count that stays comfortably inside FAT16's range.
    for spc in [4usize, 8, 16, 32, 64] {
        let cluster = spc * SECTOR;
        let mut data_clusters = dirs.len(); // one cluster per directory
        for f in files {
            data_clusters += f.bytes.len().div_ceil(cluster).max(1);
        }
        // Slack: a spare cluster per file for the rounding above, plus a floor
        // so a nearly-empty volume is still a legal FAT16.
        let data_clusters = (data_clusters + files.len() + 16).max(4200);
        if data_clusters > 65_524 {
            continue;
        }
        if data_clusters < 4085 {
            continue;
        }
        let fat_bytes = (data_clusters + 2) * 2;
        let sectors_per_fat = fat_bytes.div_ceil(SECTOR);
        let root_sectors = (ROOT_ENTRIES * 32) / SECTOR;
        let total_sectors =
            RESERVED_SECTORS + NUM_FATS * sectors_per_fat + root_sectors + data_clusters * spc;
        let _ = payload;
        return build_fat16(
            files,
            &dirs,
            volume_label,
            spc,
            sectors_per_fat,
            root_sectors,
            data_clusters,
            total_sectors,
        );
    }
    Err(format!(
        "no FAT16 cluster size fits {} files totalling {} bytes: the volume is either under \
         4085 clusters (which is FAT12 however the boot sector is labelled) or over 65524 \
         (which is FAT32)",
        files.len(),
        payload
    ))
}

#[allow(clippy::too_many_arguments)]
fn build_fat16(
    files: &[FatFile],
    dirs: &[String],
    volume_label: &str,
    spc: usize,
    sectors_per_fat: usize,
    root_sectors: usize,
    data_clusters: usize,
    total_sectors: usize,
) -> Result<Vec<u8>, String> {
    let cluster_bytes = spc * SECTOR;
    let fat_start = RESERVED_SECTORS;
    let root_start = fat_start + NUM_FATS * sectors_per_fat;
    let data_start = root_start + root_sectors;

    let mut image = vec![0u8; total_sectors * SECTOR];

    // ── boot sector / BPB ───────────────────────────────────────────────────
    {
        let bs = &mut image[..SECTOR];
        // A jump the firmware never executes, but a FAT driver that sanity
        // checks the first byte rejects a volume that does not start with
        // 0xEB or 0xE9, and EDK2 is one of them.
        bs[0..3].copy_from_slice(&[0xEB, 0x3C, 0x90]);
        bs[3..11].copy_from_slice(b"MSWIN4.1");
        bs[11..13].copy_from_slice(&(SECTOR as u16).to_le_bytes());
        bs[13] = spc as u8;
        bs[14..16].copy_from_slice(&(RESERVED_SECTORS as u16).to_le_bytes());
        bs[16] = NUM_FATS as u8;
        bs[17..19].copy_from_slice(&(ROOT_ENTRIES as u16).to_le_bytes());
        // total_sectors_16 stays 0 whenever the count does not fit, and then
        // total_sectors_32 carries it. Setting both is legal; setting the
        // 16-bit one to a truncated value is the failure this avoids.
        if total_sectors <= u16::MAX as usize {
            bs[19..21].copy_from_slice(&(total_sectors as u16).to_le_bytes());
        }
        bs[21] = 0xF8; // fixed disk
        bs[22..24].copy_from_slice(&(sectors_per_fat as u16).to_le_bytes());
        bs[24..26].copy_from_slice(&32u16.to_le_bytes()); // sectors per track
        bs[26..28].copy_from_slice(&8u16.to_le_bytes()); // heads
        bs[28..32].copy_from_slice(&0u32.to_le_bytes()); // hidden sectors
        bs[32..36].copy_from_slice(&(total_sectors as u32).to_le_bytes());
        bs[36] = 0x80; // drive number
        bs[38] = 0x29; // extended boot signature: the three fields below are valid
        bs[39..43].copy_from_slice(&0x4755_4E4Au32.to_le_bytes()); // "GUNJ"
        let mut label = [b' '; 11];
        for (i, b) in volume_label.bytes().take(11).enumerate() {
            label[i] = b.to_ascii_uppercase();
        }
        bs[43..54].copy_from_slice(&label);
        bs[54..62].copy_from_slice(b"FAT16   ");
        bs[510] = 0x55;
        bs[511] = 0xAA;
    }

    // ── allocate clusters ───────────────────────────────────────────────────
    //
    // A directory gets one cluster; a file gets as many as it needs. `next` is
    // the next free cluster number, starting at 2 because 0 and 1 are the two
    // reserved FAT entries and are not addressable.
    let mut fat: Vec<u16> = vec![0; data_clusters + 2];
    fat[0] = 0xFFF8;
    fat[1] = 0xFFFF;
    let mut next: u16 = 2;

    let mut alloc = |count: usize, fat: &mut Vec<u16>| -> Result<u16, String> {
        let first = next;
        for i in 0..count {
            let this = next;
            if (this as usize) >= fat.len() {
                return Err("the FAT ran out of clusters; the sizing above is wrong".into());
            }
            next += 1;
            fat[this as usize] = if i + 1 == count { 0xFFFF } else { next };
        }
        Ok(first)
    };

    // Directories, parents before children, so a child's entry can be written
    // into a parent whose cluster is already known.
    let mut dir_cluster: Vec<(String, u16)> = Vec::new();
    let mut sorted_dirs: Vec<&String> = dirs.iter().collect();
    sorted_dirs.sort_by_key(|d| d.matches('/').count());
    for d in sorted_dirs {
        let c = alloc(1, &mut fat)?;
        dir_cluster.push((d.clone(), c));
    }

    // Files.
    let mut file_cluster: Vec<u16> = Vec::new();
    for f in files {
        let need = f.bytes.len().div_ceil(cluster_bytes).max(1);
        file_cluster.push(alloc(need, &mut fat)?);
    }

    // ── directory contents ──────────────────────────────────────────────────
    //
    // Built in memory first: a subdirectory's `.` and `..` entries have to be
    // written before any child entry, and appending to a `Vec` keeps that
    // ordering obvious.
    let mut root_entries: Vec<u8> = Vec::new();
    let mut sub_entries: Vec<(u16, Vec<u8>)> = Vec::new();

    for (path, cluster) in &dir_cluster {
        let (parent, name) = match path.rsplit_once('/') {
            Some((p, n)) => (Some(p.to_string()), n.to_string()),
            None => (None, path.clone()),
        };
        let mut body = Vec::new();
        body.extend_from_slice(&dir_entry(".", *cluster, 0, true));
        let parent_cluster = match &parent {
            None => 0, // `..` of a first-level directory points at the root, and
            // the root's cluster number is written as 0
            Some(p) => dir_cluster
                .iter()
                .find(|(d, _)| d == p)
                .map(|(_, c)| *c)
                .ok_or_else(|| format!("parent directory {p:?} was never allocated"))?,
        };
        body.extend_from_slice(&dir_entry("..", parent_cluster, 0, true));
        sub_entries.push((*cluster, body));

        let entry = dir_entry(&name, *cluster, 0, true);
        match &parent {
            None => root_entries.extend_from_slice(&entry),
            Some(p) => {
                let pc = dir_cluster
                    .iter()
                    .find(|(d, _)| d == p)
                    .map(|(_, c)| *c)
                    .expect("checked above");
                push_into(&mut sub_entries, pc, &entry);
            }
        }
    }

    for (i, f) in files.iter().enumerate() {
        let (parent, name) = match f.path.rsplit_once('/') {
            Some((p, n)) => (Some(p.to_string()), n.to_string()),
            None => (None, f.path.clone()),
        };
        let entry = dir_entry(&name, file_cluster[i], f.bytes.len() as u32, false);
        match &parent {
            None => root_entries.extend_from_slice(&entry),
            Some(p) => {
                let pc = dir_cluster
                    .iter()
                    .find(|(d, _)| d == p)
                    .map(|(_, c)| *c)
                    .ok_or_else(|| format!("parent directory {p:?} was never allocated"))?;
                push_into(&mut sub_entries, pc, &entry);
            }
        }
    }

    if root_entries.len() > ROOT_ENTRIES * 32 {
        return Err(format!(
            "{} root-directory entries do not fit in the fixed {ROOT_ENTRIES}-entry root of a \
             FAT16 volume",
            root_entries.len() / 32
        ));
    }

    // ── write it all out ────────────────────────────────────────────────────
    for f in 0..NUM_FATS {
        let off = (fat_start + f * sectors_per_fat) * SECTOR;
        for (i, entry) in fat.iter().enumerate() {
            let at = off + i * 2;
            if at + 2 <= image.len() {
                image[at..at + 2].copy_from_slice(&entry.to_le_bytes());
            }
        }
    }
    image[root_start * SECTOR..root_start * SECTOR + root_entries.len()]
        .copy_from_slice(&root_entries);

    let cluster_offset = |c: u16| -> usize { (data_start + (c as usize - 2) * spc) * SECTOR };

    for (cluster, body) in &sub_entries {
        if body.len() > cluster_bytes {
            return Err(format!(
                "a subdirectory needs {} bytes and this writer gives every directory one \
                 {cluster_bytes}-byte cluster",
                body.len()
            ));
        }
        let at = cluster_offset(*cluster);
        image[at..at + body.len()].copy_from_slice(body);
    }
    for (i, f) in files.iter().enumerate() {
        let at = cluster_offset(file_cluster[i]);
        image[at..at + f.bytes.len()].copy_from_slice(&f.bytes);
    }

    Ok(image)
}

fn push_into(dirs: &mut [(u16, Vec<u8>)], cluster: u16, entry: &[u8]) {
    if let Some((_, body)) = dirs.iter_mut().find(|(c, _)| *c == cluster) {
        body.extend_from_slice(entry);
    }
}

/// One 32-byte 8.3 directory entry.
fn dir_entry(name: &str, cluster: u16, size: u32, is_dir: bool) -> [u8; 32] {
    let mut e = [0u8; 32];
    let mut field = [b' '; 11];
    if name == "." {
        field[0] = b'.';
    } else if name == ".." {
        field[0] = b'.';
        field[1] = b'.';
    } else {
        let (base, ext) = match name.rsplit_once('.') {
            Some((b, x)) => (b, x),
            None => (name, ""),
        };
        for (i, b) in base.bytes().take(8).enumerate() {
            field[i] = b.to_ascii_uppercase();
        }
        for (i, b) in ext.bytes().take(3).enumerate() {
            field[8 + i] = b.to_ascii_uppercase();
        }
    }
    e[0..11].copy_from_slice(&field);
    e[11] = if is_dir { 0x10 } else { 0x20 };
    // 1980-01-01 00:00:00, the FAT epoch. A zero date is legal and some drivers
    // display it as garbage; the epoch is the smallest honest value.
    e[22..24].copy_from_slice(&0u16.to_le_bytes());
    e[24..26].copy_from_slice(&0x0021u16.to_le_bytes());
    e[26..28].copy_from_slice(&cluster.to_le_bytes());
    e[28..32].copy_from_slice(&size.to_le_bytes());
    e
}

// ════════════════════════════════════════════════════════════════════════════
// ISO-9660 + El Torito
// ════════════════════════════════════════════════════════════════════════════

/// 2 048 bytes, the ISO-9660 logical block, not the 512-byte disk sector the
/// El Torito catalog counts in. Conflating the two is how a boot image ends up
/// four times too small.
pub const ISO_BLOCK: usize = 2048;

/// The largest EFI boot image an El Torito catalog can describe.
///
/// `sector_count` in a boot catalog entry is a **u16 of 512-byte sectors**, and
/// EDK2's `PartitionDxe` uses exactly that to size the block device it exposes
/// for the boot image (`ElTorito.c`: `Size = Catalog->Boot.SectorCount`,
/// `SubBlockSize = 512`). A larger image is not truncated at boot with an
/// error; the firmware exposes a partition that stops in the middle of the FAT,
/// and the failure surfaces as "no bootable device".
pub const ELTORITO_MAX_BOOT_IMAGE: usize = 65_535 * 512;

/// An ISO-9660 image with a single El Torito EFI boot entry.
///
/// One file in the root directory, the ESP itself, so the ISO is
/// self-describing: `mount -o loop` shows `EFI.IMG`, and the boot catalog
/// points at the same extent rather than at a hidden one.
pub fn iso9660_eltorito(
    volume_id: &str,
    boot_image_name: &str,
    boot_image: &[u8],
) -> Result<Vec<u8>, String> {
    if boot_image.len() > ELTORITO_MAX_BOOT_IMAGE {
        return Err(format!(
            "the EFI boot image is {} bytes and El Torito's sector_count is a u16 of 512-byte \
             sectors, so {ELTORITO_MAX_BOOT_IMAGE} is the ceiling. Shrink the initramfs \
             (compress it) or move to a GPT-partitioned hybrid image.",
            boot_image.len()
        ));
    }

    // ── the layout, in 2 048-byte blocks ────────────────────────────────────
    const PVD_LBA: u32 = 16;
    const BOOT_RECORD_LBA: u32 = 17;
    const TERMINATOR_LBA: u32 = 18;
    const PATH_TABLE_L_LBA: u32 = 19;
    const PATH_TABLE_M_LBA: u32 = 20;
    const ROOT_DIR_LBA: u32 = 21;
    const CATALOG_LBA: u32 = 22;
    const BOOT_IMAGE_LBA: u32 = 23;

    let boot_blocks = boot_image.len().div_ceil(ISO_BLOCK) as u32;
    let total_blocks = BOOT_IMAGE_LBA + boot_blocks;

    let mut iso = vec![0u8; total_blocks as usize * ISO_BLOCK];
    let block = |iso: &mut Vec<u8>, lba: u32| -> std::ops::Range<usize> {
        let start = lba as usize * ISO_BLOCK;
        let _ = iso;
        start..start + ISO_BLOCK
    };

    // ── root directory ──────────────────────────────────────────────────────
    let iso_name = format!("{};1", boot_image_name.to_ascii_uppercase());
    let mut root = Vec::new();
    root.extend_from_slice(&iso_dir_record(ROOT_DIR_LBA, ISO_BLOCK as u32, true, &[0]));
    root.extend_from_slice(&iso_dir_record(ROOT_DIR_LBA, ISO_BLOCK as u32, true, &[1]));
    root.extend_from_slice(&iso_dir_record(
        BOOT_IMAGE_LBA,
        boot_image.len() as u32,
        false,
        iso_name.as_bytes(),
    ));
    let root_len = root.len();
    let r = block(&mut iso, ROOT_DIR_LBA);
    iso[r.start..r.start + root_len].copy_from_slice(&root);

    // ── primary volume descriptor ───────────────────────────────────────────
    let mut pvd = vec![0u8; ISO_BLOCK];
    pvd[0] = 1;
    pvd[1..6].copy_from_slice(b"CD001");
    pvd[6] = 1;
    fill_achars(&mut pvd[8..40], "LINUX");
    fill_dchars(&mut pvd[40..72], volume_id);
    both_u32(&mut pvd[80..88], total_blocks);
    both_u16(&mut pvd[120..124], 1); // volume set size
    both_u16(&mut pvd[124..128], 1); // volume sequence number
    both_u16(&mut pvd[128..132], ISO_BLOCK as u16);
    // The path table exists because the standard requires it. Every EFI
    // firmware this targets walks directory records instead, but a missing
    // path table is the kind of omission a stricter driver rejects and a
    // permissive one ignores, and "works on the firmware I tested" is not a
    // property, it is a coincidence.
    let path_table = iso_path_table(ROOT_DIR_LBA, true);
    let path_table_m = iso_path_table(ROOT_DIR_LBA, false);
    both_u32(&mut pvd[132..140], path_table.len() as u32);
    pvd[140..144].copy_from_slice(&PATH_TABLE_L_LBA.to_le_bytes());
    pvd[148..152].copy_from_slice(&PATH_TABLE_M_LBA.to_be_bytes());
    let root_record = iso_dir_record(ROOT_DIR_LBA, ISO_BLOCK as u32, true, &[0]);
    pvd[156..156 + 34].copy_from_slice(&root_record);
    fill_dchars(&mut pvd[190..318], "GUNNAR");
    fill_achars(&mut pvd[318..446], "GUNNAR");
    fill_achars(&mut pvd[446..574], "CARGO XTASK ISO");
    fill_achars(&mut pvd[574..702], "GUNNAR APPLIANCE");
    for range in [702..739, 739..776, 776..813] {
        fill_dchars(&mut pvd[range], "");
    }
    for at in [813usize, 830, 847, 864] {
        pvd[at..at + 17].copy_from_slice(b"0000000000000000\0");
    }
    pvd[881] = 1; // file structure version
    let p = block(&mut iso, PVD_LBA);
    iso[p.clone()].copy_from_slice(&pvd);

    // ── El Torito boot record volume descriptor ─────────────────────────────
    let mut br = vec![0u8; ISO_BLOCK];
    br[0] = 0;
    br[1..6].copy_from_slice(b"CD001");
    br[6] = 1;
    br[7..7 + 23].copy_from_slice(b"EL TORITO SPECIFICATION");
    br[71..75].copy_from_slice(&CATALOG_LBA.to_le_bytes());
    let b = block(&mut iso, BOOT_RECORD_LBA);
    iso[b.clone()].copy_from_slice(&br);

    // ── terminator ──────────────────────────────────────────────────────────
    let mut term = vec![0u8; ISO_BLOCK];
    term[0] = 255;
    term[1..6].copy_from_slice(b"CD001");
    term[6] = 1;
    let t = block(&mut iso, TERMINATOR_LBA);
    iso[t.clone()].copy_from_slice(&term);

    // ── path tables ─────────────────────────────────────────────────────────
    let l = block(&mut iso, PATH_TABLE_L_LBA);
    iso[l.start..l.start + path_table.len()].copy_from_slice(&path_table);
    let m = block(&mut iso, PATH_TABLE_M_LBA);
    iso[m.start..m.start + path_table_m.len()].copy_from_slice(&path_table_m);

    // ── boot catalog ────────────────────────────────────────────────────────
    let catalog = eltorito_catalog(BOOT_IMAGE_LBA, boot_image.len());
    let c = block(&mut iso, CATALOG_LBA);
    iso[c.start..c.start + catalog.len()].copy_from_slice(&catalog);

    // ── the boot image itself ───────────────────────────────────────────────
    let at = BOOT_IMAGE_LBA as usize * ISO_BLOCK;
    iso[at..at + boot_image.len()].copy_from_slice(boot_image);

    Ok(iso)
}

/// The 64-byte El Torito boot catalog: a validation entry and one EFI entry.
///
/// Exposed so a test can recompute the checksum rather than trusting the
/// producer to have computed it. The validation entry is the one structure
/// here whose corruption makes the firmware ignore the catalog **silently**.
pub fn eltorito_catalog(boot_lba: u32, boot_len: usize) -> Vec<u8> {
    let mut cat = vec![0u8; 64];
    // Validation entry.
    cat[0] = 0x01; // header id
    cat[1] = 0xEF; // platform: EFI
    cat[30] = 0x55;
    cat[31] = 0xAA;
    // The checksum is chosen so the sixteen little-endian u16 words of the
    // validation entry sum to zero mod 2^16.
    let mut sum: u16 = 0;
    for i in (0..32).step_by(2) {
        sum = sum.wrapping_add(u16::from_le_bytes([cat[i], cat[i + 1]]));
    }
    let checksum = (0u16).wrapping_sub(sum);
    cat[28..30].copy_from_slice(&checksum.to_le_bytes());

    // The default entry: no emulation, so the firmware exposes the image as a
    // block device rather than pretending it is a floppy.
    cat[32] = 0x88; // bootable
    cat[33] = 0x00; // no emulation
    cat[34..36].copy_from_slice(&0u16.to_le_bytes()); // load segment: firmware default
    cat[36] = 0; // system type
    let sectors = boot_len.div_ceil(512) as u16;
    cat[38..40].copy_from_slice(&sectors.to_le_bytes());
    cat[40..44].copy_from_slice(&boot_lba.to_le_bytes());
    cat
}

/// A 34-byte-or-longer directory record.
fn iso_dir_record(extent: u32, size: u32, is_dir: bool, name: &[u8]) -> Vec<u8> {
    let mut rec = vec![0u8; 33 + name.len()];
    rec[1] = 0; // extended attribute length
    both_u32(&mut rec[2..10], extent);
    both_u32(&mut rec[10..18], size);
    // Recording date and time, seven bytes: years since 1900, month, day, hour,
    // minute, second, GMT offset in 15-minute intervals.
    rec[18..25].copy_from_slice(&[80, 1, 1, 0, 0, 0, 0]);
    rec[25] = if is_dir { 0x02 } else { 0x00 };
    both_u16(&mut rec[28..32], 1); // volume sequence number
    rec[32] = name.len() as u8;
    rec[33..].copy_from_slice(name);
    if rec.len() % 2 != 0 {
        rec.push(0);
    }
    rec[0] = rec.len() as u8;
    rec
}

fn iso_path_table(root_lba: u32, little: bool) -> Vec<u8> {
    let mut t = vec![0u8; 10];
    t[0] = 1; // directory identifier length
    t[1] = 0; // extended attribute length
    if little {
        t[2..6].copy_from_slice(&root_lba.to_le_bytes());
        t[6..8].copy_from_slice(&1u16.to_le_bytes());
    } else {
        t[2..6].copy_from_slice(&root_lba.to_be_bytes());
        t[6..8].copy_from_slice(&1u16.to_be_bytes());
    }
    t[8] = 0; // the root's identifier is a single NUL
    t
}

/// ISO-9660's "both-endian" u32: the value little-endian, then big-endian.
///
/// Not a redundancy anybody may skip. A reader is entitled to consult either
/// half, and the two disagreeing is undefined rather than tolerated.
fn both_u32(out: &mut [u8], v: u32) {
    out[0..4].copy_from_slice(&v.to_le_bytes());
    out[4..8].copy_from_slice(&v.to_be_bytes());
}

fn both_u16(out: &mut [u8], v: u16) {
    out[0..2].copy_from_slice(&v.to_le_bytes());
    out[2..4].copy_from_slice(&v.to_be_bytes());
}

fn fill_achars(out: &mut [u8], value: &str) {
    for b in out.iter_mut() {
        *b = b' ';
    }
    for (i, b) in value.bytes().take(out.len()).enumerate() {
        out[i] = b.to_ascii_uppercase();
    }
}

fn fill_dchars(out: &mut [u8], value: &str) {
    fill_achars(out, value);
}