new file mode 100755
@@ -0,0 +1,250 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0+
+"""Turn an Ubuntu live ISO into one that boots via U-Boot + BLS.
+
+BLS (Boot Loader Specification) is a freedesktop.org standard for
+describing boot menu entries as small files under /loader/entries/ on
+the EFI system partition. Each entry names a kernel, initrd and command
+line; U-Boot's BOOTMETH_BLS scans the ESP and presents them in its boot
+menu, replacing the shim/grub chain that Ubuntu ships by default.
+
+The ISO's appended EFI System Partition is replaced with a fresh ESP
+containing:
+
+ /EFI/BOOT/BOOTX64.EFI U-Boot EFI app (built with CONFIG_BOOTMETH_BLS=y)
+ /casper/vmlinuz copied from the input ISO
+ /casper/initrd copied from the input ISO
+ /loader/entry.conf BLS Type #1 entry pointing at the above
+
+The ISO9660 tree is preserved unchanged, so casper still finds its squashfs
+by disc label at runtime. All other boot records (BIOS El Torito, grub2 MBR,
+GPT layout) are preserved by xorriso's -boot_image any replay.
+
+Quick start (run from the root of the U-Boot tree)::
+
+ # 1. Install host tools
+ sudo apt install xorriso mtools dosfstools qemu-system-x86 ovmf
+
+ # 2. Download an Ubuntu live ISO (desktop or server both work)
+ curl -LO https://releases.ubuntu.com/24.04.1/ubuntu-24.04.1-desktop-amd64.iso
+
+ # 3. Build U-Boot as an x86_64 EFI application. The defconfig enables
+ # BOOTMETH_BLS, FS_ISOFS and JOLIET. If rustc is not installed,
+ # also pass -d RUST_EXAMPLES -d EXAMPLES to scripts/config and
+ # re-run olddefconfig before building.
+ make O=/tmp/b/efi-x86_app64 efi-x86_app64_defconfig
+ make O=/tmp/b/efi-x86_app64 -j$(nproc)
+ # produces /tmp/b/efi-x86_app64/u-boot-app.efi
+
+ # 4. Rewrite the ISO to boot via U-Boot
+ scripts/ubuntu-iso-to-uboot.py ubuntu-24.04.1-desktop-amd64.iso \\
+ -u /tmp/b/efi-x86_app64/u-boot-app.efi \\
+ -o ubuntu-uboot.iso
+
+ # 5. Try it under QEMU + OVMF
+ cp /usr/share/OVMF/OVMF_VARS_4M.fd /tmp/OVMF_VARS.fd
+ qemu-system-x86_64 -machine q35 -m 4096 -smp 2 \\
+ -drive if=pflash,format=raw,readonly=on,file=/usr/share/OVMF/OVMF_CODE_4M.fd \\
+ -drive if=pflash,format=raw,file=/tmp/OVMF_VARS.fd \\
+ -drive if=virtio,file=ubuntu-uboot.iso,format=raw,readonly=on
+
+Assumptions:
+ - Ubuntu-style live ISO with casper/vmlinuz and casper/initrd.
+ - Input ISO has an appended GPT EFI System Partition
+ (-append_partition 2 in xorriso's report).
+ - U-Boot EFI app is built with CONFIG_BOOTMETH_BLS=y,
+ CONFIG_CMD_ZBOOT=y and FAT support (efi-x86_app64_defconfig is
+ the reference config).
+ - xorriso, mtools and dosfstools are installed on the host
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import re
+import shutil
+import sys
+import tempfile
+from pathlib import Path
+
+# Add the tools directory to the path for u_boot_pylib
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'tools'))
+
+# pylint: disable=wrong-import-position,import-error
+from u_boot_pylib import command
+from u_boot_pylib import tout
+
+DEFAULT_CMDLINE = 'console=ttyS0,115200 console=tty0 --- quiet'
+REQUIRED_TOOLS = ('xorriso', 'mcopy', 'mmd', 'mkfs.vfat')
+MIB = 1024 * 1024
+
+
+def check_tools() -> None:
+ missing = [t for t in REQUIRED_TOOLS if not shutil.which(t)]
+ if missing:
+ tout.fatal(
+ f'missing tools: {" ".join(missing)}\n'
+ f'try: apt install xorriso mtools dosfstools'
+ )
+
+
+def parse_boot_report(iso: Path) -> tuple[str, str]:
+ """Return (volume_label, esp_partition_guid) from xorriso's mkisofs report.
+
+ Raises SystemExit if the ISO does not have an appended EFI System
+ Partition on slot 2
+ """
+ # xorriso prints the report on stdout; stderr carries progress/status.
+ report = command.output(
+ 'xorriso', '-indev', str(iso), '-report_el_torito', 'as_mkisofs',
+ )
+
+ m_vol = re.search(r"^-V '([^']*)'", report, re.MULTILINE)
+ m_esp = re.search(
+ r'^-append_partition 2 ([0-9a-fA-F]+) ', report, re.MULTILINE,
+ )
+ if not m_vol:
+ tout.fatal('could not find volume label in xorriso report')
+ if not m_esp:
+ tout.fatal(
+ 'could not find appended partition 2 in xorriso report '
+ '(is this an Ubuntu-style hybrid ISO?)'
+ )
+ return m_vol.group(1), m_esp.group(1)
+
+
+def extract_from_iso(iso: Path, src: str, dst: Path) -> None:
+ """Pull a single file out of the ISO9660 tree via xorriso -osirrox"""
+ dst.parent.mkdir(parents=True, exist_ok=True)
+ command.run(
+ 'xorriso', '-osirrox', 'on', '-indev', str(iso),
+ '-extract', f'/{src.lstrip("/")}', str(dst),
+ )
+
+
+def build_esp(
+ esp_path: Path,
+ size_mib: int,
+ uboot_efi: Path,
+ vmlinuz: Path,
+ initrd: Path,
+ entry_conf: str,
+) -> None:
+ """Create a fresh FAT32 ESP at esp_path populated with BLS + kernel.
+
+ Args:
+ esp_path (Path): Output file to hold the new ESP image
+ size_mib (int): Size of the ESP in mebibytes
+ uboot_efi (Path): U-Boot EFI app to install as /EFI/BOOT/BOOTX64.EFI
+ vmlinuz (Path): Kernel image to install under /casper/vmlinuz
+ initrd (Path): Initrd image to install under /casper/initrd
+ entry_conf (str): Contents for /loader/entry.conf (BLS Type #1)
+ """
+ with esp_path.open('wb') as f:
+ f.truncate(size_mib * MIB)
+ command.output('mkfs.vfat', '-F32', '-n', 'ESP', str(esp_path))
+ command.run('mmd', '-i', str(esp_path),
+ '::EFI', '::EFI/BOOT', '::loader', '::casper')
+
+ def put(src: Path, dst: str) -> None:
+ command.run('mcopy', '-i', str(esp_path), str(src), f'::{dst}')
+
+ put(uboot_efi, 'EFI/BOOT/BOOTX64.EFI')
+ put(vmlinuz, 'casper/vmlinuz')
+ put(initrd, 'casper/initrd')
+
+ entry = Path(esp_path.parent) / 'entry.conf'
+ entry.write_text(entry_conf)
+ put(entry, 'loader/entry.conf')
+
+
+def auto_esp_size(files: list[Path], headroom_mib: int = 16) -> int:
+ """Sum file sizes + headroom, round up to MiB, floor at 64 MiB (FAT32)"""
+ total = sum(f.stat().st_size for f in files) + headroom_mib * MIB
+ mib = (total + MIB - 1) // MIB
+ return max(mib, 64)
+
+
+def repack_iso(
+ in_iso: Path, out_iso: Path, esp_img: Path, esp_guid: str,
+) -> None:
+ """Stream the input ISO to a new ISO, replacing partition 2's data.
+
+ -boot_image any replay preserves every other boot record (BIOS El Torito,
+ grub2 MBR, GPT layout); only the bytes behind partition 2 are rewritten
+ """
+ command.run(
+ 'xorriso',
+ '-indev', str(in_iso),
+ '-outdev', str(out_iso),
+ '-boot_image', 'any', 'replay',
+ '-append_partition', '2', esp_guid, str(esp_img),
+ '-commit',
+ )
+
+
+def main() -> None:
+ p = argparse.ArgumentParser(
+ description='Rewrite an Ubuntu live ISO to boot via U-Boot + BLS.',
+ )
+ p.add_argument('iso', type=Path, help='input Ubuntu live ISO')
+ p.add_argument('-u', '--uboot', type=Path, required=True,
+ help='U-Boot EFI app (e.g. u-boot-app.efi)')
+ p.add_argument('-o', '--out', type=Path, required=True,
+ help='output ISO path')
+ p.add_argument('-k', '--kernel', default='casper/vmlinuz',
+ help='kernel path inside the input ISO')
+ p.add_argument('-i', '--initrd', default='casper/initrd',
+ help='initrd path inside the input ISO')
+ p.add_argument('-a', '--cmdline', default=DEFAULT_CMDLINE,
+ help='kernel command line written into loader/entry.conf')
+ p.add_argument('-t', '--title', default=None,
+ help='BLS entry title (default: derived from volume label)')
+ p.add_argument('-s', '--esp-size', type=int, default=None,
+ help='ESP size in MiB (default: auto-size to fit)')
+ args = p.parse_args()
+
+ if not args.iso.is_file():
+ tout.fatal(f'ISO not found: {args.iso}')
+ if not args.uboot.is_file():
+ tout.fatal(f'EFI app not found: {args.uboot}')
+ check_tools()
+
+ print(f'=> Reading boot config from {args.iso}')
+ # Extract the volume label and ESP partition GUID from xorriso's report
+ vol_id, esp_guid = parse_boot_report(args.iso)
+ title = args.title or f'U-Boot BLS boot ({vol_id})'
+ print(f' Volume label: {vol_id}')
+ print(f' ESP GUID: {esp_guid}')
+
+ with tempfile.TemporaryDirectory(prefix='iso2uboot.') as td:
+ work = Path(td)
+ vmlinuz = work / 'vmlinuz'
+ initrd = work / 'initrd'
+
+ print(f'=> Extracting /{args.kernel} and /{args.initrd}')
+ extract_from_iso(args.iso, args.kernel, vmlinuz)
+ extract_from_iso(args.iso, args.initrd, initrd)
+
+ esp_mib = args.esp_size or auto_esp_size([vmlinuz, initrd, args.uboot])
+ print(f'=> Building {esp_mib} MiB ESP')
+
+ entry_conf = f'''\
+title {title}
+linux /casper/vmlinuz
+initrd /casper/initrd
+options {args.cmdline}
+'''
+ esp = work / 'esp.img'
+ build_esp(esp, esp_mib, args.uboot, vmlinuz, initrd, entry_conf)
+
+ print(f'=> Repacking to {args.out}')
+ repack_iso(args.iso, args.out, esp, esp_guid)
+
+ size_mib = args.out.stat().st_size / MIB
+ print(f'=> Done: {args.out} ({size_mib:.1f} MiB)')
+
+
+if __name__ == '__main__':
+ main()