#!/usr/bin/env bash
#
# make-iso.sh — assemble the Alloy installer ISO. Runs inside the builder
# image from build/Containerfile.iso; build/build-iso.sh is what invokes it.
#
# Expects:
#   /rootfs   the Alloy image's root filesystem, read-only
#   /output   where install.iso is written
#   /source   an OCI layout of the Alloy image, copied onto the ISO
#
# The ISO carries two copies of Alloy for two different jobs. The squashfs
# is the live system the installer runs in. The OCI layout is what gets
# deployed, because `bootc install` installs a container image and cannot
# install a running squashfs. They are the same content and the ISO pays
# for it twice; that is the cost of not needing a network to install.

set -euo pipefail

ROOTFS=/rootfs
OUTPUT=/output
SOURCE=/source
WORK=/work
VOLID="ALLOY"

# The build this medium carries, read out of the image it was made from, so a
# stick can be asked which mint it holds without booting it:
#
#   xorriso -indev install.iso -pvd_info    (or isoinfo -d, or `blkid`)
#
# It answers the question that otherwise costs an install. The ISO's creation
# timestamp is close but not the same thing: two mints of different trees can
# share a minute, and the stamp names the image.
#
# Empty is not an error. A hand-built image with no stamp is a real thing to
# have, and a medium made from one says so rather than refusing to be made.
STAMP="$(sed -n 's/^IMAGE_VERSION="\(.*\)"$/\1/p' "$ROOTFS/usr/lib/os-release" 2>/dev/null | head -1)"

# Every phase line carries the seconds since the last one, because the ISO is
# where this build spends most of its wall clock and nobody could say on what.
# The four minutes were assumed to be compression until they were measured;
# they were not (mksquashfs at the fast level is 8s of it). A build that
# reports its own shape is the difference between optimizing the slow part and
# optimizing the part that looks slow.
#
# The number on a line is the time spent getting TO it, not the time the phase
# it announces will take, because a phase cannot report its own cost before
# doing the work. Read a line's figure as the price of the line above it.
START_MARK=$(date +%s)
LAST_MARK=$START_MARK
say() {
  local now elapsed
  now=$(date +%s)
  elapsed=$((now - LAST_MARK))
  LAST_MARK=$now
  printf '==> [+%3ds] %s\n' "$elapsed" "$*"
}

[ -d "$ROOTFS" ] || { echo "no $ROOTFS" >&2; exit 1; }
[ -d "$OUTPUT" ] || { echo "no $OUTPUT" >&2; exit 1; }

# ---------------------------------------------------------------------
# 0. Which architecture this medium is for.
#
# From `uname -m`, because the builder image is derived from the Alloy image
# and carries its kernel: the squashfs, the initramfs and the OCI layout are
# all already the architecture this is running on, and an ISO whose boot
# chain disagreed with them would boot nothing. ALLOY_ISO_ARCH overrides it
# for testing the GRUB half alone; it does not make this a cross-build.
#
# Anything but the two mappings is an explicit refusal. Falling through to a
# default would produce an ISO with the wrong EFI binary name, which fails
# as "the stick is not bootable" with no diagnosis available on the machine
# that cannot boot it.
# ---------------------------------------------------------------------
ARCH="${ALLOY_ISO_ARCH:-$(uname -m)}"
case "$ARCH" in
  x86_64)
    GRUB_FORMAT="x86_64-efi"
    # The removable-media path from the UEFI spec. Firmware looks for this
    # exact name on a medium with no boot entry of its own and nothing else,
    # so it is per-architecture and is not ours to choose.
    EFI_NAME="BOOTX64.EFI"
    GRUB_MODULE_PKG="grub2-efi-x64-modules"
    SERIAL_CONSOLES="console=ttyS0,115200" ;;
  aarch64)
    GRUB_FORMAT="arm64-efi"
    EFI_NAME="BOOTAA64.EFI"
    GRUB_MODULE_PKG="grub2-efi-aa64-modules"
    # ttyAMA0 first because SBSA mandates a PL011 and that is what an Ampere
    # board actually presents; ttyS0 stays listed for boards that present an
    # 8250 instead. The kernel writes to every console that exists and
    # ignores the ones that do not, so listing both costs nothing and losing
    # the serial log on a headless arm box costs the only view of a failed
    # boot. tty0 stays last, so /dev/console is still the screen.
    SERIAL_CONSOLES="console=ttyAMA0,115200 console=ttyS0,115200" ;;
  *)
    echo "unsupported architecture: $ARCH (this builds x86_64 and aarch64)" >&2
    exit 1 ;;
esac

# grub2-mkimage reads the modules for the target format out of this
# directory, and says only "cannot open directory" when they are absent.
# Name the package instead, before the several minutes of squashfs that
# would otherwise run first.
[ -d "/usr/lib/grub/$GRUB_FORMAT" ] \
  || { echo "no GRUB modules for $GRUB_FORMAT; the builder needs $GRUB_MODULE_PKG" >&2; exit 1; }

# The modules the EFI binary is built from, checked here for the same reason:
# grub2-mkimage runs at the end and stops at the first module it cannot open,
# so an absent one costs a whole build to discover and names only itself.
#
# The list is *almost* architecture-independent. Measured on astra 2026-08-06 by
# building an arm ISO rather than assumed: of the 30 names, arm64-efi carries
# 29. The exception is `efi_uga`, and its absence is correct. UGA is EFI 1.x's
# pre-GOP graphics protocol and only ever existed on x86 firmware; arm64 UEFI
# has always had GOP, and `efi_gop` is in the list and present on both. So the
# module is dropped on arm rather than substituted for.
#
# One list with a conditional addition, not two lists: a second copy of 29
# shared names drifts the day anything is added to one of them.
MODULES="part_gpt part_msdos fat iso9660 udf normal linux echo all_video test
  search search_label search_fs_uuid search_fs_file gfxterm gfxterm_background
  configfile loadenv chain efi_gop ls cat halt reboot minicmd
  font terminal squash4 loopback probe regexp"
[ "$ARCH" = "x86_64" ] && MODULES="$MODULES efi_uga"

missing=""
for module in $MODULES; do
  [ -f "/usr/lib/grub/$GRUB_FORMAT/$module.mod" ] || missing="$missing $module"
done
[ -z "$missing" ] || {
  echo "the $GRUB_FORMAT target is missing:$missing" >&2
  echo "either $GRUB_MODULE_PKG is incomplete, or the module does not exist on this architecture and this list needs another exception" >&2
  exit 1
}

say "architecture $ARCH, $GRUB_FORMAT, $EFI_NAME"

# Version-sorted, not `head -1`: `ls` collates 7.1.10 before 7.1.3, so the
# alphabetical pick is arbitrary rather than newest. A bootc image should carry
# exactly one kernel, so more than one means something layered a second and the
# choice stops being obvious, so say so rather than picking silently.
KVER="$(ls -1 "$ROOTFS/usr/lib/modules" | sort -V | tail -1)"
[ -n "$KVER" ] || { echo "no kernel in $ROOTFS/usr/lib/modules" >&2; exit 1; }
KCOUNT="$(ls -1 "$ROOTFS/usr/lib/modules" | wc -l)"
[ "$KCOUNT" -eq 1 ] || echo "warning: $KCOUNT kernels present, using newest ($KVER)" >&2
say "kernel $KVER"

rm -rf "$WORK"
mkdir -p "$WORK/iso/LiveOS" "$WORK/iso/EFI/BOOT" "$WORK/iso/boot/grub"

# ---------------------------------------------------------------------
# 1. The live root, as a squashfs holding an ext4 image.
#
# dmsquash-live expects LiveOS/squashfs.img to contain LiveOS/rootfs.img,
# not the root tree directly. Handing it a squashfs of the tree boots to a
# dracut shell with "failed to mount live root", which says nothing about
# the layout being the problem.
# ---------------------------------------------------------------------
say "building rootfs.img"
SIZE_KB="$(du -sk "$ROOTFS" | cut -f1)"
# Slack for ext4 metadata. Not for the live session's writes: with
# rd.live.overlay.overlayfs=1 this image is the overlay's read-only lower dir
# and is never written to. The upper dir is a tmpfs under /run, so the real
# budget for the live session is RAM, not this number.
IMG_MB=$(( SIZE_KB / 1024 + 1536 ))
# Inodes are budgeted from the file count rather than left to the default
# bytes-per-inode ratio. A desktop rootfs is many small files, and the default
# runs out before the space does. That fails mkfs mid-populate, minutes into
# a build, with an error that reads as unrelated to file count.
INODES=$(( $(find "$ROOTFS" -xdev 2>/dev/null | wc -l) * 12 / 10 ))
say "rootfs.img ${IMG_MB}M, $INODES inodes"
# The LiveOS directory has to exist *inside* the squashfs, so mksquashfs is
# pointed at its parent: it takes the contents of the directory it is given
# as the squashfs root, so compressing LiveOS/ directly puts rootfs.img at
# the top level and dracut fails with "Failed to find a root filesystem in
# .../squashfs.img" while looking for LiveOS/rootfs.img.
mkdir -p "$WORK/sqroot/LiveOS"
truncate -s "${IMG_MB}M" "$WORK/sqroot/LiveOS/rootfs.img"
mkfs.ext4 -q -L Alloy -N "$INODES" -d "$ROOTFS" "$WORK/sqroot/LiveOS/rootfs.img"

# The console, into the live root and nowhere else.
#
# The image deliberately does not carry /usr/bin/alloy. A component the base
# carries can never be replaced client-side, so the console and shop travel as
# uninstalled RPMs at /usr/share/alloy/rpm and are layered on the first boot
# after an install (Containerfile, "The component repo"; measurements in
# build/layertest).
#
# The live environment is the one place that cannot wait for a first boot. It
# never gets one, it is thrown away when the install finishes, and `alloy
# install` is the entire reason the medium exists: alloy-installer.service and
# the installer account's ForceCommand both name /usr/bin/alloy. Without this
# the ISO boots to a machine with no installer, which is how it behaved for
# exactly one build.
#
# It goes into the mounted image rather than into $ROOTFS, which is the
# read-only mount of the shipped image and must stay exactly what installs.
# `bootc install` reads the OCI layout under /source, not this tree, so the
# console lands in the live session and reaches no installed machine.
#
# From the carried repo rather than by copying a binary in, and the difference
# is what it proves: this is the first thing that ever resolves against
# /usr/share/alloy/rpm, so a package that cannot install fails here, on a build
# host, instead of at somebody's first boot where the symptom is a session that
# will not start. rpm rather than dnf because the repo is on the read-only tree
# and the dependencies are already in the base, so nothing needs resolving.
say "installing the console into the live root"
mkdir -p "$WORK/liveroot"
mount -o loop "$WORK/sqroot/LiveOS/rootfs.img" "$WORK/liveroot"
set -- "$ROOTFS"/usr/share/alloy/rpm/alloy-*.rpm
[ "$#" -eq 1 ] && [ -f "$1" ] \
  || { echo "expected one console RPM in the carried repo, got: $*" >&2; umount "$WORK/liveroot"; exit 1; }
rpm --root "$WORK/liveroot" -i "$1" \
  || { echo "the console RPM did not install into the live root" >&2; umount "$WORK/liveroot"; exit 1; }
test -x "$WORK/liveroot/usr/bin/alloy" \
  || { echo "no /usr/bin/alloy in the live root; the ISO would boot with no installer" >&2; umount "$WORK/liveroot"; exit 1; }
umount "$WORK/liveroot"
rmdir "$WORK/liveroot"

# ALLOY_ISO_FAST picks the cheap level, for when the question is whether the
# thing boots at all rather than how big it is.
#
# Measured on fw13 (12 cores) against this image, 2026-08-15, 6.4G of rootfs:
#
#   level  time   squashfs.img
#     3      8s   2.519 G
#     9     20s   2.412 G
#    12     29s   2.405 G
#    15     63s   2.398 G
#    19    192s   2.264 G
#
# Two things in that table, and both contradict what this comment used to say.
# It claimed level 19 costs "about ten minutes of saturated CPU for a few
# percent of size"; it costs three, and it is the only level that buys anything
# after 9 — the jump at 19 is zstd's long-distance matching, which the levels
# below it do not enable. And 12 and 15 are dominated: 15 spends 43 seconds
# more than 9 to save 14 MB.
#
# So the two levels here are the two worth having. 19 for a release, where
# three minutes is nothing against 255 MB off every stick and every install.
# 3 for iteration, where eight seconds is close enough to free.
if [ "${ALLOY_ISO_FAST:-0}" = "1" ]; then
  COMP_LEVEL=3
  say "compressing squashfs.img (fast mode, level $COMP_LEVEL)"
else
  COMP_LEVEL=19
  say "compressing squashfs.img (this is the long part)"
fi
mksquashfs "$WORK/sqroot" "$WORK/iso/LiveOS/squashfs.img" \
  -noappend -no-progress -comp zstd -Xcompression-level "$COMP_LEVEL" -b 1M \
  -processors "$(nproc)"
rm -rf "$WORK/sqroot"

# ---------------------------------------------------------------------
# 2. Kernel and a live initramfs.
#
# --no-hostonly matters: a host-only initramfs is built for the hardware
# doing the building, and this one has to boot anything.
# ---------------------------------------------------------------------
say "kernel and initramfs"
cp "$ROOTFS/usr/lib/modules/$KVER/vmlinuz" "$WORK/iso/boot/vmlinuz"

# The image's own dracut config is for booting an installed ostree system:
# /usr/lib/dracut/dracut.conf.d/*bootc* add the ostree and bootc modules,
# which assume an ostree root and fail here trying to install /root. A live
# medium has no deployment to find, so start from no distro config at all
# (--conf /dev/null, empty --confdir) and name what this initramfs needs.
mkdir -p "$WORK/empty-conf"
dracut --force --no-hostonly --nomdadmconf --nolvmconf \
  --conf /dev/null \
  --confdir "$WORK/empty-conf" \
  --kver "$KVER" \
  --kmoddir "$ROOTFS/usr/lib/modules/$KVER" \
  --add "dmsquash-live" \
  --omit "ostree bootc" \
  --add-drivers "squashfs loop overlay iso9660 sr_mod sd_mod usb_storage virtio_blk virtio_scsi virtio_pci" \
  "$WORK/iso/boot/initramfs.img" 2>&1 | tee "$WORK/dracut.log"

# dracut reports module failures on stderr and still exits 0, so a broken
# initramfs ships looking like a success. Check the result instead.
#
# This build used to print `ERROR: installing '/root'` and a FAILED line
# every time, harmlessly: /root is a symlink to var/roothome in the bootc
# layout and no var is populated in a container. The builder now creates
# /var/roothome (see build/Containerfile.iso), so that error is gone rather
# than tolerated, and this check can be the strict one it could not be
# while a known error was expected in normal output.
#
# One message is still allowlisted, by its exact text. dracut tests for
# /dev/log or a logger binary before it installs anything and warns when it
# finds neither; `--install logger` does not satisfy it, because the test
# runs first. It concerns logging from inside the initramfs and nothing
# else. Matching the whole sentence rather than a pattern keeps the
# allowance from widening into "errors mentioning logging are fine".
#
# grep -c rather than grep -q: grep -q exits at the first match, which
# SIGPIPEs lsinitrd, and under `set -o pipefail` that reads as a failed
# check on a perfectly good initramfs. It cost a build to work that out.
[ -s "$WORK/iso/boot/initramfs.img" ] || { echo "dracut produced no initramfs" >&2; exit 1; }
unexpected="$(grep -E 'dracut\[E\]|dracut-install: ERROR|FAILED' "$WORK/dracut.log" \
              | grep -vF "No '/dev/log' or 'logger' included for syslog logging" || true)"
if [ -n "$unexpected" ]; then
  echo "dracut reported errors this build does not expect:" >&2
  printf '%s\n' "$unexpected" | sed 's/^/  /' >&2
  exit 1
fi
found="$(lsinitrd "$WORK/iso/boot/initramfs.img" 2>/dev/null | grep -c dmsquash || true)"
[ "${found:-0}" -gt 0 ] \
  || { echo "initramfs has no dmsquash-live; it cannot mount the live root" >&2; exit 1; }
say "initramfs $(du -h "$WORK/iso/boot/initramfs.img" | cut -f1), dmsquash-live present"

# ---------------------------------------------------------------------
# 3. The image the installer deploys.
# ---------------------------------------------------------------------
if [ -d "$SOURCE" ] && [ -n "$(ls -A "$SOURCE" 2>/dev/null)" ]; then
  say "copying the install source onto the ISO"
  mkdir -p "$WORK/iso/source"
  cp -a "$SOURCE/." "$WORK/iso/source/"
else
  echo "warning: no OCI layout at $SOURCE; the ISO will have nothing to install" >&2
fi

# ---------------------------------------------------------------------
# 4. GRUB.
#
# `alloy.installer` is what arms alloy-installer.service; without it the
# live system boots to a greeter with no account, which looks like a
# broken ISO. rd.live.image and root=live:CDLABEL are what dmsquash-live
# reads to find the squashfs.
# ---------------------------------------------------------------------
say "grub"
# console= more than once on purpose. The kernel writes to every console
# listed, so serial gets a full log for headless debugging, while
# /dev/console resolves to the last one named and so stays on the screen the
# user is looking at. Which serial lines are named is per-architecture; see
# the arch block at the top.
# Reversed, the installer's own output would go down the serial line and the
# monitor would sit black. The installer renders on tty1 explicitly
# (alloy-installer.service, TTYPath) and does not depend on this ordering.
CONSOLES="$SERIAL_CONSOLES console=tty0"
# SELinux on the live medium: permissive by default, off as a fallback.
#
# The live rootfs.img is built by mkfs.ext4 from a container rootfs, and it
# carries SELinux labels only if the machine doing the building has SELinux
# to derive them from. fw13 is Pop!_OS and has none, so every file in the
# live root is unlabeled and systemd refuses to start at all: "Failed to
# allocate manager object: Permission denied", then it freezes, with no
# hint that labelling is what is missing.
#
# `selinux=0` cures the freeze and costs the install. A process running
# with SELinux switched off cannot write labels to anything, the target
# disk included, so the machine this ISO installs comes up unlabeled and
# freezes the same way on its own first boot, with no way past it but
# editing the kernel line at GRUB by hand.
#
# `enforcing=0` is the setting between the two: the policy in the image at
# /etc/selinux/targeted/policy loads, unlabeled files stop being fatal, and
# the kernel still tracks labels. That is enough for the live system to
# boot and, unlike selinux=0, leaves `bootc install` able to label the
# target as it writes it. An install from the permissive entry should come
# up enforcing and correct on its first boot, with no relabel pass and no
# manual step.
#
# Should, because it is untested until this ISO boots on real hardware,
# which is why both entries ship. Boot the default; if the live system
# freezes before the installer draws, the SELinux-off entry is exactly the
# medium that was known to work, at the cost of an install that needs
# `enforcing=0` typed at GRUB on first boot. Recorded in the wiki note
# alloy-build-notes.
#
# Building on a Fedora host removes the question entirely by shipping a
# labeled live root. That is a build-host project, not a boot flag.
SELINUX_PERMISSIVE="enforcing=0"
SELINUX_OFF="selinux=0"
LIVE="root=live:CDLABEL=$VOLID rd.live.image rd.live.overlay.overlayfs=1 alloy.installer $CONSOLES"

# Where machines installed from this ISO will fetch their updates.
#
# Empty for ordinary media, which leaves the installer on its compiled-in
# default (the public registry). Set by `build/build-iso.sh --update-target`
# for media built against a registry that exists today, so the machines it
# installs can take an update without a `bootc switch` afterwards.
#
# Whitespace would split into a second kernel parameter and silently truncate
# the reference, so it is refused rather than quoted: a reference cannot
# legally contain any.
if [ -n "${ALLOY_UPDATE_TARGET:-}" ]; then
  case "$ALLOY_UPDATE_TARGET" in
    *[[:space:]]*)
      echo "update target contains whitespace: $ALLOY_UPDATE_TARGET" >&2; exit 1 ;;
  esac
  # A tag is required, because the containers stack reads its absence as
  # `latest` and docs/IMAGE.md makes a major bump a deliberate act. The tag is
  # what follows the last colon, and only when no `/` follows it: a colon
  # before a slash is a registry port, not a tag.
  case "${ALLOY_UPDATE_TARGET##*:}" in
    */*|"$ALLOY_UPDATE_TARGET")
      echo "update target has no tag: $ALLOY_UPDATE_TARGET" >&2; exit 1 ;;
    latest)
      echo "update target is :latest; pin a version" >&2; exit 1 ;;
  esac
  say "update target $ALLOY_UPDATE_TARGET"
  LIVE="$LIVE alloy.update-target=$ALLOY_UPDATE_TARGET"
fi
CMDLINE="$LIVE $SELINUX_PERMISSIVE quiet loglevel=3"

cat > "$WORK/iso/boot/grub/grub.cfg" <<EOF
set default=0
set timeout=5

menuentry "Install Alloy" {
    linux /boot/vmlinuz $CMDLINE
    initrd /boot/initramfs.img
}

# The fallback, for a live system that freezes before the installer draws.
# Installs a machine that needs enforcing=0 on its own first boot; see the
# SELinux note above.
menuentry "Install Alloy (SELinux off, if the default will not boot)" {
    linux /boot/vmlinuz $LIVE $SELINUX_OFF quiet loglevel=3
    initrd /boot/initramfs.img
}

# alloy.debug arms alloy-debug-shell@.service, which puts a root shell on tty9
# and on the serial console. The live medium has no account and a locked root,
# so without this a failure inside the live system can only be read off
# whatever the installer printed before it stopped.
#
# Quiet, like the default. This entry used to add rd.debug and
# systemd.log_level=debug, which with console=tty0 print over the installer TUI
# and over the shell itself. Every question asked of the live system so far has
# been answered from the shell, with dmesg and findmnt, which hold the same
# information and hold still while being read.
menuentry "Install Alloy (root shell on tty9)" {
    linux /boot/vmlinuz $CMDLINE alloy.debug
    initrd /boot/initramfs.img
}

# Not a boot option: a rescue shell in the initramfs, for when the live root
# will not mount at all and there is nothing else to ask.
menuentry "Initramfs shell" {
    linux /boot/vmlinuz root=live:CDLABEL=$VOLID rd.live.image $CONSOLES rd.break=pre-mount
    initrd /boot/initramfs.img
}
EOF

# grub.cfg next to the EFI binary is the one the firmware reads first; it
# hands off to the copy above so there is a single place to edit entries.
mkdir -p "$WORK/iso/EFI/BOOT"
cat > "$WORK/iso/EFI/BOOT/grub.cfg" <<EOF
search --no-floppy --set=root --label $VOLID
set prefix=(\$root)/boot/grub
configfile /boot/grub/grub.cfg
EOF

# Our own GRUB *is* the removable-media binary, with no shim in front of it.
#
# The signed shim and signed grubx64.efi that shim expects to chain to both
# ship in RPMs whose files a bootc image strips out of /boot, and the
# packages still read as installed, so reinstalling them is the only way to
# get the bytes back. Not worth it here: a self-built GRUB is unsigned
# either way, so this ISO needs Secure Boot off regardless of whether shim
# is in front of it. Signing is a distribution problem and distribution is
# not set up (GO task 0d7505b5); when it is, this is where shim goes.
#
# The module list is *almost* architecture-independent. Measured on astra
# 2026-08-06 by building an arm ISO, rather than assumed: of the 30 modules
# below, arm64-efi carries 29. The exception is `efi_uga`, and its absence is
# correct. UGA is EFI 1.x's pre-GOP graphics protocol, which only ever existed
# on x86 firmware; arm64 UEFI has always had GOP, and `efi_gop` is in the list
# and present. So the module is dropped on arm rather than replaced.
#
# The list itself is built at the top of this file, and checked there, so a
# missing module fails before the several minutes of squashfs rather than after.
#
# shellcheck disable=SC2086 # $MODULES is a deliberate word-split list
grub2-mkimage \
  --format="$GRUB_FORMAT" \
  --prefix="/EFI/BOOT" \
  --output="$WORK/iso/EFI/BOOT/$EFI_NAME" \
  $MODULES

# The EFI system partition the firmware actually mounts. Sized to contents
# rather than a round number, because a fixed size is a future failure the
# day the EFI binaries grow.
say "efiboot.img"
# FAT16, not whatever mkfs.fat picks. Sized to contents it picks FAT12, which
# UEFI permits on removable media but which no shipping distro relies on --
# Arch and Fedora both force FAT16 for exactly this image. Firmware that
# declines FAT12 produces no boot option at all, and the GPT still looks
# perfect to every tool you would inspect it with, so the failure arrives as
# "the stick is not bootable" with nothing to read.
#
# FAT16 needs at least 4085 clusters, which the contents-sized image is far
# below, so floor it at 16 MiB. That is noise against a 4.8 GB ISO.
EFI_KB=$(( $(du -sk "$WORK/iso/EFI" | cut -f1) + 2048 ))
[ "$EFI_KB" -lt 16384 ] && EFI_KB=16384
truncate -s "${EFI_KB}K" "$WORK/efiboot.img"
# The label some firmware shows for a removable boot entry, which is the only
# string on this medium with any chance of reaching a boot menu. EDK2 does not
# read it -- measured 2026-09-07 under OVMF, which named our medium
# `UEFI QEMU QEMU USB HARDDRIVE 1-0000:00:03.0-1` from the USB descriptor alone
# -- but several vendor firmwares do, so it is worth being a name rather than an
# identifier. Eleven characters is the FAT limit and this is ten.
#
# It must NOT equal $VOLID. GRUB's `search --label $VOLID` looks at every
# filesystem it can see, the ESP on this same medium included, so two
# filesystems sharing a label is a coin flip over which one becomes $root.
mkfs.fat -F 16 -n "ALLOY INST" "$WORK/efiboot.img" >/dev/null

# One code path, and it is allowed to fail.
#
# This used to try mmd/mcopy, fall back to a loop mount when mtools was
# absent, and then re-run mcopy under `|| true`. Three paths, of which one --
# mmd succeeding and mcopy failing, produced an ESP holding two empty
# directories and no bootloader, reported success, and shipped. mtools was
# not in the builder, so the loop-mount branch is what actually ran; the
# silent branch was live and simply never taken. mtools is installed now
# (build/Containerfile.iso) and this is the only path.
mmd -i "$WORK/efiboot.img" ::/EFI ::/EFI/BOOT
mcopy -i "$WORK/efiboot.img" -s "$WORK/iso/EFI/BOOT/"* ::/EFI/BOOT/

# Read it back out of the filesystem rather than trusting the exit codes: a
# bootloader that is not in here is the one defect this whole file exists to
# avoid, and it is invisible everywhere else.
#
# Checked against the name this build wrote, not a fixed one: an arm ISO
# that failed a check naming BOOTX64 would report an architecture it was
# never building for.
mdir -i "$WORK/efiboot.img" ::/EFI/BOOT | grep -qi "${EFI_NAME%.EFI}" \
  || { echo "$EFI_NAME is not in the ESP; the medium cannot boot" >&2; exit 1; }
say "ESP $(( EFI_KB / 1024 ))M FAT16, $EFI_NAME present"
# Deliberately not copied into $WORK/iso. It is appended to the medium as a
# real partition below, and El Torito is pointed at that partition rather
# than at a file in the tree, so a copy inside the ISO filesystem would be
# a second copy of these same bytes that nothing reads.

# ---------------------------------------------------------------------
# 5. Assemble. UEFI only, matching the images bootc produces: they have no
#    legacy BIOS path, so the MBR must be protective and must not advertise
#    a BIOS boot that would fail.
# ---------------------------------------------------------------------
say "xorriso"
# -rock is not decoration here. An OCI layout stores every blob under a
# 64-character hex filename, and plain ISO 9660 truncates names to 31, so
# without Rock Ridge the layout arrives on the medium renamed and the
# install fails looking like a corrupt image rather than a naming problem.
# The squashfs and the boot files have short names and never needed it,
# which is why it was absent until the install source became a directory.
#
# The partition table is written by -append_partition + -appended_part_as_gpt,
# not by -isohybrid-gpt-basdat, which is what this call used to pass. That
# option is a no-op in this shape: it describes an El Torito image that was
# opened with -eltorito-alt-boot and there is no primary entry for it to be
# an alternative to, so xorriso emitted no table and reported no error. The
# result was an ISO whose first 4096 bytes were zero, i.e. no MBR, no GPT
# and no discoverable ESP, which left booting a USB stick up to how lenient
# the firmware felt. It survived because every QEMU test attached the ISO
# with -cdrom, and optical emulation boots El Torito by design and never
# looks for a partition table.
#
# Appending the ESP instead gives the firmware the removable-media path it
# actually specifies: a protective 0xEE MBR, a GPT, and the ESP as a typed
# partition. `-e --interval:appended_partition_2:all::` keeps El Torito as
# well, pointed at those same bytes, so the optical path is unchanged.
#
# fdisk reports three partitions, not two. The third is the 300 KiB of
# padding xorriso appends by default to work around kernels that read past
# the end of the medium; -appended_part_as_gpt describes every region of
# the image, padding included. It is expected, not a stray partition.
xorriso -as mkisofs \
  -iso-level 3 \
  -rock \
  -volid "$VOLID" \
  -appid "Alloy Installer" \
  -publisher "Make Creative, LLC" \
  -preparer "Alloy build/make-iso.sh" \
  -sysid "LINUX" \
  -volset "${STAMP:-unstamped}" \
  -append_partition 2 C12A7328-F81F-11D2-BA4B-00A0C93EC93B "$WORK/efiboot.img" \
  -appended_part_as_gpt \
  -eltorito-alt-boot \
  -e --interval:appended_partition_2:all:: \
  -no-emul-boot \
  -output "$OUTPUT/install.iso" \
  "$WORK/iso"

# ---------------------------------------------------------------------
# 6. Read the boot structures back.
#
# The bug above shipped because the build trusted a flag that exited 0.
# These checks read the bytes the firmware reads, so a table that silently
# fails to appear fails the build instead of the stick.
# ---------------------------------------------------------------------
say "checking the partition table"
# -v is load-bearing, not decoration. Without it od replaces runs of identical
# 16-byte lines with a single `*`, so the hex string is shorter than the bytes
# it describes and every offset past the first repeat is wrong. A GPT entry
# array is mostly zero padding, which is exactly the input that triggers it.
# Presence checks survive this; arithmetic on offsets does not.
at() { dd if="$OUTPUT/install.iso" bs=1 skip="$1" count="$2" 2>/dev/null | od -An -tx1 -v | tr -d ' \n'; }

[ "$(at 510 2)" = "55aa" ] \
  || { echo "no MBR signature at offset 510; the ISO has no partition table" >&2; exit 1; }
[ "$(at 450 1)" = "ee" ] \
  || { echo "MBR partition 1 is not type 0xEE; the MBR is not protective" >&2; exit 1; }
[ "$(at 446 1)" = "00" ] \
  || { echo "MBR partition 1 is marked bootable; this image has no BIOS path" >&2; exit 1; }
[ "$(at 462 16)" = "$(printf '00%.0s' $(seq 16))" ] \
  || { echo "MBR partition 2 is populated; only a protective entry belongs here" >&2; exit 1; }
[ "$(dd if="$OUTPUT/install.iso" bs=512 skip=1 count=1 2>/dev/null | head -c 8)" = "EFI PART" ] \
  || { echo "no GPT header at LBA 1" >&2; exit 1; }

# Every check above passes on a GPT that describes no ESP at all, which is the
# one thing this shape exists to add, so read the entry array and find it. The
# type GUID is stored mixed-endian: the first three fields little-endian, the
# last two as written, so C12A7328-F81F-11D2-BA4B-00A0C93EC93B lands on disk as
# the bytes below. A 16-byte substring match is not entry-aligned, but a GUID
# occurring by chance anywhere else in the array is not a thing that happens.
le() { local h=$1 out="" i; for ((i = ${#h} / 2 - 1; i >= 0; i--)); do out="$out${h:$((i * 2)):2}"; done; printf '%d\n' "0x$out"; }
ENTRY_LBA=$(le "$(at $((512 + 72)) 8)")
ENTRY_COUNT=$(le "$(at $((512 + 80)) 4)")
ENTRY_SIZE=$(le "$(at $((512 + 84)) 4)")
ARRAY=$(dd if="$OUTPUT/install.iso" bs=512 skip="$ENTRY_LBA" \
  count=$(( (ENTRY_COUNT * ENTRY_SIZE + 511) / 512 )) 2>/dev/null | od -An -tx1 -v | tr -d ' \n')
case "$ARRAY" in
  *28732ac11ff8d211ba4b00a0c93ec93b*) ;;
  *) echo "the GPT describes no EFI system partition" >&2; exit 1 ;;
esac
say "protective MBR, GPT at LBA 1, ESP appended as partition 2"

# The checks above all pass on an image whose El Torito entry points somewhere
# other than the ESP, because they never compare the two. That is the optical boot
# path, and it is the one a partition-table change can silently break while
# every GPT check stays green. Read both numbers and require them to agree.
#
# The GUID match above found the ESP somewhere in the entry array; recover
# which entry it was, so its start LBA can be read rather than assumed.
ESP_PREFIX="${ARRAY%%28732ac11ff8d211ba4b00a0c93ec93b*}"
ESP_INDEX=$(( ${#ESP_PREFIX} / 2 / ENTRY_SIZE ))
ESP_LBA=$(le "${ARRAY:$(( (ESP_INDEX * ENTRY_SIZE + 32) * 2 )):16}")

# El Torito: the boot record volume descriptor at LBA 17 points at the boot
# catalog; the default entry sits 32 bytes into it, with the sector count at
# +6 and the load RBA at +8. The RBA is in 2048-byte blocks, the partition in
# 512-byte sectors.
CAT_LBA=$(le "$(at $((17 * 2048 + 71)) 4)")
ENTRY_OFF=$(( CAT_LBA * 2048 + 32 ))
BOOT_COUNT=$(le "$(at $((ENTRY_OFF + 6)) 2)")
BOOT_RBA=$(le "$(at $((ENTRY_OFF + 8)) 4)")

[ "$(( BOOT_RBA * 2048 ))" = "$(( ESP_LBA * 512 ))" ] \
  || { echo "El Torito load RBA ($(( BOOT_RBA * 2048 ))) is not the ESP ($(( ESP_LBA * 512 ))); optical boot would read the wrong bytes" >&2; exit 1; }

ISO_BYTES=$(stat -c %s "$OUTPUT/install.iso")
[ "$(( BOOT_RBA * 2048 + BOOT_COUNT * 512 ))" -le "$ISO_BYTES" ] \
  || { echo "El Torito extent runs past the end of the image" >&2; exit 1; }

say "El Torito entry points at the ESP, $BOOT_COUNT sectors, within the image"

chmod 0644 "$OUTPUT/install.iso"
say "built $(du -h "$OUTPUT/install.iso" | cut -f1) at $OUTPUT/install.iso"

TOTAL=$(( $(date +%s) - START_MARK ))
printf '==> total %dm%02ds\n' "$((TOTAL / 60))" "$((TOTAL % 60))"
