git: d8acc2a9a431 - main - boot-test.sh: Add gptboot.efi tests
- Go to: [ bottom of page ] [ top of archives ] [ this month ]
Date: Sat, 26 Sep 2026 06:44:56 UTC
The branch main has been updated by imp:
URL: https://cgit.FreeBSD.org/src/commit/?id=d8acc2a9a431b4d57ef79b2ca3113ecf33b95d8d
commit d8acc2a9a431b4d57ef79b2ca3113ecf33b95d8d
Author: Warner Losh <imp@FreeBSD.org>
AuthorDate: 2026-09-24 15:02:23 +0000
Commit: Warner Losh <imp@FreeBSD.org>
CommitDate: 2026-09-26 06:44:21 +0000
boot-test.sh: Add gptboot.efi tests
Make sure that we can chainboot with gptboot.efi. Many projects use this
as their migration tool from gptboot to ping-pong partitions to boot
from. This tests that functionality which I recently broke.
Assisted-by: Claude Code (Fable 5, Opus 5)
Sponsored by: Netflix
---
tools/boot/boot-test.sh | 125 ++++++++++++++++++++++++++++++++++++++++++++-
tools/boot/gptattr.c | 133 ++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 257 insertions(+), 1 deletion(-)
diff --git a/tools/boot/boot-test.sh b/tools/boot/boot-test.sh
index a2149e5da80a..909c87fa1288 100755
--- a/tools/boot/boot-test.sh
+++ b/tools/boot/boot-test.sh
@@ -199,6 +199,10 @@ fi
# The smallest FAT32 filesystem is 33292 KB
espsize=33292
+# GPT attribute bit that marks the partition to boot, from
+# sys/sys/disk/gpt.h (GPT_ENT_ATTR_BOOTME). Used for the A/B test.
+GPT_ATTR_BOOTME=59
+
# Linux kernel version for linuxboot tests
LINUX_VERSION=6.18.2
@@ -495,6 +499,84 @@ make_one_zfs() {
rm -f ${mt}
}
+# --------------------------------------------------------------------------
+# A/B (dual root) images
+#
+# gptboot.efi is the only FreeBSD EFI component that reads the GPT bootme
+# attribute, so it is the only way to test that the loader it chainloads
+# honours the partition that was selected for it, rather than picking a root
+# of its own.
+#
+# The failure being tested for is "boots fine, wrong root", not "fails to
+# boot": a loader that ignores the selected partition still reaches /etc/rc
+# and still looks healthy. So the two roots have to differ in what they
+# print, and only the bootme one may print the string run_one_test greps for.
+# Two identical roots (what mkimg writes if handed the same image twice)
+# would make this test permanently green.
+# --------------------------------------------------------------------------
+
+# Create one of the two A/B root images. ${1} is the slice letter: each slice
+# gets its own UFS label, an fstab pointing at that label, and an /etc/rc that
+# names the slice it booted from. Only slice B -- the one set bootme in
+# assemble_efi_gpt_ab -- prints SUCCESS; booting A prints WRONG SLICE instead
+# and the test fails on timeout.
+make_one_ufs_ab() {
+ slice=$1
+ img=${IMGDIR}/bootable-ufs-ab-${slice}.img
+ mt=$(mktemp ${OUTDIR}/ufs-ab-mtree.XXXXXX)
+ fstab=${OUTDIR}/fstab.ufs.ab-${slice}
+ rc=${OUTDIR}/rc.ab-${slice}
+
+ echo " Creating UFS image for A/B slice ${slice}..."
+ echo "/dev/ufs/root${slice} / ufs rw 1 1" > ${fstab}
+
+ # Both roots report which one got mounted, so a failing log says what
+ # happened; only B counts as a pass. vfs.root.mountfrom is the direct
+ # readout of the loader's choice.
+ if [ "${slice}" = "B" ]; then
+ verdict='echo "RC COMMAND RUNNING -- SUCCESS!!!!!"'
+ else
+ verdict='echo "WRONG SLICE -- booted A, expected B"'
+ fi
+ cat > ${rc} <<RCEOF
+#!/bin/sh
+
+sysctl machdep.bootmethod
+kenv vfs.root.mountfrom
+${verdict}
+halt -p
+RCEOF
+
+ echo "./etc/fstab type=file mode=0644 contents=${fstab}" > ${mt}
+ echo "./etc/rc type=file mode=0755 contents=${rc}" >> ${mt}
+ echo "./boot/loader.efi type=file mode=0755 contents=${DESTDIR}/boot/loader_lua.efi" >> ${mt}
+ makefs -t ffs -B $(param byte_order) -M 10m -o label=root${slice} -o version=2 \
+ ${img} ${mt} ${DESTDIR} >> ${LOGDIR}/imagebuild.log 2>&1
+ rm -f ${mt}
+}
+
+# Create an ESP holding gptboot.efi rather than a loader variant. gptboot.efi
+# is a boot1 flavour: it reads the GPT, picks the bootme partition, and
+# chainloads /boot/loader.efi from it.
+make_gptboot_esp() {
+ esp=${IMGDIR}/gptboot.esp
+ mt=$(mktemp ${OUTDIR}/esp-mtree.XXXXXX)
+
+ echo " Creating ESP with gptboot.efi..."
+ cat > ${mt} <<EOF
+./efi type=dir uname=root gname=wheel mode=0755
+./efi/boot type=dir uname=root gname=wheel mode=0755
+./efi/boot/$(param efi_bootname).efi type=file uname=root gname=wheel mode=0755 contents=${DESTDIR}/boot/gptboot.efi
+EOF
+ makefs -t msdos \
+ -o fat_type=32 \
+ -o sectors_per_cluster=1 \
+ -o volume_label=EFISYS \
+ -s ${espsize}k \
+ ${esp} ${mt} >> ${LOGDIR}/imagebuild.log 2>&1
+ rm -f ${mt}
+}
+
# Create an ESP with the given EFI loader using an mtree spec
make_one_esp() {
loader_name=$1
@@ -666,6 +748,12 @@ make_base_images() {
for l in $(param efi_loaders); do
make_one_esp $l
done
+ # A/B: gptboot.efi on the ESP, plus two tellable-apart roots.
+ if [ -f "${DESTDIR}/boot/gptboot.efi" ]; then
+ make_gptboot_esp
+ make_one_ufs_ab A
+ make_one_ufs_ab B
+ fi
fi
# Linuxboot initrd + ESP (if supported and Linux kernel is available)
@@ -748,6 +836,30 @@ assemble_efi_gpt() {
done
}
+# Build the A/B disk and register its test. Layout mirrors an A/B upgrade
+# scheme: one ESP holding gptboot.efi, then two interchangeable root slices.
+#
+# bootme is set on p3 (slice B, the second root) on purpose. With no bootme
+# anywhere gptboot.efi falls back to the first UFS partition, which is slice
+# A, so marking p2 instead would pass even if the attribute were ignored
+# entirely. Marking the second root means only a loader that honours the
+# selected partition can reach slice B and print SUCCESS.
+assemble_efi_gpt_ab() {
+ echo " Assembling EFI+GPT A/B image..."
+ name="efi-gpt-ufs-ab"
+ img=${IMGDIR}/${name}.img
+
+ mkimg -s gpt \
+ -p efi:=${IMGDIR}/gptboot.esp \
+ -p freebsd-ufs:=${IMGDIR}/bootable-ufs-ab-A.img \
+ -p freebsd-ufs:=${IMGDIR}/bootable-ufs-ab-B.img \
+ -o ${img} >> ${LOGDIR}/imagebuild.log 2>&1
+
+ ${GPTATTR} ${img} 3 ${GPT_ATTR_BOOTME} >> ${LOGDIR}/imagebuild.log 2>&1
+
+ register_test ${name} $(qemu_efi ${img})
+}
+
assemble_efi_mbr() {
echo " Assembling EFI+MBR images..."
for loader in $(param efi_loaders); do
@@ -1488,6 +1600,9 @@ assemble_all_images() {
if has efi; then
if [ -r "$(param efi_firmware)" ]; then
assemble_efi_gpt
+ if [ -f "${IMGDIR}/gptboot.esp" ]; then
+ assemble_efi_gpt_ab
+ fi
if has mbr; then
assemble_efi_mbr
fi
@@ -1693,10 +1808,18 @@ case "${NETBOOT_MODE}" in
esac
# Preflight: universal tools (per-arch qemu binaries are checked in build_all).
-for prog in jq expect makefs mkimg; do
+for prog in jq expect makefs mkimg cc; do
need_cmd "${prog}"
done
+# gptattr sets the GPT bootme attribute for the A/B test; see
+# assemble_efi_gpt_ab. It is a host tool and independent of the target
+# architecture, so build it once here rather than per-arch.
+GPTATTR=$(mktemp -t boot-test-gptattr)
+if ! cc -o ${GPTATTR} ${SRCTOP}/tools/boot/gptattr.c -lz 2>/dev/null; then
+ die "Failed to build ${SRCTOP}/tools/boot/gptattr.c"
+fi
+
echo "FreeBSD boot loader test suite: ${ARCHES}"
echo ""
diff --git a/tools/boot/gptattr.c b/tools/boot/gptattr.c
new file mode 100644
index 000000000000..547ebde3c047
--- /dev/null
+++ b/tools/boot/gptattr.c
@@ -0,0 +1,133 @@
+/*
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+/*
+ * Set a GPT attribute on a partition of a disk image file.
+ *
+ * gpart(8) does the same thing, but only on a real device and only as root.
+ * boot-test.sh builds every image as an unprivileged user, so it needs to
+ * edit the on-disk GPT itself. Both the primary and the secondary entry
+ * table are updated: the loader only reads the primary, but the kernel
+ * checks both, and a mismatch would be reported as a corrupt GPT during the
+ * boot under test.
+ *
+ * Usage: gptattr <image> <index> <attribute-bit>
+ * where <index> is 1-based, matching gpart(8) partition numbering.
+ */
+
+#include <sys/param.h>
+#include <sys/disk/gpt.h>
+#include <sys/endian.h>
+
+#include <err.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <zlib.h>
+
+#define SECSZ 512
+
+static uint8_t *image;
+static size_t imagesz;
+
+/*
+ * Apply the attribute to one entry table and refresh the CRCs of the header
+ * at hdroff that describes it.
+ */
+static void
+patch_table(uint64_t hdroff, u_int index, uint64_t attr)
+{
+ struct gpt_hdr *hdr;
+ struct gpt_ent *ent;
+ uint64_t tbloff;
+ uint32_t entries, entsz, hdrsz;
+
+ if (hdroff + SECSZ > imagesz)
+ errx(1, "GPT header at offset %ju is past end of image",
+ (uintmax_t)hdroff);
+
+ hdr = (struct gpt_hdr *)(image + hdroff);
+ if (memcmp(hdr->hdr_sig, GPT_HDR_SIG, sizeof(hdr->hdr_sig)) != 0)
+ errx(1, "no GPT header at offset %ju", (uintmax_t)hdroff);
+
+ hdrsz = le32toh(hdr->hdr_size);
+ entries = le32toh(hdr->hdr_entries);
+ entsz = le32toh(hdr->hdr_entsz);
+ tbloff = le64toh(hdr->hdr_lba_table) * SECSZ;
+
+ if (index < 1 || index > entries)
+ errx(1, "partition index %u out of range (1..%u)", index,
+ entries);
+ if (tbloff + (uint64_t)entries * entsz > imagesz)
+ errx(1, "GPT entry table is past end of image");
+
+ ent = (struct gpt_ent *)(image + tbloff + (uint64_t)(index - 1) * entsz);
+ le64enc(&ent->ent_attr, le64toh(ent->ent_attr) | attr);
+
+ /*
+ * The table CRC covers the entries, and the header CRC covers the
+ * header including that table CRC, so recompute them in that order.
+ * hdr_crc_self is zeroed while it is being computed over itself.
+ */
+ le32enc(&hdr->hdr_crc_table, crc32(0, image + tbloff,
+ entries * entsz));
+ hdr->hdr_crc_self = 0;
+ le32enc(&hdr->hdr_crc_self, crc32(0, (const uint8_t *)hdr, hdrsz));
+}
+
+int
+main(int argc, char *argv[])
+{
+ FILE *fp;
+ char *end;
+ uint64_t attr;
+ u_long bit, index;
+
+ if (argc != 4) {
+ fprintf(stderr,
+ "usage: %s <image> <index> <attribute-bit>\n", argv[0]);
+ return (1);
+ }
+
+ errno = 0;
+ index = strtoul(argv[2], &end, 0);
+ if (errno != 0 || *end != '\0' || index == 0)
+ errx(1, "bad partition index: %s", argv[2]);
+ bit = strtoul(argv[3], &end, 0);
+ if (errno != 0 || *end != '\0' || bit > 63)
+ errx(1, "bad attribute bit: %s", argv[3]);
+ attr = (uint64_t)1 << bit;
+
+ if ((fp = fopen(argv[1], "r+")) == NULL)
+ err(1, "%s", argv[1]);
+ if (fseek(fp, 0, SEEK_END) != 0)
+ err(1, "%s", argv[1]);
+ imagesz = (size_t)ftell(fp);
+ rewind(fp);
+ if (imagesz < 2 * SECSZ)
+ errx(1, "%s is too small to hold a GPT", argv[1]);
+ if ((image = malloc(imagesz)) == NULL)
+ err(1, "malloc");
+ if (fread(image, 1, imagesz, fp) != imagesz)
+ err(1, "%s: read", argv[1]);
+
+ /* Primary header is always at LBA 1; it names the secondary. */
+ patch_table(SECSZ, index, attr);
+ patch_table(le64toh(((struct gpt_hdr *)(image + SECSZ))->hdr_lba_alt) *
+ SECSZ, index, attr);
+
+ rewind(fp);
+ if (fwrite(image, 1, imagesz, fp) != imagesz)
+ err(1, "%s: write", argv[1]);
+ if (fclose(fp) != 0)
+ err(1, "%s: close", argv[1]);
+ free(image);
+
+ return (0);
+}