diff --git a/boards/qemu-coreboot-fbwhiptail-tpm1-hotp-prod_quiet/qemu-coreboot-fbwhiptail-tpm1-hotp-prod_quiet.config b/boards/qemu-coreboot-fbwhiptail-tpm1-hotp-prod_quiet/qemu-coreboot-fbwhiptail-tpm1-hotp-prod_quiet.config index 14b268cc4..3cb335774 100644 --- a/boards/qemu-coreboot-fbwhiptail-tpm1-hotp-prod_quiet/qemu-coreboot-fbwhiptail-tpm1-hotp-prod_quiet.config +++ b/boards/qemu-coreboot-fbwhiptail-tpm1-hotp-prod_quiet/qemu-coreboot-fbwhiptail-tpm1-hotp-prod_quiet.config @@ -91,7 +91,7 @@ export CONFIG_BOOT_REQ_HASH=n export CONFIG_BOOT_REQ_ROLLBACK=n export CONFIG_BOOT_RECOVERY_SERIAL="/dev/ttyS0" export CONFIG_BOOT_KERNEL_ADD="console=ttyS0 console=tty systemd.zram=0" -export CONFIG_BOOT_KERNEL_REMOVE="quiet rhgb splash" +export CONFIG_BOOT_KERNEL_REMOVE="" export CONFIG_BOARD_NAME="qemu-coreboot-fbwhiptail-tpm1-hotp-prod_quiet" #export CONFIG_FLASH_OPTIONS="flashprog --progress --programmer internal" diff --git a/boards/qemu-coreboot-fbwhiptail-tpm1-prod_quiet/qemu-coreboot-fbwhiptail-tpm1-prod_quiet.config b/boards/qemu-coreboot-fbwhiptail-tpm1-prod_quiet/qemu-coreboot-fbwhiptail-tpm1-prod_quiet.config index 14ee259d9..557a26d93 100644 --- a/boards/qemu-coreboot-fbwhiptail-tpm1-prod_quiet/qemu-coreboot-fbwhiptail-tpm1-prod_quiet.config +++ b/boards/qemu-coreboot-fbwhiptail-tpm1-prod_quiet/qemu-coreboot-fbwhiptail-tpm1-prod_quiet.config @@ -88,7 +88,7 @@ export CONFIG_BOOT_REQ_HASH=n export CONFIG_BOOT_REQ_ROLLBACK=n export CONFIG_BOOT_RECOVERY_SERIAL="/dev/ttyS0" export CONFIG_BOOT_KERNEL_ADD="console=ttyS0 console=tty systemd.zram=0" -export CONFIG_BOOT_KERNEL_REMOVE="quiet rhgb splash" +export CONFIG_BOOT_KERNEL_REMOVE="" export CONFIG_BOARD_NAME="qemu-coreboot-fbwhiptail-tpm1-prod_quiet" #export CONFIG_FLASH_OPTIONS="flashprog --progress --programmer internal" diff --git a/boards/qemu-coreboot-fbwhiptail-tpm2-hotp-prod_quiet/qemu-coreboot-fbwhiptail-tpm2-hotp-prod_quiet.config b/boards/qemu-coreboot-fbwhiptail-tpm2-hotp-prod_quiet/qemu-coreboot-fbwhiptail-tpm2-hotp-prod_quiet.config index 84eae4d0d..3e59d5bec 100644 --- a/boards/qemu-coreboot-fbwhiptail-tpm2-hotp-prod_quiet/qemu-coreboot-fbwhiptail-tpm2-hotp-prod_quiet.config +++ b/boards/qemu-coreboot-fbwhiptail-tpm2-hotp-prod_quiet/qemu-coreboot-fbwhiptail-tpm2-hotp-prod_quiet.config @@ -90,7 +90,7 @@ export CONFIG_BOOT_REQ_HASH=n export CONFIG_BOOT_REQ_ROLLBACK=n export CONFIG_BOOT_RECOVERY_SERIAL="/dev/ttyS0" export CONFIG_BOOT_KERNEL_ADD="console=ttyS0 console=tty systemd.zram=0" -export CONFIG_BOOT_KERNEL_REMOVE="quiet rhgb splash" +export CONFIG_BOOT_KERNEL_REMOVE="" export CONFIG_BOARD_NAME="qemu-coreboot-fbwhiptail-tpm2-hotp-prod_quiet" #export CONFIG_FLASH_OPTIONS="flashprog --progress --programmer internal" diff --git a/boards/qemu-coreboot-fbwhiptail-tpm2-prod_quiet/qemu-coreboot-fbwhiptail-tpm2-prod_quiet.config b/boards/qemu-coreboot-fbwhiptail-tpm2-prod_quiet/qemu-coreboot-fbwhiptail-tpm2-prod_quiet.config index 531b4f0a4..c1b10bda2 100644 --- a/boards/qemu-coreboot-fbwhiptail-tpm2-prod_quiet/qemu-coreboot-fbwhiptail-tpm2-prod_quiet.config +++ b/boards/qemu-coreboot-fbwhiptail-tpm2-prod_quiet/qemu-coreboot-fbwhiptail-tpm2-prod_quiet.config @@ -89,7 +89,7 @@ export CONFIG_BOOT_REQ_HASH=n export CONFIG_BOOT_REQ_ROLLBACK=n export CONFIG_BOOT_RECOVERY_SERIAL="/dev/ttyS0" export CONFIG_BOOT_KERNEL_ADD="console=ttyS0 console=tty systemd.zram=0" -export CONFIG_BOOT_KERNEL_REMOVE="quiet rhgb splash" +export CONFIG_BOOT_KERNEL_REMOVE="" export CONFIG_BOARD_NAME="qemu-coreboot-fbwhiptail-tpm2-prod_quiet" #export CONFIG_FLASH_OPTIONS="flashprog --progress --programmer internal" diff --git a/doc/architecture.md b/doc/architecture.md index 63f2d7bbf..f9b5cf67e 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -5,9 +5,9 @@ Linux kernel, and a security-focused initrd. It establishes a hardware root of t measured boot via TPM, and verifies the OS boot environment before handing off control. See also: [security-model.md](security-model.md), [boot-process.md](boot-process.md), -[tpm.md](tpm.md) — detailed subsystem documentation. +[tpm.md](tpm.md): detailed subsystem documentation. -External reference: [deepwiki.com/linuxboot/heads](https://deepwiki.com/linuxboot/heads) — +External reference: [deepwiki.com/linuxboot/heads](https://deepwiki.com/linuxboot/heads) validated against code in this repository. --- @@ -19,7 +19,7 @@ validated against code in this repository. │ SPI Flash ROM │ │ ┌──────────────┐ ┌───────────────┐ ┌──────────┐ │ │ │ coreboot │ │ Linux kernel │ │ initrd │ │ -│ │ (HW init + │→ │ (minimal, │→ │ (boot │ │ +│ │ (HW init + │-> │ (minimal, │-> │ (boot │ │ │ │ PCR 2 SRTM) │ │ no initramfs)│ │ scripts)│ │ │ └──────────────┘ └───────────────┘ └──────────┘ │ └─────────────────────────────────────────────────────┘ @@ -31,13 +31,13 @@ validated against code in this repository. ### coreboot Replaces vendor firmware. Performs hardware initialization (memory training, PCIe, USB), -extends PCR 2 in the TPM with each firmware stage (bootblock → romstage → ramstage → +extends PCR 2 in the TPM with each firmware stage (bootblock -> romstage -> ramstage -> payload) as the Static Root of Trust for Measurement (SRTM), and launches the kernel directly without a second-stage bootloader. ### Linux kernel (payload) -A minimal, stripped kernel compiled specifically for Heads. No initramfs — it boots directly +A minimal, stripped kernel compiled specifically for Heads. No initramfs: it boots directly into the Heads initrd. Provides device drivers (TPM, USB, storage, network), filesystem support, and the platform for the boot scripts. @@ -70,9 +70,9 @@ execution. Source lives in `initrd/`. Three-layer hierarchy: -1. **`/etc/config`** — Board defaults compiled into the ROM at build time -2. **`/etc/config.user`** — User overrides extracted from CBFS at runtime -3. **`/tmp/config`** — Combined result, sourced during boot +1. **`/etc/config`**: Board defaults compiled into the ROM at build time +2. **`/etc/config.user`**: User overrides extracted from CBFS at runtime +3. **`/tmp/config`**: Combined result, sourced during boot `combine_configs()` in `initrd/etc/functions.sh` merges these by concatenating `/etc/config*` into `/tmp/config`. User settings in CBFS take precedence @@ -87,14 +87,14 @@ Changes to user configuration are persisted by reflashing the ROM (CBFS operatio The top-level `Makefile` orchestrates: - Cross-compiler (`musl-cross-make`, target: `x86_64-linux-musl` or `powerpc64le-linux-musl`) -- Modules (coreboot, Linux, busybox, GPG, cryptsetup, kexec, LVM2, …) +- Modules (coreboot, Linux, busybox, GPG, cryptsetup, kexec, LVM2, zstd, ...: see [modules.md](modules.md) for the full list) - Six CPIO archives assembled into the initrd: - 1. `dev.cpio` — device nodes - 2. `modules.cpio` — kernel modules - 3. `tools.cpio` — userspace tools + configuration - 4. `board.cpio` — board-specific scripts - 5. `heads.cpio` — security scripts (`CONFIG_HEADS=y`) - 6. `data.cpio` — data files + 1. `dev.cpio`: device nodes + 2. `modules.cpio`: kernel modules + 3. `tools.cpio`: userspace tools + configuration + 4. `board.cpio`: board-specific scripts + 5. `heads.cpio`: security scripts (`CONFIG_HEADS=y`) + 6. `data.cpio`: data files - Final ROM image: coreboot ROM with Linux + initrd payload embedded Reproducible builds are achieved via Nix-pinned Docker images. See [docker.md](docker.md). @@ -114,8 +114,96 @@ The CI pipeline's workspace and cache behavior is documented in ## Key design principles -- **No network at boot** — all verification is local; no certificate authorities -- **Hardware root of trust** — coreboot in SPI flash is the trust anchor; coreboot extends measurements into the TPM -- **Fail-closed** — failed verification drops to authenticated recovery shell, not an unverified OS boot -- **Separation of duties** — the key that signs `/boot` lives on a hardware security dongle, never in the ROM -- **Auditability** — all source is open, builds are reproducible, ROM images are verifiable +- **No network at boot**: all verification is local; no certificate authorities +- **Hardware root of trust**: coreboot in SPI flash is the trust anchor; coreboot extends measurements into the TPM +- **Fail-closed**: failed verification drops to authenticated recovery shell, not an unverified OS boot +- **Separation of duties**: the key that signs `/boot` lives on a hardware security dongle, never in the ROM +- **Auditability**: all source is open, builds are reproducible, ROM images are verifiable + +--- + +## Display output after kexec + +``` + kexec boundary + ┌──────────────────────────────────────────────────┐ + │ DISPLAY CONTROLLER │ + │ (initialised by coreboot, continuously scans │ + │ out from framebuffer at physical address X) │ + └──────────────────────┬───────────────────────────┘ + │ reads pixels from + ▼ + ┌────────────────┐ + │ scanout buf │ + │ @ phys addr X │ + └────────────────┘ + ▲ + ───────────────────────┼────────────────────────────── + Heads side │ OS side + │ + ┌──────────────────────────┐ ┌───────────────────────────────┐ + │ coreboot/libgfxinit │ │ target kernel (after kexec) │ + │ - initialises display │ │ sysfb_init() reads boot │ + │ - sets VIDEO_TYPE_EFI │ │ params -> VIDEO_TYPE_EFI │ + │ in screen_info │ │ -> "efi-framebuffer" device │ + └────────────┬─────────────┘ │ -> efifb binds │ + │ │ BUT: lfb_base == 0 │ + ▼ │ -> efifb_probe fails │ + ┌──────────────────────────┐ │ -> no /dev/fb0 │ + │ Heads kernel │ │ -> initramfs display blank │ + │ sysfb_init() -> sees │ └───────────────────────────────┘ + │ VIDEO_TYPE_EFI │ + │ -> "efi-framebuffer" │ ┌───────────────────────────────┐ + │ -> efifb binds │ │ DRM driver (post-switchroot) │ + │ -> Heads GUI visible │ │ i915 reinitialises display │ + └────────────┬─────────────┘ │ -> framebuffer works again │ + │ └───────────────────────────────┘ + │ + ▼ + ┌──────────────────────────┐ + │ kexec-tools │ + │ opens /dev/fb0 -> reads │ + │ fb parameters │ + │ fix.id = "EFI VGA" ✓ │ + │ smem_start = 0 ✗ │ + │ (kernel hid it) │ + │ writes to new kernel │ + │ boot params: │ + │ orig_video_isVGA = EFI │ + │ lfb_base = 0 │ + └──────────────────────────┘ +``` + +Heads' initrd runs under a kernel compiled with `CONFIG_FB_EFI=y`. +coreboot/libgfxinit initialises the display hardware (PLL, timings, +scanout buffer) and presents it to Linux as an efifb-compatible +framebuffer (`screen_info` set to `VIDEO_TYPE_EFI`). Heads' kernel +binds efifb, maps the scanout buffer, and the Heads console and +whiptail GUI are visible. Without this, Heads would have no +framebuffer at all. + +After kexec, the display hardware remains in its initialised state +(kexec does not reset devices: the controller keeps scanning out +from the same physical address). The target kernel must also have +`CONFIG_FB_EFI=y` to adopt the same framebuffer: simplefb and +simpledrm are not compatible because the scanout buffer was set up +as an efifb-compatible buffer by coreboot, not as a simplefb buffer. + +**Known limitation: lost framebuffer address:** + +Linux no longer exposes the physical framebuffer address to userspace. +kexec-tools obtains `smem_start = 0` from `FBIOGET_FSCREENINFO` and +writes `lfb_base = 0` into the new kernel's boot params. The target +kernel's efifb sees a zero address and cannot map the framebuffer +the display stays blank even though the controller is still scanning +out the correct memory (set up by coreboot, preserved through kexec). + +Historically, Heads worked around this by using i915 with +`CONFIG_DRM_FBDEV_LEAK_PHYS_SMEM=y` (leaking the physical address), +but that was suboptimal. The current efifb approach is also +suboptimal: we are waiting for improvements in coreboot, Linux, and +kexec-tools to properly convey the framebuffer state across kexec. + +**TPM Disk Unlock Key** works around this for encrypted disks by +injecting the LUKS key before kexec: the initramfs never prompts for +a passphrase on a blank display. Serial console is unaffected. \ No newline at end of file diff --git a/doc/boot-process.md b/doc/boot-process.md index 7e966bbe6..cbcddebda 100644 --- a/doc/boot-process.md +++ b/doc/boot-process.md @@ -122,6 +122,101 @@ menu, system info, power off. --- +## Stage 2b: USB ISO Boot (`kexec-iso-init.sh`) + +When booting from an ISO file on USB media, `kexec-iso-init.sh` handles the ISO +boot flow. It is invoked from the "USB ISO Boot" option in the main menu. + +### Flow + +1. **Signature verification**: Check for `.sig` or `.asc` detached signature +2. **Mount ISO**: Mount the ISO file as loopback device at `/boot` + 3. **Layer 1: initramfs fs compatibility check** (`check_initrd_compat`): + Before presenting boot options, verify the ISO's initramfs contains kernel + modules for the USB partition's filesystem (ext4/vfat/exfat). If the initrd + can't read the USB filesystem, the kernel won't find the ISO after kexec. + Also checks for a framebuffer driver (efifb, bochs) needed for display after + kexec. + - Parsing boot configs for initrd paths (instead of searching the whole ISO) + - Unpacking each initrd and checking for required `.ko` files and + `modules.builtin` + - Each initrd gets its own independent `[OK]` / `[!]` / (blank) marker in + `/tmp/kexec_initrd_compat.txt` (the per-initrd flag `initrd_supports_fs` is tracked + separately from the global `any_supported` flag, so no initrd is silently + skipped) + - `[OK]` = initrd has the needed module as `.ko`, has it in + `modules.builtin`, or has no `.ko` files at all (minimal initrd with + everything built into the kernel: nothing to check against). + - `[!]` = initrd has loadable kernel modules but none for the USB + filesystem type. No built-in assumption: we report what we find. + - Read-only filesystems (iso9660/squashfs/udf) and unmapped fstypes skip + - All initrds are checked (no early break) so the compat file is complete. + - Framebuffer results are written to `/tmp/kexec_fb_compat.txt`. A + separate warning is shown if no initrd has a known fb driver. +4. **Layer 2: loopback.cfg fast path**: If the ISO has a `loopback.cfg`, parse + it and resolve GRUB variables (`${iso_path}`, `${isofile}`) to extract the + ISO kernel params from loopback entries. +5. **Boot param injection**: When Layer 2 resolves nothing (no GRUB vars found + in loopback.cfg), all common ISO boot methods are injected unconditionally + as kernel ADD params so the ISO initrd can pick whichever it supports: + - `iso-scan/filename=/$ISO_PATH`: Ubuntu casper, Fedora dracut + - `findiso=/$ISO_PATH`: Debian live-boot, NixOS stage-1 + - `img_dev=/dev/disk/by-uuid/$DEV_UUID`: block device containing the ISO + - `img_loop=$ISO_PATH`: loopback file path (relative) + - `iso=$DEV_UUID/$ISO_PATH`: UUID/path alternative + - `live-media=/dev/disk/by-uuid/$DEV_UUID`: device filter (casper, live-boot) + The kernel ignores parameters it doesn't understand. + `fromiso=` is intentionally not injected because it conflicts with `findiso=` + in Debian live-boot's `check_dev()`: `fromiso` mounts the ISO, then `findiso` + looks for the ISO file inside the mounted ISO (not found), unmounts it, + leaving orphaned loop devices that get re-scanned -> infinite loop. + `findiso=` alone covers Debian and NixOS. + `live-media-path=` is intentionally not injected because the default differs + per distro (`/live` for Debian, `/casper` for Ubuntu/PureOS, `/LiveOS` for + Fedora); leaving it unset lets each distro use its own default. +6. **Layer 3: kexec-select-boot**: Launch the standard boot menu with `-u` + (unique entries, dedup sorted by name). + +### Initrd compatibility markers in the boot menu + +During Layer 1, `check_initrd_compat` writes per-initrd results to +`/tmp/kexec_initrd_compat.txt`. `kexec-select-boot` reads this file and shows +`[OK]` or `[!]` at the start of each menu line (before the entry name): + +| Marker | Meaning | Behavior | +|--------|---------|----------| +| `[OK]` | Initrd has the USB fs module (as .ko or modules.builtin) | Boot should work | +| `[!]` | Initrd has loadable modules but none for the USB fs type | May fail after kexec | +| (blank) | Initrd has zero .ko files: can't verify either way | Assume OK (minimal initrd) | +| (none) | Entry has no initrd (memtest, etc.) | No filesystem dependency | + +A `NOTE` (3-second sleep, cannot scroll past) is displayed before the menu +explaining the legend. Markers follow `doc/logging.md` accessibility rules: +text-based, serial-safe, not color-dependent. + +### Compatibility note for ext4 and vfat + +Initrds with no `.ko` files at all get no marker at all (blank): we can't +verify either way, so nothing is displayed. + +### Boot param injection + +When Layer 2 (loopback.cfg) resolves no GRUB variables, the following +parameters are injected unconditionally so the ISO initrd can find the USB +partition and the ISO file after kexec, regardless of which distribution's +init system it uses: + +| Parameter | Example | Used by | +|-----------|---------|---------| +| `iso-scan/filename=` | `/ISOs/foo.iso` | Ubuntu casper, Fedora dracut | +| `findiso=` | `/ISOs/foo.iso` | Debian live-boot, NixOS stage-1 | +| `img_dev=` | `/dev/disk/by-uuid/UUID` | Block device hint | +| `img_loop=` | `ISOs/foo.iso` | Loopback path | +| `iso=` | `UUID/ISOs/foo.iso` | Alternative path | +| `live-media=` | `/dev/disk/by-uuid/UUID` | Device filter (casper, live-boot) | + +--- + ## Stage 3: kexec-select-boot Called from the boot menu. Responsible for final verification and OS handoff. @@ -136,7 +231,7 @@ the TPM2 primary key was regenerated without updating the stored hash. `verify_checksums` checks the SHA-256 of every `/boot` file against `kexec_hashes.txt`, then verifies `kexec.sig` with `gpgv`. A hash mismatch or -invalid signature causes `die` — there is no "boot anyway" path. +invalid signature causes `die`: there is no "boot anyway" path. Optionally, root partition hashes are also checked if `CONFIG_ROOT_CHECK_AT_BOOT=y`. diff --git a/doc/busybox_perks.md b/doc/busybox_perks.md new file mode 100644 index 000000000..4fe6935b6 --- /dev/null +++ b/doc/busybox_perks.md @@ -0,0 +1,200 @@ +# BusyBox vs GNU: Heads usage reference + +Heads initrd scripts run under BusyBox v1.36.1, not GNU coreutils. +This documents every command usage across Heads scripts and the BusyBox +adaptations required. + +## dd + +BusyBox `dd` supports `status=`, `iflag=`, `oflag=` the same as GNU. + +**Usage in Heads**: +```bash +dd if="$file" bs=6 count=1 status=none # kexec-iso-init.sh: +dd bs=1 count=1 status=none # unpack_initramfs.sh:38 +dd bs="$segment_end" count=1 status=none # unpack_initramfs.sh:101 +``` + +## xxd + +**BusyBox quirk**: `xxd -p` pads the last line to 60 columns with spaces. +GNU xxd does not pad. + +**Usage in Heads**: +```bash +# Good: strips padding: +next_byte="$(dd bs=1 count=1 status=none | xxd -p | tr -d '\n ')" # unpack_initramfs.sh:38 +magic="$(dd if="$f" bs=6 count=1 status=none | xxd -p | tr -d '\n ')" # unpack_initramfs.sh:68 + +# Reverse: needs fold workaround (from etc/functions.sh:2701): +fold -w 60 | xxd -p -r +``` + +## cpio + +**BusyBox quirk**: Stops at first TRAILER. GNU reads past it and exits 2. + +**Heads pattern**: `cpio -i -d "${CPIO_ARGS[@]}" 2>/dev/null || true` +The `|| true` handles GNU's exit 2 on multi-segment archives. + +For multi-segment extraction (`unpack_initramfs.sh:94-106`), the TRAILER offset +is pre-computed and dd limits cpio's input to exactly one segment, so both +BusyBox and GNU behave identically. + +## grep + +**BusyBox quirks**: +- No `-b` (byte-offset) flag. GNU grep's `-b` reports byte offset + of each match. BusyBox lacks this. If you need byte positions + in binary streams, use `dd bs=1 skip=N count=M` instead. +- `-a` (text mode) is default — no-op on both BusyBox and GNU. +- `-o` (only-matching), `-n` (line number), `-H`/`-h` (filename + prefix) all work as expected. +- Extended regex (`grep -E`): `|` is alternation. `\|` is a literal + pipe character. +- Basic regex (`grep`, no -E): `\|` is alternation. `|` is a literal + pipe character. +- **Common mistake**: `grep -E "i915\|nouveau"` searches for the + literal string `i915|nouveau`, NOT for `i915` OR `nouveau`. + Correct: `grep -E "i915|nouveau"` or `grep "i915\|nouveau"`. + +**Heads pattern**: +```bash +# BRE (basic regex) for alternation: +grep "i915\|nouveau\|amdgpu\|radeon\|bochs" "$initrd" +# ERE (extended regex) for alternation — note plain |, not \|: +grep -E "i915|nouveau|amdgpu|radeon|bochs" "$initrd" +``` + +## stat + +Identical for `stat -c %s FILE`. + +**Usage in Heads**: +```bash +orig_size="$(stat -c %s "$unpack_archive")" # unpack_initramfs.sh:127 +rest_size="$(stat -c %s "$rest_archive")" # unpack_initramfs.sh:128 +rest_size="$(stat -c %s "$next_archive" 2>/dev/null || echo 0)" # unpack_initramfs.sh:147 +``` + +## find + +**Usage in Heads** (all supported by BusyBox): +```bash +find "$dir" -name "*.ko*" -type f 2>/dev/null | head -1 # kexec-iso-init.sh +find "$dir" -name '*.cfg' -type f 2>/dev/null # kexec-iso-init.sh +find "$dir" -name "*.ko*" 2>/dev/null | grep -q "ext4" # kexec-iso-init.sh +``` + +## gunzip / gzip / zcat + +Identical. Used via pipe in segment decompression: +```bash +gunzip | unpack_cpio # unpack_initramfs.sh:111 +``` + +## unxz / xzcat + +Identical: +```bash +unxz | unpack_cpio # unpack_initramfs.sh:115 +``` + +## zstd / zstd-decompress + +**Standalone binary** compiled at `build/x86/zstd-1.5.5/programs/zstd-decompress` +and included in the initrd via `Makefile:745` (`CONFIG_ZSTD`). Not a BusyBox +applet, but available in all boards with `CONFIG_ZSTD=y`. + +Usage via pipe (`unpack_initramfs.sh:119`): +```bash +zstd-decompress -d < input.zst # reads stdin, writes stdout +``` + +**Current code**: `(zstd-decompress -d 2>/dev/null || zstd -d 2>/dev/null || true) | unpack_cpio` +-- should work if `CONFIG_ZSTD=y` in the board config. + +## sed + +Identical for all patterns used in Heads: +```bash +sed 's|^/dev/||' # kexec-iso-init.sh (path stripping) +sed 's/^append //' # kexec-iso-init.sh (param extraction) +sed 's/^initrd //' # kexec-iso-init.sh (field extraction) +sed "s|\${$var}|$val|g" # kexec-iso-init.sh (GRUB var resolution) +``` + +## awk + +BusyBox awk is minimal but sufficient for Heads usage: +```bash +awk -v dev="$dev" 'index($1, dev) == 1 { print $3; exit }' /proc/mounts +awk '{print $2}' /proc/mounts +``` + +## strings + +BusyBox `strings` scans binary files character by character for printable +sequences. Supports `-f` (prefix filename), `-o` (octal offset), +`-t o|d|x` (offset radix), `-n LEN` (min string length, default 4). +`-a` (scan whole file) is the default. No byte-offset flag. + +```bash +strings "$vmlinuz" | grep -i "efifb\|simpledrm" # kernel binary scan +``` + +## tail + +BusyBox `tail -c +N` (start at byte N) works identically to GNU. Used +for byte-offset extraction from kernel images. + +```bash +tail -c+$((pos+8)) "$img" | zcat # offset + decompress +``` + +## tr + +BusyBox `tr` handles octal escape sequences (`\NNN`) via +`bb_process_escape_sequence()`. Named escapes (`\n`, `\t`, etc.) work +as expected. Octal sequences from shell variables (e.g. +`cf1='\037\213\010'`) are interpreted by `tr`, not by the shell. + +```bash +cf1='IKCFG_ST\037\213\010'; cf2='0123456789' +tr "$cf1\n$cf2" "\n$cf2=" < "$img" # extract-ikconfig +``` + +## cut / head / uniq / fold / basename / dirname / readlink + +All identical for Heads usage. No special BusyBox workarounds needed. + +## sort + +**BusyBox quirk**: `sort -k` keyed sort with `-u` deduplicates based on the +**entire line**, not the sort key. GNU sort deduplicates by the key alone. + +**Heads pattern**: Use `awk -F'|' '!seen[$1]++'` instead of `sort -t\| -k1 -u` +when deduplicating by a single field: +```bash +# Wrong under BusyBox (dedups by full line, not field 1): +sort -t\| -k1 -u + +# Correct under both: +awk -F'|' '!seen[$1]++' +``` + +## cat / mv / rm / mkdir / mktemp / printf / wc / xargs / echo + +All identical. No BusyBox workarounds needed. + +## Summary of required BusyBox workarounds + +| Command | Workaround | Where | +|---------|------------|-------| +| `xxd -p` | `tr -d '\n '` strips 60-col padding | `unpack_initramfs.sh` (multi-segment cpio) | +| `xxd -p -r` | `fold -w 60 \| xxd -p -r` | `etc/functions.sh` (tohex_plain) | +| `cpio` trailing data exit | `|| true` swallows GNU exit 2 | `unpack_initramfs.sh` (multi-segment cpio) | +| `grep -b` | Not available — use `dd bs=1 skip=N count=M` | kernel binary analysis | +| `grep -E` alternation | Use `|` not `\|` in ERE; use `\|` in BRE | initrd module detection | +| `sort -u` with `-k` | Dedups by full line, not key — use awk | `scan_options()` in kexec-select-boot.sh | +| `zstd` not in busybox | `/bin/zstd-decompress -d` standalone binary | `unpack_initramfs.sh` (initrd decompression) | diff --git a/doc/index.md b/doc/index.md new file mode 100644 index 000000000..3fd28177d --- /dev/null +++ b/doc/index.md @@ -0,0 +1,16 @@ +# Heads documentation index + +Quick reference: read the relevant doc when working on a topic. + +| File | What it covers | +|------|----------------| +| `architecture.md` | System architecture: coreboot -> kernel -> initrd, build system, config hierarchy | +| `boot-process.md` | Boot flow stages, ISO boot layers (Layer 1/2/3), [OK]/[!] marker legend | +| `busybox_perks.md` | GNU vs BusyBox command differences for all tools used in initrd scripts | +| `docker.md` | Docker-based build environment | +| `logging.md` | Log levels (STATUS, WARN, NOTE, INFO, DEBUG, TRACE) usage conventions | +| `modules.md` | Available tools: which are BusyBox applets vs standalone binaries | +| `security-model.md` | TPM, measured boot, trust chain | +| `tpm.md` | TPM 1.2 and 2.0 operations | +| `ux-patterns.md` | User interaction patterns (whiptail, CLI menu, confirm dialogs) | +| `iso_boot.md` | ISO boot parameter reference: what each kernel param does and which framework uses it | diff --git a/doc/iso_boot.md b/doc/iso_boot.md new file mode 100644 index 000000000..6405a8554 --- /dev/null +++ b/doc/iso_boot.md @@ -0,0 +1,194 @@ +# ISO Boot Parameter Reference + +## Design + +Heads kexec-boots ISO files from USB by injecting kernel command-line +parameters that tell the target initramfs where to find the ISO on the +USB drive. The parameters are designed to be universal: every common +Linux distribution's initramfs framework finds the ISO via at least one +of the injected parameters. + +The layered boot approach (mount ISO -> check initrd compat -> parse +loopback.cfg -> interactive menu) was inspired by +[u-root's boot/iso implementation](https://github.com/u-root/u-root/pull/3578). + +## Universal parameter set + +The following parameters are injected unconditionally. Each specifies +the ISO location in a format that a different initramfs framework +understands. Unrecognised parameters are harmless: the kernel passes +them to userspace and each initramfs ignores what it doesn't understand. + +``` +iso-scan/filename=/$ISO_PATH +findiso=/$ISO_PATH +img_dev=/dev/disk/by-uuid/$DEV_UUID +img_loop=$ISO_PATH +iso=$DEV_UUID/$ISO_PATH +live-media=/dev/disk/by-uuid/$DEV_UUID +``` + +Where: +- `ISO_PATH`: path to the ISO file relative to the USB device root + (e.g. `ISOs/debian-live-13.2.0-amd64-xfce.iso`) +- `DEV_UUID`: UUID of the USB block device +- `$ISO_PATH` resolves to the relative path (e.g. `ISOs/file.iso`) +- `/$ISO_PATH` resolves to the absolute path within a mounted device + (e.g. `/ISOs/file.iso`) +- `$DEV_UUID` resolves to the UUID string +- `/dev/disk/by-uuid/$DEV_UUID` resolves to a stable block device path + +## Framework coverage + +| Param | live-boot (Debian) | casper (Ubuntu) | dracut (Fedora) | NixOS stage-1 | +|-------|--------------------|-----------------|----------------|---------------| +| `iso-scan/filename=` |: | ✓ scans devices | ✓ scans devices |: | +| `findiso=` | ✓ scans devices |: |: | ✓ scans devices | +| `img_dev=` + `img_loop=` |: |: |: |: | +| `live-media=` | ✓ device filter | ✓ device filter |: |: | + +### live-boot (Debian, Tails, Devuan) + +`findiso=` scans all block devices, mounts each, and checks for +`${mountpoint}/$FINDISO`: so the value must be a relative path from +the device root. + +`live-media=` narrows the device scan to a specific device. + +Squashfs lives in `/live/` on the ISO. + +### casper (Ubuntu, PureOS) + +`iso-scan/filename=` scans all block devices and looks for the ISO at +the given relative path. `live-media=` specifies the block device. +`live-media-path` defaults to `casper` and does not need to be +specified. + +Squashfs lives in `/casper/` on the ISO. + +### dracut/dmsquash-live (Fedora, RHEL, Kicksecure) + +`iso-scan/filename=` is supported identically to casper: it scans +block devices and loop-mounts the ISO. `root=live:*` is the primary +specification (LABEL, UUID, CDLABEL, or `/path/file.iso`). + +Squashfs lives in `/LiveOS/` by default, but Kicksecure overrides to +`rd.live.dir=live`. + +### NixOS stage-1 + +`findiso=` scans block devices identically to Debian live-boot +mounts each device and checks for `${mountpoint}$isoPath`. + +## Parameter value rules + +1. **`iso-scan/filename=/$ISO_PATH`**: relative path prefixed with `/` + (absolute within the mounted filesystem). The initramfs mounts each + block device and checks for the file at that path. + +2. **`findiso=/$ISO_PATH`**: same format as `iso-scan/filename=`. + The initramfs scans block devices for the file. + +3. **`img_dev=/dev/disk/by-uuid/$DEV_UUID`**: block device path only + (no file path appended). + +4. **`img_loop=$ISO_PATH`**: relative path without `/` prefix. + +5. **`live-media=/dev/disk/by-uuid/$DEV_UUID`**: block device path + only (no file path appended). This is NOT a file path: the + initramfs uses it to identify which device to scan. + +## Known Limitations + +### Debian DVD (installer) ISOs are not supported + +Debian DVD images (e.g. `debian-13.2.0-amd64-DVD-1.iso`) use the +Debian installer initramfs, which is fundamentally different from the +live-boot framework used by Debian Live images. + +**How the Debian installer finds media:** + +After kexec, `cdrom-detect` in the installer initrd enumerates block +devices via `list-devices cd`, `list-devices usb-partition`, and +`list-devices disk`. It mounts each device as iso9660 or vfat and +checks for `/cdrom/.disk/info`: it does NOT scan for an ISO file on a +filesystem. + +**Why it doesn't work with Heads:** + +The loop device Heads uses to mount the ISO at `/boot` does not +survive kexec. The installer then scans the USB block device +(`/dev/sda`), finds an ext4 partition (Heads' USB format), tries to +mount it as iso9660: which fails: and prompts for manual +configuration. The installer initrd has no `iso-scan` or `findiso` +support; `iso-scan/filename=` and `findiso=` parameters are ignored. + +**Workaround:** Write the ISO directly to a USB drive with `dd` and +boot from Heads' external USB boot option, bypassing the ISO file +approach. + +### Graphical output after kexec on coreboot boards + +Heads' initrd runs under a kernel compiled with `CONFIG_FB_EFI=y`. +coreboot/libgfxinit initialises the display hardware (PLL, timings, +scanout buffer) and presents it to Linux as an efifb-compatible +framebuffer (`screen_info` set to `VIDEO_TYPE_EFI`). Heads' kernel +binds efifb, maps the scanout buffer, and the Heads console and +whiptail GUI are visible. Without this, Heads would have no +framebuffer at all. + +After kexec, the display hardware remains in its initialised state +(kexec does not reset devices: the controller keeps scanning out +from the same physical address). The target kernel must also have +`CONFIG_FB_EFI=y` to adopt the same framebuffer: simplefb and +simpledrm are not compatible. + +**Known limitation: lost framebuffer address:** + +Linux no longer exposes the physical framebuffer address to userspace. +kexec-tools obtains `smem_start = 0` from `FBIOGET_FSCREENINFO` and +writes `lfb_base = 0` into the new kernel's boot params. The target +kernel's efifb sees a zero address and cannot map the framebuffer +the display stays blank. + +The display controller is still scanning out the correct memory (set +up by coreboot and preserved across kexec), but efifb does not know +where to write because the address was lost in transit. + +Historically, Heads worked around this by using i915 with +`CONFIG_DRM_FBDEV_LEAK_PHYS_SMEM=y` (leaking the physical address), +but that was suboptimal. The current efifb approach is also +suboptimal: we are waiting for improvements in coreboot, Linux, and +kexec-tools to properly convey the framebuffer state across kexec. + +**TPM Disk Unlock Key** works around this for encrypted disks by +injecting the LUKS key before kexec: the initramfs never prompts for +a passphrase on a blank display. Serial console is unaffected. + +#### Layer 1 display driver check + +Before showing boot options, Heads inspects each initrd for DRM/KMS +kernel modules (i915, nouveau, amdgpu, bochs, virtio-gpu, etc.). +These drivers reinitialize the display after kexec and make the booted +OS visible regardless of efifb availability. + +Entries where at least one such driver is found get `[OK]` markers. +Where none is found and the initrd has other loadable modules, a warning +dialog is shown before the boot menu. + +#### ISOs without display drivers (CorePlus/TinyCore) + +CorePlus and TinyCore ship a minimal kernel that uses `vesafb.ko` +(VESA framebuffer) as its only display driver. `vesafb` requires +VESA BIOS, which is unavailable under coreboot without a +Compatibility Support Module (CSM). + +The ISO's userspace extensions (`Xvesa.tcz`, window managers, etc.) +are loaded by TinyCore's init after boot and do not provide kernel +display drivers. After kexec, the target kernel has no KMS or +framebuffer driver for any GPU: the display stays blank even though +the OS boots and runs normally. + +All other distributions tested (Debian, Ubuntu, Fedora, PureOS, +NixOS, Tails) ship at least one DRM/KMS driver in their initrd and +pass the display check. diff --git a/doc/logging.md b/doc/logging.md index bcf5dd0ca..6e815ff69 100644 --- a/doc/logging.md +++ b/doc/logging.md @@ -302,6 +302,67 @@ Do NOT use INPUT for yes/no confirmation dialogs inside whiptail GUI flows — u INPUT is appropriate for inline `[Y/n]` confirmations in terminal-mode scripts (recovery shell, setup wizards, debug paths) where a full whiptail dialog would be out of place. +## ANSI Escape Sequences + +Some callers pass inline ANSI escape sequences to NOTE, STATUS, and STATUS_OK for console color. +For example, the dongle firmware version display uses `\033[1;33m` (yellow) for versions below the +recommended minimum, and the boot option menu uses `\033[0;32m[OK]\033[0m` (green OK marker). + +These ANSI sequences are preserved in all output paths — both the console and +debug.log retain the codes so tools like `less -R` or `cat` render the colors. +Logging functions write the message as-is to both destinations without stripping. + +## ISO Boot Message Conventions + +The ISO boot path (`kexec-iso-init.sh` → `kexec-select-boot.sh`) follows specific message +conventions to produce a readable, scannable user flow. + +### STATUS/STATUS_OK pairing + +Announce an operation with STATUS, confirm success with STATUS_OK: + +``` +STATUS "Mounting ISO as loopback" → >> Mounting ISO as loopback +... process runs ... +STATUS_OK "ISO mounted at /boot" → OK ISO mounted at /boot +``` + +This gives users clear start/end brackets for each step. +Use NOTE instead of STATUS_OK when the result is surprising or security-relevant +(the unsigned-ISO path uses NOTE "Proceeding with unsigned ISO boot"). + +### Layer-prefixed DEBUG messages + +Internal ISO boot stages are identified by "Layer N:" at DEBUG level: + +| Prefix | Stage | Purpose | +|---|---|---| +| `Layer 1:` | Initramfs filesystem compat | USB fs type detection, initrd unpack, module search | +| `Layer 2:` | loopback.cfg fast path | GRUB variable resolution in ISO boot config | + +The Layer prefix makes it possible to grep debug.log for the relevant section +during troubleshooting without reading the entire file. + +### Sub-step STATUS demotion + +When a logical operation is already bracketed by a STATUS/STATUS_OK pair, +sub-steps within the pair should be at DEBUG level — not STATUS — to avoid +redundant console output: + +```bash +# CORRECT — outer STATUS brackets the operation, inner detail is DEBUG +STATUS "Checking USB filesystem compatibility with ISO initramfs" +DEBUG "Layer 1: USB drive is ext4 - verifying initramfs module support" +... work happens ... +STATUS_OK "USB filesystem compatibility check complete" +``` + +### Once-per-session legend + +The compatibility marker legend (`[OK]=compatible [!]=may fail after kexec`) uses NOTE +with a 3-second sleep. It is guarded by `/tmp/kexec_compat_shown` to show only once +per session (same pattern as `hotpkey_fw_display()`). + ## Output Levels Users can choose one of three output levels for console information. @@ -333,7 +394,7 @@ color is never the sole signal; text prefixes carry meaning independently): \* INPUT prints a newline after user input, not before. -debug.log and /dev/kmsg always receive plain text without ANSI codes. +debug.log and /dev/kmsg include ANSI escape sequences as passed by callers. All console output goes to **`/dev/console`** — the kernel console device, which follows the `console=` kernel parameter and reaches whatever output the system was configured for diff --git a/doc/modules.md b/doc/modules.md new file mode 100644 index 000000000..ad94e9638 --- /dev/null +++ b/doc/modules.md @@ -0,0 +1,79 @@ +# Heads modules (tools included in the initrd) + +Tools available in the Heads initrd are defined by `CONFIG_*` flags in board configs +(`boards/*/*.config`) and compiled by the top-level `Makefile`. Each `bin_modules-$(CONFIG_*) += ` +line adds a package to `tools.cpio` (one of six CPIO archives assembled into the initrd). + +Not all tools are BusyBox applets — many are standalone binaries compiled as separate packages. + +## Module list (from Makefile:718-745) + +| Makefile line | Config flag | Package | Type | +|---|---|---|---| +| 718 | `CONFIG_KEXEC` | kexec | Standalone | +| 719 | `CONFIG_TPMTOTP` | tpmtotp | Standalone | +| 720 | `CONFIG_PCIUTILS` | pciutils | Standalone | +| 721 | `CONFIG_FLASHROM` | flashrom | Standalone | +| 722 | `CONFIG_FLASHPROG` | flashprog | Standalone | +| 723 | `CONFIG_CRYPTSETUP` | cryptsetup | Standalone | +| 724 | `CONFIG_CRYPTSETUP2` | cryptsetup2 | Standalone | +| 725 | `CONFIG_GPG` | gpg | Standalone | +| 726 | `CONFIG_GPG2` | gpg2 | Standalone | +| 727 | `CONFIG_PINENTRY` | pinentry | Standalone | +| 728 | `CONFIG_LVM2` | lvm2 | Standalone | +| 729 | `CONFIG_DROPBEAR` | dropbear | Standalone | +| 730 | `CONFIG_FLASHTOOLS` | flashtools | Standalone | +| 731 | `CONFIG_NEWT` | newt | Standalone | +| 732 | `CONFIG_CAIRO` | cairo | Standalone | +| 733 | `CONFIG_FBWHIPTAIL` | fbwhiptail | Standalone | +| 734 | `CONFIG_HOTPKEY` | hotp-verification | Standalone | +| 735 | `CONFIG_MSRTOOLS` | msrtools | Standalone | +| 736 | `CONFIG_NKSTORECLI` | nkstorecli | Standalone | +| 737 | `CONFIG_UTIL_LINUX` | util-linux | Standalone | +| 738 | `CONFIG_OPENSSL` | openssl | Standalone | +| 739 | `CONFIG_TPM2_TOOLS` | tpm2-tools | Standalone | +| 740 | `CONFIG_BASH` | bash | Standalone | +| 741 | `CONFIG_POWERPC_UTILS` | powerpc-utils | Standalone | +| 742 | `CONFIG_IO386` | io386 | Standalone | +| 743 | `CONFIG_IOPORT` | ioport | Standalone | +| 744 | `CONFIG_KBD` | kbd | Standalone | +| 745 | **`CONFIG_ZSTD`** | **zstd** | **Standalone** | +| 746 | `CONFIG_E2FSPROGS` | e2fsprogs | Standalone | + +## BusyBox applets (always available) + +BusyBox v1.36.1 provides the following applets relevant to Heads scripts. +These are always available regardless of board config. + +```text +[, [[, arch, arp, ascii, ash, awk, base32, basename, blkid, blockdev, +bunzip2, bzcat, bzip2, cat, chattr, chmod, chroot, clear, cmp, cp, +cpio, crc32, cttyhack, cut, date, dc, dd, devmem, df, diff, dirname, +dmesg, du, echo, env, expr, factor, fallocate, false, fdisk, find, +fold, fsck, fsfreeze, getopt, grep, groups, gunzip, gzip, hd, head, +hexdump, hexedit, hostid, hwclock, i2cdetect, i2cdump, i2cget, i2cset, +id, ifconfig, insmod, install, ip, kill, killall, killall5, less, link, +ln, loadkmap, losetup, ls, lsattr, lsmod, lsof, lsscsi, lsusb, lzcat, +lzma, md5sum, mkdir, mkdosfs, mkfifo, mkfs.vfat, mknod, mktemp, +modinfo, more, mount, mv, nc, nl, nproc, nslookup, ntpd, partprobe, +paste, patch, pgrep, pidof, ping, pkill, printf, ps, pwd, readlink, +realpath, reboot, reset, resume, rm, rmdir, route, sed, seedrng, seq, +setfattr, setpriv, setserial, setsid, sh, sha1sum, sha256sum, sha3sum, +sha512sum, shred, sleep, sort, ssl_client, stat, strings, stty, sync, +sysctl, tail, tar, tee, test, tftp, time, top, touch, tr, tree, true, +truncate, tsort, tty, udhcpc, umount, uname, uniq, unlzma, unxz, unzip, +usleep, vconfig, vi, wc, wget, which, xargs, xxd, xz, xzcat, zcat +``` + +## How to check what's available in a build + +```bash +# List all BusyBox applets: +busybox --list + +# Check if a standalone module is included: +which zstd-decompress kexec-tools gpg 2>/dev/null + +# Check board config for CONFIG_ZSTD: +grep CONFIG_ZSTD boards/*/*.config +``` diff --git a/doc/ux-patterns.md b/doc/ux-patterns.md index 08998243a..85f00c52c 100644 --- a/doc/ux-patterns.md +++ b/doc/ux-patterns.md @@ -381,3 +381,116 @@ fi This ensures prompt/read always use the correct device regardless of how the caller has redirected stdout/stderr (e.g. `2>/tmp/whiptail`). + +--- + +## Main Menu Background Color State + +`BG_COLOR_MAIN_MENU` (exported by `gui-init.sh` and `gui-init-basic.sh`) controls the +background color of all `whiptail_type` menus. It acts as a **visual trust indicator**: +the menu background changes color based on the measured integrity state of the system. + +| State | Value | Background | When triggered | +|---|---|---|---| +| Trusted | `"normal"` | Default (transparent in fbwhiptail, white/gray in newt) | Boot environment is healthy: /boot is mounted, signatures verified | +| Warning | `"warning"` | Yellow/amber | Transient or non-critical issues: unsigned ISO, missing optional config | +| Untrusted | `"error"` | Red | Fatal or integrity issues: no /boot partition, TPM hash mismatch, failed signature check | + +Transitions happen at specific points in the boot flow: + +```bash +# gui-init.sh trusted start: +export BG_COLOR_MAIN_MENU="normal" + +# On mount failure — no /boot partition found: +BG_COLOR_MAIN_MENU="error" + +# After user selects a valid boot device: +BG_COLOR_MAIN_MENU="normal" + +# On TPM/tampering detection in integrity report: +BG_COLOR_MAIN_MENU="error" + +# After successful reset/update flow: +BG_COLOR_MAIN_MENU="normal" + +# Warning state for transient issues: +BG_COLOR_MAIN_MENU="warning" +``` + +### Inheritance + +`whiptail_type` passes `BG_COLOR_MAIN_MENU` as its type argument. Since `whiptail_type` +routes through `whiptail_error` / `whiptail_warning` / `_whiptail_preprocess_args` based +on the string value, setting `BG_COLOR_MAIN_MENU=error` makes every subsequent +`whiptail_type` call render with a red background — including submenus and selection +dialogs. This is intentional: the red background signals that the system is in an +untrusted state regardless of which menu the user is looking at. + +To inspect the current state in debug.log: +``` +DEBUG: whiptail_type: type=error args=--title Select your ISO boot option --menu ... +``` + +### When to change BG_COLOR_MAIN_MENU + +- **Set to `error`** only when the system cannot verify /boot integrity: missing TPM hash, + signature check failure, no /boot partition found, tampering detected. +- **Set to `warning`** for non-fatal issues: unsigned ISO selected, optional config missing. +- **Reset to `normal`** after the user resolves the condition: boot device selected, + TPM reset completed, integrity report acknowledged. +- Do NOT set it in sub-scripts called from the main menu (e.g. ISO init scripts). + The main menu loop in `gui-init.sh` owns the state. + +--- + +## Boot Option Compatibility Markers + +The ISO boot flow displays **compatibility markers** before each boot option in the +menu to indicate whether the initramfs supports the USB filesystem: + +| Marker | Color | Meaning | +|---|---|---| +| `[OK]` | Green | Ready — USB filesystem and display driver both confirmed present | +| `[!]` | Yellow | May fail — missing USB filesystem module or display driver | +| (blank) | Default | Unable to check — initrd has no loadable modules to inspect | + +A legend explaining these markers is shown once per session via NOTE. +When all entries pass both checks, the legend shows the short form. +When any entry has a `[!]` marker, the full legend includes the warning: + +``` +[OK]=ready (USB+display) [!]=may fail (blank)=unable to check +``` +NOTE: [OK]=ready (USB+display) [!]=may fail after kexec (blank)=could not check + +The markers indicate whether the initramfs can read the USB filesystem and drive +the display after kexec. "Not checked" usually means the kernel module or +framebuffer driver is built into the kernel (no .ko file to inspect). +``` + +The legend uses a 3-second NOTE sleep and is guarded by `/tmp/kexec_compat_shown` +(mirroring the `hotpkey_fw_display()` once-per-session pattern) so it is not repeated +if the user re-enters the boot menu. + +### Per-initrd tracking + +Each initrd referenced by the ISO's boot config is independently checked. The results +are written to `/tmp/kexec_initrd_compat.txt` (filesystem) and +`/tmp/kexec_fb_compat.txt` (framebuffer): + +``` +casper/initrd [OK] +``` + +`boot_marker()` in `kexec-select-boot.sh` reads `/tmp/kexec_initrd_compat.txt` to +assign the marker for each menu entry. Initrds without an entry in the compat file +get no marker (treated as "cannot verify"). + +The legend label `compatible(fs+fb)` indicates that both the USB filesystem module +and a framebuffer driver (efifb/bochs) are verified. A separate whiptail warning +appears before the menu if no initrd has a known framebuffer driver. + +This ensures every initrd is evaluated independently — mixing verified and unverified +initrds in the same ISO shows clear per-entry markers rather than a single global +status. diff --git a/initrd/bin/extract-ikconfig b/initrd/bin/extract-ikconfig new file mode 100755 index 000000000..b03f5a2cb --- /dev/null +++ b/initrd/bin/extract-ikconfig @@ -0,0 +1,82 @@ +#!/bin/sh +# ---------------------------------------------------------------------- +# extract-ikconfig - Extract the .config file from a kernel image +# +# This will only work when the kernel was compiled with CONFIG_IKCONFIG. +# +# Original script (c) 2009,2010 Dick Streefland +# Licensed under GPL v2, from the Linux kernel tree: +# https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git +# (scripts/extract-ikconfig) +# +# Heads adaptation: +# Replaced grep -abo with od -A d for byte-offset searches +# (BusyBox grep lacks -b). Decompressors mapped to Heads paths. +# Use strings -t d to find IKCFG_ST marker instead of tr pipeline. +# ---------------------------------------------------------------------- + +# IKCFG_ST marker + gzip magic (\037 = 0x1f, \213 = 0x8b, \010 = 0x08) +cf_marker="IKCFG_ST\x1f\x8b\x08" + +find_decimal_offset() +{ + local img="$1" pattern="$2" + od -A d -t x1 "$img" 2>/dev/null | grep "$pattern" | head -1 | awk '{print $1}' +} + +dump_config() +{ + local pos + # Use strings to find IKCFG_ST with byte offset (-t d = decimal) + pos=$(strings -t d "$1" 2>/dev/null | grep "IKCFG_ST" | head -1 | awk '{print $1}') + if [ -n "$pos" ]; then + # IKCFG_ST is 8 chars, gzip magic starts at pos+8 (0-based). + # tail -c+N uses 1-based indexing, so add 1: pos+9. + tail -c+$((pos+9)) "$1" | zcat > "$tmp1" 2>/dev/null + if [ $? != 1 ]; then + cat "$tmp1" + exit 0 + fi + fi +} + +try_decompress() +{ + local img="$1" needle="$2" decompressor="$3" + local pos + pos=$(find_decimal_offset "$img" "$needle") + if [ -n "$pos" ]; then + tail -c+$pos "$img" | $decompressor > "$tmp2" 2>/dev/null + dump_config "$tmp2" + fi +} + +# Check invocation +if [ $# -ne 1 -o ! -s "$1" ]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +img="$1" +tmp1="/tmp/ikconfig1.$$" +tmp2="/tmp/ikconfig2.$$" +trap "rm -f $tmp1 $tmp2" 0 + +# Initial attempt for uncompressed images +dump_config "$img" + +# Retry after decompression (ordered by commonality under Heads) +# gzip: 1f 8b 08 +try_decompress "$img" "1f 8b 08" gunzip +# zstd: 28 b5 2f fd +try_decompress "$img" "28 b5 2f fd" "zstd-decompress -d" +# xz: fd 37 7a 58 5a 00 +try_decompress "$img" "fd 37 7a 58 5a 00" unxz +# bzip2: 42 5a 68 +try_decompress "$img" "42 5a 68" bunzip2 +# lzma: 5d 00 00 +try_decompress "$img" "5d 00 00 00" unlzma + +# Not found +echo "$0: Cannot find kernel config." >&2 +exit 1 diff --git a/initrd/bin/kexec-boot.sh b/initrd/bin/kexec-boot.sh index 4043c3118..18d32aefc 100755 --- a/initrd/bin/kexec-boot.sh +++ b/initrd/bin/kexec-boot.sh @@ -1,16 +1,31 @@ #!/bin/bash -# Launches kexec from saved configuration entries +# Execute kexec to boot an OS kernel from parsed boot configuration +# +# This script takes a boot entry (from kexec-parse-boot.sh) and executes +# kexec to load and boot the OS kernel. It handles: +# - ELF kernels (standard Linux) +# - Multiboot kernels (Xen) +# - Initial ramdisks (initrd) +# - Kernel command line modification (add/remove parameters) +# +# Options: +# -b Boot directory (e.g., /boot) +# -e Entry string (name|kexectype|kernel path[|initrd][|append]) +# -r Parameters to remove from cmdline +# -a Parameters to add to cmdline +# -o Override initrd path +# -f Dry run: print files only +# -i Dry run: print initrd only +# set -e -o pipefail . /tmp/config . /etc/functions.sh -TRACE_FUNC - dryrun="n" printfiles="n" printinitrd="n" -while getopts "b:e:r:a:o:fi" arg; do - case $arg in + while getopts "b:e:r:a:o:fi" arg; do + case $arg in b) bootdir="$OPTARG" ;; e) entry="$OPTARG" ;; r) cmdremove="$OPTARG" ;; @@ -18,18 +33,23 @@ while getopts "b:e:r:a:o:fi" arg; do o) override_initrd="$OPTARG" ;; f) dryrun="y"; printfiles="y" ;; i) dryrun="y"; printinitrd="y" ;; - esac -done + esac + done -if [ -z "$bootdir" -o -z "$entry" ]; then - DIE "Usage: $0 -b /boot -e 'kexec params|...|...'" -fi + if [ -z "$bootdir" -o -z "$entry" ]; then + DIE "Usage: $0 -b /boot -e 'kexec params|...|...'" + fi -bootdir="${bootdir%%/}" + bootdir="${bootdir%%/}" -kexectype=`echo $entry | cut -d\| -f2` -kexecparams=`echo $entry | cut -d\| -f3- | tr '|' '\n'` -kexeccmd="kexec" + kexectype=$(echo $entry | cut -d\| -f2) + kexecparams=$(echo $entry | cut -d\| -f3- | tr '|' '\n') + kexeccmd="kexec" + + DEBUG "kexec-boot: entry='$entry'" + DEBUG "kexec-boot: kexectype='$kexectype'" + DEBUG "kexec-boot: kexecparams='$kexecparams'" + DEBUG "kexec-boot: cmdadd='$cmdadd'" cmdadd="$CONFIG_BOOT_KERNEL_ADD $cmdadd" cmdremove="$CONFIG_BOOT_KERNEL_REMOVE $cmdremove" @@ -53,6 +73,9 @@ fix_file_path() { adjusted_cmd_line="n" adjust_cmd_line() { + DEBUG "adjust_cmd_line: original cmdline='$cmdline'" + cmdline=$(echo "$cmdline" | sed 's/---.*$//' | xargs) + DEBUG "adjust_cmd_line: after stripping --- separator='$cmdline'" if [ -n "$cmdremove" ]; then for i in $cmdremove; do cmdline=$(echo $cmdline | sed "s/\b$i\b//g") @@ -60,7 +83,9 @@ adjust_cmd_line() { fi if [ -n "$cmdadd" ]; then + DEBUG "adjust_cmd_line: cmdadd='$cmdadd'" cmdline="$cmdline $cmdadd" + DEBUG "adjust_cmd_line: final cmdline='$cmdline'" fi adjusted_cmd_line="y" } @@ -71,11 +96,10 @@ if [ "$CONFIG_DEBUG_OUTPUT" = "y" ];then fi module_number="1" -while read line -do - key=`echo $line | cut -d\ -f1` - firstval=`echo $line | cut -d\ -f2` - restval=`echo $line | cut -d\ -f3-` +while read line; do + key=$(echo $line | cut -d\ -f1) + firstval=$(echo $line | cut -d\ -f2) + restval=$(echo $line | cut -d\ -f3-) if [ "$key" = "kernel" ]; then fix_file_path if [ "$kexectype" = "xen" ]; then @@ -112,7 +136,7 @@ do fi fi fi - module_number=`expr $module_number + 1` + module_number=$((module_number + 1)) kexeccmd="$kexeccmd --module \"$filepath $cmdline\"" fi if [ "$key" = "initrd" ]; then @@ -149,12 +173,23 @@ fi if [ "$dryrun" = "y" ]; then exit 0; fi +DEBUG "kexec-boot: cmdadd='$cmdadd'" +DEBUG "kexec-boot: cmdremove='$cmdremove'" +DEBUG "kexec-boot: final cmdline='$cmdline'" + +# Kernel command line length limit is typically 2047 bytes (CONFIG_COMMAND_LINE_SIZE). +# If the final cmdline exceeds this, warn before attempting kexec. +cmdline_len=${#cmdline} +if [ "$cmdline_len" -gt 2047 ]; then + WARN "Kernel command line is $cmdline_len bytes, kernel limit is 2047. USB boot may fail." + WARN "Check for duplicate kernel options in the ISO's boot configuration." + WARN "Report to ISO distributor: duplicate options waste command line space." +fi + STATUS "Loading the new kernel" DEBUG "kexec command: $kexeccmd" -# DO_WITH_DEBUG captures the debug output from stderr to the log, we don't need -# it on the console as well -DO_WITH_DEBUG eval "$kexeccmd" 2>/dev/null \ -|| DIE "Failed to load the new kernel" +DO_WITH_DEBUG eval "$kexeccmd" \ +|| DIE "Failed to load the new kernel${cmdline_len:+ (cmdline length: $cmdline_len bytes, kernel limit typically 2047)}" if [ "$CONFIG_DEBUG_OUTPUT" = "y" ];then #Ask user if they want to continue booting without echoing back the input (-s) diff --git a/initrd/bin/kexec-iso-init.sh b/initrd/bin/kexec-iso-init.sh index fa7b85ce9..c0c908ddd 100755 --- a/initrd/bin/kexec-iso-init.sh +++ b/initrd/bin/kexec-iso-init.sh @@ -1,5 +1,78 @@ #!/bin/bash # Boot from signed ISO +# +# Design overview +# --------------- +# This script implements layered ISO boot for Heads (in execution order): +# Layer 1 — initramfs compatibility check (verifies the ISO's initrd +# can read the USB filesystem and has a framebuffer driver +# before offering boot options) +# Layer 2 — loopback.cfg fast path (parse GRUB ${iso_path} / ${isofile} +# variables from the ISO's standard USB-boot config) +# Layer 3 — kexec-select-boot.sh (interactive boot menu, called at the +# end of this script — the user picks a kernel/initrd entry) +# +# The layered approach (loopback.cfg resolution, universal fallback params) +# was inspired by u-root's boot/iso implementation: +# https://github.com/u-root/u-root/pull/3578 +# +# The fallback ADD params below apply when Layer 2 finds no GRUB variables +# in the loopback.cfg. These params are universal — every common Linux +# initramfs framework finds the ISO via at least one of them. +# +# ADD parameter set (fallback, used when loopback.cfg has no GRUB vars): +# +# iso-scan/filename=/$ISO_PATH Ubuntu casper, Fedora dracut +# findiso=/$ISO_PATH Debian live-boot, NixOS stage-1 +# img_dev=/dev/disk/by-uuid/UUID block device containing the ISO +# img_loop=$ISO_PATH loopback file path (relative) +# iso=$DEV_UUID/$ISO_PATH UUID/path alternative +# live-media=/dev/disk/by-uuid/UUID device filter (casper, live-boot) +# +# Each initramfs framework picks what it understands and ignores the rest. +# Unrecognised kernel parameters are passed to userspace harmlessly. +# +# Changes from origin/master +# --------------------------- +# origin/master ADD was: +# fromiso=/dev/disk/by-uuid/UUID/ISO img_dev=... iso-scan/filename=... +# img_loop=... iso=... +# +# Our ADD adds: +# findiso=/$ISO_PATH — covers NixOS (not supported by origin/master) +# live-media=$ISO_DEV — device filter for casper/live-boot +# +# Our ADD removes: +# fromiso=... — conflicts with findiso in Debian live-boot's +# check_dev(): fromiso mounts the ISO, then +# findiso looks for the ISO file inside the +# mounted ISO (not found), unmounts it, leaving +# orphaned loop devices that get re-scanned → +# infinite loop. findiso alone covers Debian. +# +# Origin/master used fromiso= as the only Debian live-boot path. We +# replaced it with findiso= which Debian live-boot also supports, and +# which additionally covers NixOS. +# +# Layer 1 per-initrd markers +# --------------------------- +# Each unique initrd referenced by any boot entry gets an independent +# [OK] or [!] marker in /tmp/kexec_initrd_compat.txt (filesystem). +# Framebuffer markers are written to /tmp/kexec_fb_compat.txt. +# The per-initrd flag (initrd_supports_fs) is tracked separately from the +# global "any_supported" flag so that every initrd always gets an entry — +# no silent skips. ALL initrds are checked (no early break) so the +# compat file is complete. The global flag only controls whether a +# whiptail warning is shown. +# +# Minor changes from origin/master +# --------------------------------- +# - STATUS messages: "Mounting ISO as loopback" (was "Mounting ISO and +# booting"), added "Checking USB filesystem compatibility" and +# "Passing boot parameters so the OS can find the ISO" +# - Variable quoting: "$ADD_FILE" "$REMOVE_FILE" "$paramsdir" (was bare) +# - Syntax: $(...) instead of backticks for DEV_UUID +# - Removed stale "# Call kexec and indicate that hashes have been verified" comment set -e -o pipefail . /etc/functions.sh . /etc/gui_functions.sh @@ -22,56 +95,420 @@ ISO_PATH="${ISO_PATH##/}" if [ -r "$ISOSIG" ]; then # Signature found, verify it - gpgv.sh --homedir=/etc/distro/ "$ISOSIG" "$MOUNTED_ISO_PATH" \ - || DIE 'ISO signature failed' - STATUS_OK "ISO signature verified" -else - # No signature found, prompt user with warning + if ! gpgv.sh --homedir=/etc/distro/ "$ISOSIG" "$MOUNTED_ISO_PATH"; then + WARN "ISO signature verification failed: $ISOSIG" + WARN "See Recovery Shell wall message for instructions to sign or boot unsigned" + DIE "ISO signature verification failed" + else + STATUS_OK "ISO signature verified" + # Skip unsigned warning — jump past the unsigned block + skip_unsigned_warning="y" + fi +fi + +if [ "$skip_unsigned_warning" != "y" ]; then + # No valid signature — prompt user with warning WARN "No signature found for ISO" if [ -x /bin/whiptail ]; then if ! whiptail_warning --title 'UNSIGNED ISO WARNING' --yesno \ - "WARNING: UNSIGNED ISO DETECTED\n\nThe selected ISO file:\n$MOUNTED_ISO_PATH\n\nDoes not have a detached signature (.sig or .asc file).\n\n\nThis means the integrity and authenticity of the ISO cannot be verified.\nBooting unsigned ISOs is potentially unsafe.\n\nDo you want to proceed with booting this unsigned ISO?" \ + "WARNING: UNSIGNED ISO DETECTED\n\nThe selected ISO file:\n$MOUNTED_ISO_PATH\n\nDoes not have a detached signature.\nIntegrity and authenticity cannot be verified.\n\nCancel to Recovery Shell for instructions on signing this ISO\nwith your GPG key, or boot unsigned now.\n\nBoot unsigned?" \ 0 80; then - DIE "Unsigned ISO boot cancelled by user" + exit 1 fi else WARN "The selected ISO file does not have a detached signature" WARN "Integrity and authenticity of the ISO cannot be verified" - WARN "Booting unsigned ISOs is potentially unsafe" + WARN "Cancel to Recovery Shell for instructions on signing this ISO" + WARN "with your GPG key, or boot unsigned now" INPUT "Do you want to proceed anyway? (y/N):" -n 1 response if [ "$response" != "y" ] && [ "$response" != "Y" ]; then - DIE "Unsigned ISO boot cancelled by user" + exit 1 fi fi +fi + +if [ "$skip_unsigned_warning" != "y" ]; then NOTE "Proceeding with unsigned ISO boot" fi -STATUS "Mounting ISO and booting" -mount -t iso9660 -o loop $MOUNTED_ISO_PATH /boot \ +STATUS "Mounting ISO as loopback" +mount -t iso9660 -o loop "$MOUNTED_ISO_PATH" /boot \ || DIE '$MOUNTED_ISO_PATH: Unable to mount /boot' +STATUS_OK "ISO mounted at /boot" + +DEV_UUID=$(blkid "$DEV" | tail -1 | tr " " "\n" | grep UUID | cut -d\" -f2) + +# --------------------------------------------------------------------------- +# Layer 1: initramfs compatibility check (filesystem + framebuffer) +# --------------------------------------------------------------------------- +STATUS "Checking initramfs compatibility with ISO boot environment" +# Before proposing boot options, verify the ISO's initramfs contains a kernel +# module for the USB filesystem type, and a framebuffer driver (efifb) that can +# drive the display after kexec without DRM/KMS reinit. If the initrd can't +# read the partition where the ISO lives, or can't drive the display, the boot +# will fail or be blind after kexec. +# +# We find initrd paths by parsing the ISO's boot configs — the same way +# kexec-select-boot.sh enumerates boot entries. GRUB/syslinux configs +# specify initrd via "initrd /path" directives, so we extract those paths +# rather than searching the entire ISO filesystem tree. +# +# Multiple entries may reference different initrds (e.g., text vs graphical +# installer). We check ALL unique initrds and accept if ANY one has the +# needed USB filesystem module. This handles hybrid ISOs where one initrd +# supports ext4 and another doesn't. +# +# The kernel module for a filesystem almost always matches the blkid +# TYPE (ext4 → ext4.ko, btrfs → btrfs.ko). Only vfat/msdos differ +# (kernel module is "fat"), handled by initrd_fs_type_to_kmod(). +# No hardcoded module list — any filesystem type is supported. + +# Map blkid filesystem type to kernel module name. +# The kernel module for a filesystem is almost always the same as the +# blkid TYPE string (ext4 → ext4, btrfs → btrfs, xfs → xfs). +# Only vfat/msdos are exceptions (kernel module is "fat", not "vfat"). +initrd_fs_type_to_kmod() { + case "$1" in + vfat|msdos) echo "fat" ;; + *) echo "$1" ;; + esac +} + +check_initrd_compat() { + local dev="$1" + local bootdir="$2" + + local fstype + # BusyBox blkid doesn't support -o value consistently — parse TYPE from full output + fstype=$(blkid "$dev" 2>/dev/null | tr ' ' '\n' | sed -n 's/^TYPE="\(.*\)"$/\1/p') + # blkid on a whole disk (e.g. /dev/sda) returns no TYPE — scan /dev for partitions + if [ -z "$fstype" ]; then + local devbase="${dev#/dev/}" + for part in /dev/${devbase}*; do + [ "$part" = "$dev" ] && continue + [ -b "$part" ] || continue + fstype=$(blkid "$part" 2>/dev/null | tr ' ' '\n' | sed -n 's/^TYPE="\(.*\)"$/\1/p') + [ -n "$fstype" ] && DEBUG "Layer 1: resolved $dev -> $part ($fstype)" && break + done + fi + # Still no TYPE? The USB is already mounted by media-scan.sh — check /proc/mounts + if [ -z "$fstype" ]; then + fstype=$(awk -v dev="$dev" 'index($1, dev) == 1 { print $3; exit }' /proc/mounts 2>/dev/null) + [ -n "$fstype" ] && DEBUG "Layer 1: resolved $dev via /proc/mounts ($fstype)" + fi + # No filesystem detected — skip check (can't determine what module to look for) + [ -z "$fstype" ] && DEBUG "Layer 1: no filesystem type for $dev (skipping)" && return 0 + DEBUG "Layer 1: USB device $dev filesystem=$fstype" + DEBUG "Layer 1: USB drive is $fstype - verifying initramfs module support" + echo "$fstype" > /tmp/kexec_usb_fstype + + case "$fstype" in + squashfs|iso9660|udf) + # Read-only filesystems: kernel has built-in support, no module needed + DEBUG "Layer 1: skip $fstype (read-only, built-in kernel support)" + return 0 + esac + + local kernel_mod + kernel_mod=$(initrd_fs_type_to_kmod "$fstype") + DEBUG "Layer 1: kernel module needed for $fstype: $kernel_mod" + + # Find initrd from parsed boot entries — walk .cfg files the same way + # kexec-select-boot.sh does, extract initrd paths from the pipe-delimited output. + local entries_file + entries_file=$(mktemp -p /tmp -t iso_initrd_entries.XXXXXX) + while IFS= read -r cfg; do + [ -f "$cfg" ] || continue + case "$cfg" in *EFI*|*efi*|*x86_64-efi*) continue ;; esac + kexec-parse-boot.sh "$bootdir" "$cfg" >>"$entries_file" 2>/dev/null || true + done < <(find "$bootdir" -name '*.cfg' -type f 2>/dev/null) + + # Collect all unique initrd paths from parsed boot entries + # Entries are pipe-delimited: name|type|kernel|initrd |append + # Field 4 starts with "initrd " if the entry has a regular initrd. + # Xen/multiboot entries use "module " for kernel and initrd. + local initrd_paths="" + while IFS= read -r entry; do + [ -z "$entry" ] && continue + local entry_field4 entry_field5 initrd_relpath + entry_field4=$(echo "$entry" | cut -d\| -f4) + entry_field5=$(echo "$entry" | cut -d\| -f5) + case "$entry_field4" in + initrd\ *) + initrd_relpath="${entry_field4#initrd }" + # Dedup: skip if already in list + [ -f "$bootdir/$initrd_relpath" ] || continue + case " $initrd_paths " in + *" $initrd_relpath "*) ;; + *) initrd_paths="$initrd_paths $initrd_relpath" ;; + esac + ;; + module\ *) + # Xen multiboot: modules are ordered kernel then initrd. + # Collect all module paths; let the filesystem check + # filter out non-initrd files later. + for mod_path in "$entry_field4" "$entry_field5"; do + initrd_relpath="${mod_path#module }" + initrd_relpath="${initrd_relpath%% *}" + [ -f "$bootdir/$initrd_relpath" ] || continue + case " $initrd_paths " in + *" $initrd_relpath "*) ;; + *) initrd_paths="$initrd_paths $initrd_relpath" ;; + esac + done + ;; + esac + done < "$entries_file" + rm -f "$entries_file" + # No initrd referenced by any boot entry — nothing to check + [ -z "$initrd_paths" ] && DEBUG "Layer 1: no initrd paths in boot entries" && return 0 + + # Check all initrds — return OK if ANY has the needed USB fs module. + # Each initrd gets its own [OK]/[!] marker independently in the compat file. + # The per-initrd flag (initrd_supports_fs) is independent of the global + # "any_supported" flag — every initrd always gets an entry in the compat file + # (no silent skips), and ALL initrds are processed (no early break on first OK). + local any_supported="n" + local fs_compat_file="/tmp/kexec_initrd_compat.txt" + local fb_compat_file="/tmp/kexec_fb_compat.txt" + : > "$fs_compat_file" + : > "$fb_compat_file" + local initrd_relpath initrd_abspath + for initrd_relpath in $initrd_paths; do + initrd_abspath="$bootdir/$initrd_relpath" + DEBUG "Layer 1: checking initrd=$initrd_abspath" + local unpack_dir + unpack_dir=$(mktemp -p /tmp -d) + unpack_initramfs.sh "$initrd_abspath" "$unpack_dir" 2>/dev/null || true + if [ -z "$(ls -A "$unpack_dir" 2>/dev/null)" ]; then + DEBUG "Layer 1: unpack_dir empty — initrd may be corrupt or unsupported format" + fi + + local initrd_supports_fs="" # ""=can't verify, "[OK]"=verified ok, "[!]"=verified not ok + local initrd_supports_fb="" + local ko_files + ko_files=$(find "$unpack_dir" -name "*.ko*" -type f 2>/dev/null | head -1) || true + if [ -z "$ko_files" ]; then + # No loadable kernel modules in this initrd at all. + # Can't verify one way or the other — the driver might + # be built into the kernel or this could be a minimal + # initrd that doesn't need the USB fs. Write no marker. + DEBUG "Layer 1: $initrd_relpath no modules (cannot verify)" + else + # Initrd has loadable modules — check if it has the + # kernel module for the USB filesystem ($kernel_mod). + # Use variable capture instead of pipe with grep -q: with + # set -e -o pipefail, grep -q terminates find via SIGPIPE, + # and pipefail propagates find's non-zero exit (141) instead + # of grep's success (0), making the if condition fail. + local ko_match + ko_match=$(find "$unpack_dir" -name "*.ko*" 2>/dev/null | grep "${kernel_mod}" 2>/dev/null | head -1) || true + if [ -n "$ko_match" ]; then + initrd_supports_fs="[OK]" + DEBUG "Layer 1: $initrd_relpath has module $kernel_mod" + elif grep -q "${kernel_mod}" "$unpack_dir/lib/modules/"*/modules.builtin 2>/dev/null; then + initrd_supports_fs="[OK]" + DEBUG "Layer 1: $initrd_relpath has $kernel_mod built-in (modules.builtin)" + else + # Has modules but not the needed one — definite fail + initrd_supports_fs="[!]" + DEBUG "Layer 1: $initrd_relpath has modules but no $kernel_mod" + fi + + # Check for DRM/KMS display drivers in the initrd. These + # reinitialize the display after kexec and make the booted + # OS visible regardless of efifb availability. Also check + # for efifb in modules.builtin in case it's listed there. + local fb_drivers="i915\|nouveau\|amdgpu\|radeon\|bochs\|virtio-gpu\|cirrus\|qxl\|mgag200\|ast" + local fb_match + fb_match=$(find "$unpack_dir" -name "*.ko*" 2>/dev/null | grep "$fb_drivers" 2>/dev/null | head -1) || true + if [ -n "$fb_match" ]; then + initrd_supports_fb="[OK]" + DEBUG "Layer 1: $initrd_relpath has DRM/KMS driver ($(basename $fb_match))" + elif grep -q "efifb" "$unpack_dir/lib/modules/"*/modules.builtin 2>/dev/null; then + initrd_supports_fb="[OK]" + DEBUG "Layer 1: $initrd_relpath has efifb built-in" + else + initrd_supports_fb="[!]" + DEBUG "Layer 1: $initrd_relpath has modules but no display driver" + fi + fi + + # Write per-initrd markers to compat files. + if [ -n "$initrd_supports_fs" ]; then + echo "${initrd_relpath#/} $initrd_supports_fs" >> "$fs_compat_file" + [ "$initrd_supports_fs" = "[OK]" ] && [ "$any_supported" = "n" ] && any_supported="y" + fi + if [ -n "$initrd_supports_fb" ]; then + echo "${initrd_relpath#/} $initrd_supports_fb" >> "$fb_compat_file" + fi + rm -rf "$unpack_dir" + done + + # Dump compat file contents to debug log so users can see per-initrd markers + if [ -s "$fs_compat_file" ]; then + while IFS= read -r line; do DEBUG "Layer 1: fs compat: $line"; done < "$fs_compat_file" + fi + if [ -s "$fb_compat_file" ]; then + while IFS= read -r line; do DEBUG "Layer 1: fb compat: $line"; done < "$fb_compat_file" + fi + + # At least one initrd verifiably supports the USB filesystem — + # proceed without warning. + [ "$any_supported" = "y" ] && return 0 -DEV_UUID=`blkid $DEV | tail -1 | tr " " "\n" | grep UUID | cut -d\" -f2` -ADD="fromiso=/dev/disk/by-uuid/$DEV_UUID/$ISO_PATH img_dev=/dev/disk/by-uuid/$DEV_UUID iso-scan/filename=/${ISO_PATH} img_loop=$ISO_PATH iso=$DEV_UUID/$ISO_PATH" + # No initrd confirmed support. The [!] markers (or absence of + # markers for no-module initrds) will inform the user in the menu. + # We still proceed since unverifiable initrds may work fine. + DEBUG "Layer 1: no initrd has $kernel_mod as .ko (likely built-in kernel support)" +} + +check_initrd_compat "$DEV" "/boot" +STATUS_OK "Initramfs compatibility check complete" + +# If no initrd confirmed support for the USB filesystem, warn the user +# that this ISO is designed for direct USB writing rather than kexec boot +fs_compat_file="/tmp/kexec_initrd_compat.txt" +if [ -s "$fs_compat_file" ] && ! grep -qF '[OK]' "$fs_compat_file"; then + _fstype=$(cat /tmp/kexec_usb_fstype 2>/dev/null || echo "USB") + if [ -x /bin/whiptail ]; then + if ! whiptail_warning --title 'USB Compatibility Warning' --yesno \ + "No Verified Compatible Boot Option\n\nNone of this ISO's initramfs images contain\n${_fstype} support.\n\nThis ISO is likely designed to be written directly to a\nUSB drive (hybrid ISO). Use the upstream recommended\nmethod to create a bootable USB instead.\n\nYou may still attempt to boot - the ${_fstype} module may\nbe built into the kernel rather than loaded from initramfs.\n\nProceed anyway?" \ + 0 80; then + exit 1 + fi + else + WARN "No boot option confirmed compatible with ${_fstype} filesystem" + WARN "The ISO was likely designed for direct USB writing - use the upstream method to create a bootable USB" + INPUT "Proceed anyway? (y/N):" -n 1 response + if [ "$response" != "y" ] && [ "$response" != "Y" ]; then + exit 1 + fi + fi +fi + +# If no initrd confirmed a display driver (i915, nouveau, etc.), warn +# that the screen may be blank after kexec — the kernel boots but has +# no way to show output on the display without a KMS or fbdev driver. +fb_compat_file="/tmp/kexec_fb_compat.txt" +if [ -s "$fb_compat_file" ] && ! grep -qF '[OK]' "$fb_compat_file"; then + if [ -x /bin/whiptail ]; then + if ! whiptail_warning --title 'Display Driver Warning' --yesno \ + "Unverified Display Support\n\nThe ISO does not contain a display driver\nthat Heads can verify.\n\nThe screen may be blank after boot.\n\nDistro maintainers: include a KMS driver\n(i915, nouveau, amdgpu, bochs, cirrus) or\nensure CONFIG_FB_EFI=y in the kernel config.\n\nProceed anyway?" \ + 0 80; then + exit 1 + fi + else + WARN "ISO has no display driver - screen may be blank after boot" + WARN "Include a KMS driver or CONFIG_FB_EFI=y for Heads support" + INPUT "Proceed anyway? (y/N):" -n 1 response + if [ "$response" != "y" ] && [ "$response" != "Y" ]; then + DIE "Incompatible display driver - cannot proceed" + fi + fi +fi + +# --------------------------------------------------------------------------- +# Layer 2: loopback.cfg fast path +# --------------------------------------------------------------------------- +# loopback.cfg is the ISO 9660 standard for declaring boot entries on a +# hybrid ISO. If the ISO ships one, its GRUB variables (${iso_path}, +# ${isofile}) tell us the kernel parameters needed to find and mount the +# ISO from USB. This approach is borrowed from u-root's boot/iso support. +ADD="" REMOVE="" +for lb_cfg in "boot/grub/loopback.cfg" "boot/grub2/loopback.cfg"; do + if [ -r "/boot/$lb_cfg" ]; then + DEBUG "Layer 2: found $lb_cfg" + STATUS "ISO supports USB boot - reading boot configuration" + + option_file="/tmp/kexec_options.txt" + scan_boot_options "/boot" "$lb_cfg" "$option_file" 2>/dev/null || true + + if [ -s "$option_file" ]; then + while IFS= read -r entry; do + [ -z "$entry" ] && continue + append_field=$(echo "$entry" | tr '|' '\n' | grep '^append' | head -1) || true + if [ -n "$append_field" ]; then + append_val=$(echo "$append_field" | sed 's/^append //') + GRUB_VARS_FOUND="n" + # GRUB supports two variable syntaxes: ${var} and $var. + # Loopback.cfg entries typically use ${iso_path} or ${isofile}; + # check both forms and substitute the actual ISO path. + resolved="$append_val" + for var_name in iso_path isofile; do + for grub_var_ref in '${'$var_name'}' '$'$var_name; do + if echo "$resolved" | grep -qF "$grub_var_ref"; then + resolved="${resolved//$grub_var_ref/$ISO_PATH}" + GRUB_VARS_FOUND="y" + fi + done + done + if [ "$GRUB_VARS_FOUND" = "y" ]; then + DEBUG "Layer 2: resolved GRUB vars: $append_val -> $resolved" + ADD="$resolved" + fi + fi + done <"$option_file" + rm -f "$option_file" + fi + + if [ -z "$ADD" ]; then + DEBUG "Layer 2: loopback.cfg found but no boot entries with GRUB vars" + else + STATUS_OK "Layer 2: loopback.cfg boot params resolved" + fi + break + fi +done + +# Layer 2 resolved nothing — build fallback ADD params with all common +# ISO boot methods, both relative and device-by-UUID paths, so the ISO +# initrd can find the ISO regardless of distribution. +# Each framework picks what it recognises: +# iso-scan/filename= — Ubuntu casper, Fedora dracut, Kicksecure +# findiso= — Debian live-boot, NixOS stage-1 +# img_dev= — block device (generic) +# img_loop= — loopback file path (generic) +# iso= — UUID/path alternative (generic) +# live-media= — casper (Ubuntu, PureOS), live-boot (Debian) +# +# Parameters NOT injected (with rationale): +# fromiso= — conflicts with findiso in Debian live-boot's +# check_dev() causing infinite loop device storm. +# Replaced by findiso= which covers Debian too. +# live-media-path= — distro-specific default differs (casper vs live), +# leaving it unset lets each distro use its own default +if [ -z "$ADD" ]; then + ISO_DEV="/dev/disk/by-uuid/$DEV_UUID" + # iso-scan/filename must use the path relative to the block device root + # (/$ISO_PATH), not an absolute host path — after kexec the initrd + # scans block devices and looks for $ISO_PATH on each one. + ADD="iso-scan/filename=/$ISO_PATH findiso=/$ISO_PATH img_dev=$ISO_DEV img_loop=$ISO_PATH iso=$DEV_UUID/$ISO_PATH live-media=$ISO_DEV" + STATUS_OK "Fallback ISO boot params injected" +else + STATUS_OK "Using loopback.cfg ISO boot params" +fi + paramsdir="/media/kexec_iso/$ISO_PATH" -check_config $paramsdir +check_config "$paramsdir" ADD_FILE=/tmp/kexec/kexec_iso_add.txt -if [ -r $ADD_FILE ]; then - NEW_ADD=`cat $ADD_FILE` +if [ -r "$ADD_FILE" ]; then + NEW_ADD=$(cat "$ADD_FILE") ADD=$(eval "echo \"$NEW_ADD\"") fi -DEBUG "Overriding ISO kernel arguments with additions: $ADD" +STATUS "Passing boot parameters so the OS can find the ISO on the USB drive" +DEBUG "ISO kernel argument additions: $ADD" REMOVE_FILE=/tmp/kexec/kexec_iso_remove.txt -if [ -r $REMOVE_FILE ]; then - NEW_REMOVE=`cat $REMOVE_FILE` +if [ -r "$REMOVE_FILE" ]; then + NEW_REMOVE=$(cat "$REMOVE_FILE") REMOVE=$(eval "echo \"$NEW_REMOVE\"") fi -DEBUG "Overriding ISO kernel arguments with suppressions: $REMOVE" +DEBUG "ISO kernel argument suppressions: $REMOVE" -# Call kexec and indicate that hashes have been verified DO_WITH_DEBUG kexec-select-boot.sh -b /boot -d /media -p "$paramsdir" \ -a "$ADD" -r "$REMOVE" -c "*.cfg" -u -i diff --git a/initrd/bin/kexec-parse-bls.sh b/initrd/bin/kexec-parse-bls.sh index 98b1a3020..165b8f073 100755 --- a/initrd/bin/kexec-parse-bls.sh +++ b/initrd/bin/kexec-parse-bls.sh @@ -1,7 +1,6 @@ #!/bin/bash set -e -o pipefail . /etc/functions.sh -TRACE_FUNC bootdir="$1" file="$2" @@ -9,7 +8,7 @@ blsdir="$3" kernelopts="" if [ -z "$bootdir" -o -z "$file" ]; then - DIE "Usage: $0 /boot /boot/grub/grub.cfg blsdir" + DIE "Usage: $0 /boot /path/to/grub.cfg blsdir" fi reset_entry() { @@ -21,7 +20,7 @@ reset_entry() { append="$kernelopts" } -filedir=`dirname $file` +filedir=$(dirname $file) bootdir="${bootdir%%/}" bootlen="${#bootdir}" appenddir="${filedir:$bootlen}" @@ -62,9 +61,9 @@ echo_entry() { bls_entry() { # add info to menuentry - trimcmd=`echo $line | tr '\t ' ' ' | tr -s ' '` - cmd=`echo $trimcmd | cut -d\ -f1` - val=`echo $trimcmd | cut -d\ -f2-` + trimcmd=$(echo $line | tr '\t ' ' ' | tr -s ' ') + cmd=$(echo "$trimcmd" | sed 's/^[[:space:]]*//' | cut -d\ -f1) + val=$(echo "$trimcmd" | sed 's/^[[:space:]]*//' | cut -d\ -f2-) case $cmd in title) name=$val @@ -78,25 +77,23 @@ bls_entry() { options) # default is "options $kernelopts" # need to substitute that variable if set in .cfg/grubenv - append=`echo "$val" | sed "s@\\$kernelopts@$kernelopts@"` + append=$(echo "$val" | sed "s@\$kernelopts@$kernelopts@") ;; esac } # This is the default append value if no options field in bls entry grep -q "set default_kernelopts" "$file" && - kernelopts=`grep "set default_kernelopts" "$file" | - tr "'" "\"" | cut -d\" -f 2` + kernelopts=$(grep "set default_kernelopts" "$file" | + tr "'" "\"" | cut -d\" -f 2) [ -f "$grubenv" ] && grep -q "^kernelopts" "$grubenv" && - kernelopts=`grep "^kernelopts" "$grubenv" | tr '@' '_' | cut -d= -f 2-` + kernelopts=$(grep "^kernelopts" "$grubenv" | tr '@' '_' | cut -d= -f 2-) reset_entry find $blsdir -type f -name \*.conf | -while read f -do - while read line - do - bls_entry - done < "$f" - echo_entry - reset_entry -done + while read f; do + while read line; do + bls_entry + done < "$f" + echo_entry + reset_entry + done diff --git a/initrd/bin/kexec-parse-boot.sh b/initrd/bin/kexec-parse-boot.sh index 852bc00ee..a51eb6c95 100755 --- a/initrd/bin/kexec-parse-boot.sh +++ b/initrd/bin/kexec-parse-boot.sh @@ -1,14 +1,22 @@ #!/bin/bash +# Parse boot loader configs (GRUB, syslinux, ISOLINUX) to extract boot entries +# +# This script parses boot configuration files to build a list of boot entries +# that can be used by kexec-boot.sh to boot an OS. It handles: +# - GRUB config files (grub.cfg) +# - SYSLINUX/ISOLINUX config files (isolinux.cfg, syslinux.cfg) +# - Multiboot kernels (Xen) +# +# Output format: name|kexectype|kernel path[|initrd path][|append params] +# set -e -o pipefail . /etc/functions.sh -TRACE_FUNC - bootdir="$1" file="$2" if [ -z "$bootdir" -o -z "$file" ]; then - DIE "Usage: $0 /boot /boot/grub/grub.cfg" + DIE "Usage: $0 /boot /path/to/config.cfg" fi reset_entry() { @@ -20,14 +28,10 @@ reset_entry() { append="" } -filedir=`dirname $file` -DEBUG "filedir= $filedir" +filedir=$(dirname $file) bootdir="${bootdir%%/}" -DEBUG "bootdir= $bootdir" bootlen="${#bootdir}" -DEBUG "bootlen= $bootlen" appenddir="${filedir:$bootlen}" -DEBUG "appenddir= $appenddir" fix_path() { path="$@" @@ -45,8 +49,8 @@ check_path() { checkpath="$1" firstval="$(echo "$checkpath" | cut -d\ -f1)" if ! [ -r "$bootdir$firstval" ]; then - DEBUG "$bootdir$firstval doesn't exist" - return 1; + DEBUG "parse-boot: check_path $bootdir$firstval not found" + return 1 fi return 0 } @@ -55,10 +59,12 @@ echo_entry() { if [ -z "$kernel" ]; then return; fi fix_path $kernel - # The kernel must exist - if it doesn't, ignore this entry, it - # wouldn't work anyway. This could happen if there was a - # GRUB variable in the kernel path, etc. - if ! check_path "$path"; then return; fi + check_path "$path" 2>/dev/null || { + # Keep entries with unresolved GRUB variables (e.g. ${iso_path}) + # since they may resolve at kexec time. Skip genuinely missing files. + echo "$path" | grep -qE '\$\{?[a-zA-Z_][a-zA-Z0-9_]*\}?' || return + } + name=$(echo "$name" | tr -d '|') entry="$name|$kexectype|kernel $path" case "$kexectype" in @@ -66,8 +72,9 @@ echo_entry() { if [ -n "$initrd" ]; then for init in $(echo $initrd | tr ',' ' '); do fix_path $init - # The initrd must also exist - if ! check_path "$path"; then return; fi + check_path "$path" 2>/dev/null || { + echo "$path" | grep -qE '\$\{?[a-zA-Z_][a-zA-Z0-9_]*\}?' || return + } entry="$entry|initrd $path" done fi @@ -83,24 +90,23 @@ echo_entry() { ;; esac - # Double-expand here in case there are variables in the kernel - # parameters - some configs do this and can boot with empty - # expansions (Debian Live ISOs use this for loopback boots) + # entry is logged at LOG level via DO_WITH_DEBUG's stdout capture echo $(eval "echo \"$entry\"") } search_entry() { case $line in - menuentry* | MENUENTRY* ) + menuentry* | MENUENTRY*) state="grub" reset_entry - name=`echo $line | tr "'" "\"" | cut -d\" -f 2` + name=$(echo $line | tr "'" "\"" | cut -d\" -f 2) ;; - label* | LABEL* ) + label* | LABEL*) state="syslinux" reset_entry - name=`echo $line | cut -c6- ` + name=$(echo $line | cut -c6-) + ;; esac } @@ -111,39 +117,32 @@ grub_entry() { return fi - # add info to menuentry - trimcmd=`echo $line | tr '\t ' ' ' | tr -s ' '` - cmd=`echo $trimcmd | cut -d\ -f1` - val=`echo $trimcmd | cut -d\ -f2-` + trimcmd=$(echo $line | tr '\t ' ' ' | tr -s ' ') + cmd=$(echo "$trimcmd" | sed 's/^[[:space:]]*//' | cut -d\ -f1) + val=$(echo "$trimcmd" | sed 's/^[[:space:]]*//' | cut -d\ -f2-) case $cmd in multiboot*) - # TODO: differentiate between Xen and other multiboot kernels kexectype="xen" kernel="$val" - DEBUG " grub_entry multiboot kernel= $kernel" + DEBUG "parse-boot: multiboot kernel=$kernel" ;; module*) case $val in - --nounzip*) val=`echo $val | cut -d\ -f2-` ;; + --nounzip*) val=$(echo $val | cut -d\ -f2-) ;; esac fix_path $val modules="$modules|module $path" - DEBUG " grub_entry linux modules= $modules" ;; linux*) - # Some configs have a device specification in the kernel - # or initrd path. Assume this would be /boot and remove - # it. Keep the '/' following the device, since this - # path is relative to the device root, not the config - # location. - DEBUG " grub_entry : linux trimcmd prior of kernel/append parsing: $trimcmd" - kernel=`echo $trimcmd | sed "s/([^)]*)//g" | cut -d\ -f2` - append=`echo $trimcmd | cut -d\ -f3-` + DEBUG "parse-boot: linux line: $trimcmd" + kernel=$(echo $trimcmd | sed "s/([^)]*)//g" | cut -d\ -f2) + append=$(echo $trimcmd | cut -d\ -f3-) + # Strip GRUB bootloader marker "---" used as append/initrd separator + append=$(echo "$append" | sed 's|[[:space:]]*---[[:space:]]*| |g' | sed 's|^ ||;s| $||') ;; initrd*) - # Trim off device specification as above initrd="$(echo "$val" | sed "s/([^)]*)//g")" - DEBUG " grub_entry: linux initrd= $initrd" + DEBUG "parse-boot: initrd=$initrd" ;; esac } @@ -157,7 +156,7 @@ syslinux_end() { for param in $append; do case $param in initrd=*) - initrd=`echo $param | cut -d\= -f2` + initrd="${param#initrd=}" ;; *) newappend="$newappend $param" ;; esac @@ -171,7 +170,7 @@ syslinux_end() { } syslinux_multiboot_append() { - splitval=`echo "${val// --- /|}" | tr '|' '\n'` + splitval=$(echo "${val// --- /|}" | tr '|' '\n') while read line do if [ -z "$kernel" ]; then @@ -191,22 +190,21 @@ syslinux_entry() { syslinux_end return ;; - label* | LABEL* ) + label* | LABEL*) syslinux_end search_entry return ;; esac - # add info to menuentry - trimcmd=`echo $line | tr '\t ' ' ' | tr -s ' '` - cmd=`echo $trimcmd | cut -d\ -f1` - val=`echo $trimcmd | cut -d\ -f2-` + trimcmd=$(echo $line | tr '\t ' ' ' | tr -s ' ') + cmd=$(echo "$trimcmd" | sed 's/^[[:space:]]*//' | cut -d\ -f1) + val=$(echo "$trimcmd" | sed 's/^[[:space:]]*//' | cut -d\ -f2-) case $trimcmd in menu* | MENU* ) - cmd2=`echo $trimcmd | cut -d \ -f2` + cmd2=$(echo $trimcmd | cut -d \ -f2) if [ "$cmd2" = "label" -o "$cmd2" = "LABEL" ]; then - name=`echo $trimcmd | cut -c11- | tr -d '^'` + name=$(echo $trimcmd | cut -c11- | tr -d '^') fi ;; linux* | LINUX* | kernel* | KERNEL* ) @@ -219,19 +217,20 @@ syslinux_entry() { ;; *) kernel="$val" - DEBUG "kernel= $kernel" + DEBUG "parse-boot: syslinux kernel=$kernel" + ;; esac ;; initrd* | INITRD* ) initrd="$val" - DEBUG "initrd= $initrd" + DEBUG "parse-boot: syslinux initrd=$initrd" ;; append* | APPEND* ) if [ "$kexectype" = "multiboot" -o "$kexectype" = "xen" ]; then syslinux_multiboot_append else append="$val" - DEBUG "append= $append" + DEBUG "parse-boot: syslinux append=$append" fi ;; esac diff --git a/initrd/bin/kexec-select-boot.sh b/initrd/bin/kexec-select-boot.sh index 10864f77b..076ec7fdc 100755 --- a/initrd/bin/kexec-select-boot.sh +++ b/initrd/bin/kexec-select-boot.sh @@ -59,7 +59,7 @@ paramsdev="${paramsdev%%/}" paramsdir="${paramsdir%%/}" PRIMHASH_FILE="$paramsdir/kexec_primhdl_hash.txt" -if [ "$CONFIG_TPM2_TOOLS" = "y" ]; then +if [ "$CONFIG_TPM2_TOOLS" = "y" ] && [ "$valid_rollback" != "y" ]; then if [ -s "$PRIMHASH_FILE" ]; then #PRIMHASH_FILE (normally /boot/kexec_primhdl_hash.txt) exists and is not empty sha256sum -c "$PRIMHASH_FILE" >/dev/null 2>&1 || @@ -91,6 +91,7 @@ verify_global_hashes() { whiptail_error --title 'ERROR: Boot Hash Mismatch' \ --msgbox "The following files failed the verification process:\n${CHANGED_FILES}\nExiting to a recovery shell" 0 80 fi + DEBUG "kexec-select-boot: hash mismatch in $TMP_HASH_FILE" DIE "$TMP_HASH_FILE: boot hash mismatch" fi # If user enables it, check root hashes before boot as well @@ -129,6 +130,23 @@ verify_rollback_counter() { valid_rollback="y" } +# Build the compat marker legend shown once per session. +# Checks if any entry has a [!] marker — if so, show the full +# legend. Otherwise show short legend. +build_legend() { + local has_issues="n" + if [ -r "/tmp/kexec_initrd_compat.txt" ] && grep -qF '[!]' /tmp/kexec_initrd_compat.txt 2>/dev/null; then + has_issues="y" + elif [ -r "/tmp/kexec_fb_compat.txt" ] && grep -qF '[!]' /tmp/kexec_fb_compat.txt 2>/dev/null; then + has_issues="y" + fi + if [ "$has_issues" = "y" ]; then + printf '\033[0;32m[OK]\033[0m=ready (USB+display) \033[1;33m[!]\033[0m=may fail (blank)=unable to check' + else + printf '\033[0;32m[OK]\033[0m=ready (USB+display) (blank)=unable to check' + fi +} + first_menu="y" get_menu_option() { num_options=$(cat $TMP_MENU_FILE | wc -l) @@ -139,41 +157,87 @@ get_menu_option() { if [ $num_options -eq 1 -a $first_menu = "y" ]; then option_index=1 elif [ "$gui_menu" = "y" ]; then + if [ ! -f /tmp/kexec_compat_shown ] && [ -f /tmp/kexec_initrd_compat.txt ]; then + NOTE "$(build_legend)" + touch /tmp/kexec_compat_shown + fi MENU_OPTIONS=() n=0 + # Show kernel/initrd in menu as "[OK] name (params) [kernel | initrd]" + # Log to debug.log so remote troubleshooting can see exact menu format. + # Long store paths (NixOS) collapse to basename; short paths keep directory context while read option; do parse_option n=$(expr $n + 1) - MENU_OPTIONS+=("$n" "$name") + local marker target display_params optline + marker=$(boot_marker) + target=$(fmt_boot_target) + display_params=$(fmt_display_params "$params") + if [ -n "$display_params" ]; then + optline="$name ($display_params) $target" + else + optline="$name $target" + fi + if [ -n "$marker" ]; then + MENU_OPTIONS+=("$n" "$marker $optline") + else + MENU_OPTIONS+=("$n" "$optline") + fi + DEBUG "whiptail menu: [$n] $marker $optline" done <$TMP_MENU_FILE + if [ -n "$add" ]; then + MENU_OPTIONS+=("b" "Select different ISO") + fi + if [ -n "$add" ]; then + local menu_prompt="Choose the boot option [1-$n, a to abort, b to select different ISO]:" + else + local menu_prompt="Choose the boot option [1-$n, a to abort]:" + fi whiptail_type $BG_COLOR_MAIN_MENU --title "Select your boot option" \ - --menu "Choose the boot option [1-$n, a to abort]:" 0 80 8 \ + --menu "$menu_prompt" 0 80 8 \ -- "${MENU_OPTIONS[@]}" \ - 2>/tmp/whiptail || DIE "Aborting boot attempt" + 2>/tmp/whiptail || option_index="a" option_index=$(cat /tmp/whiptail) else + if [ ! -f /tmp/kexec_compat_shown ] && [ -f /tmp/kexec_initrd_compat.txt ]; then + NOTE "$(build_legend)" + touch /tmp/kexec_compat_shown + fi STATUS "Select your boot option:" n=0 while read option; do parse_option - n=$(expr $n + 1) - # Use the same device routing as INPUT so option lines and the - # prompt share the same unbuffered fd (HEADS_TTY when in gui-init - # context, stderr otherwise). Writing to stdout is wrong here - # because DO_WITH_DEBUG pipes stdout through tee for debug logging, - # making it fully buffered — the last option would appear after the - # INPUT prompt. - printf '%d. %s [%s]\n' "$n" "$name" "$kernel" >"${HEADS_TTY:-/dev/stderr}" + n=$((n + 1)) + local marker target display_params optline + marker=$(boot_marker) + target=$(fmt_boot_target) + display_params=$(fmt_display_params "$params") + if [ -n "$marker" ]; then + optline="$n. $marker $name ${display_params:+($display_params)} $target" + else + optline="$n. $name ${display_params:+($display_params)} $target" + fi + printf '%s\n' "$optline" >"${HEADS_TTY:-/dev/stderr}" + DEBUG "CLI menu: $optline" done <$TMP_MENU_FILE - INPUT "Choose the boot option [1-$n, a to abort]:" -r option_index - - if [ "$option_index" = "a" ]; then - DIE "Aborting boot attempt" + if [ -n "$add" ]; then + INPUT "Choose the boot option [1-$n, a to abort, b for different ISO]:" -r option_index + else + INPUT "Choose the boot option [1-$n, a to abort]:" -r option_index fi fi + + if [ "$option_index" = "a" ]; then + STATUS "Boot aborted by user" + exit 1 + fi + if [ "$option_index" = "b" ] && [ -n "$add" ]; then + STATUS "Returning to ISO selection" + exit 2 + fi first_menu="n" option=$(head -n $option_index $TMP_MENU_FILE | tail -1) @@ -181,40 +245,148 @@ get_menu_option() { } confirm_menu_option() { - if [ "$gui_menu" = "y" ]; then - default_text="Make default" - [[ "$CONFIG_TPM_NO_LUKS_DISK_UNLOCK" = "y" ]] && default_text="${default_text} and boot" - whiptail_warning --title "Confirm boot details" \ - --menu "Confirm the boot details for $name:\n\n$(echo $kernel | fold -s -w 80) \n\n" 0 80 8 \ - -- 'd' "${default_text}" 'y' "Boot one time" \ - 2>/tmp/whiptail || DIE "Aborting boot attempt" - - option_confirm=$(cat /tmp/whiptail) + # Show full kernel/initrd/params in the confirmation dialog. + # Cancel/Esc returns to the menu (option_confirm="b") instead of aborting, + # so users can change their selection without restarting the boot flow. + # The full cmdline combines the entry's parsed params with the global ADD + # params (injected by kexec-iso-init.sh for ISO boot). + if [ "$gui_menu" = "y" ]; then + default_text="Make default" + [[ "$CONFIG_TPM_NO_LUKS_DISK_UNLOCK" = "y" ]] && default_text="${default_text} and boot" + whiptail_warning --title "Confirm boot details" \ + --menu "$name\n\nKernel: $kernel\nInitramfs: ${initrd:--}\nOptions: ${params:--}\n${CONFIG_BOOT_KERNEL_ADD:+Board adds: $CONFIG_BOOT_KERNEL_ADD\n}${CONFIG_BOOT_KERNEL_REMOVE:+Board removes: $CONFIG_BOOT_KERNEL_REMOVE\n}${add:+ISO params: $add\n}Kernel cmdline: $(echo "$params $CONFIG_BOOT_KERNEL_ADD $add" | xargs)\n" 0 80 8 \ + -- 'y' "Boot" 'd' "${default_text}" 'b' "Back to menu" \ + 2>/tmp/whiptail && option_confirm=$(cat /tmp/whiptail) || option_confirm="b" else - STATUS "Confirm boot details for $name:" - INFO "$option" - - INPUT "Confirm selection by pressing 'y', make default with 'd':" -n 1 option_confirm + STATUS " Confirm boot details for $name:" + STATUS " Kernel: $kernel" + STATUS " Initramfs: ${initrd:--}" + STATUS " Options: ${params:--}" + [ -n "$CONFIG_BOOT_KERNEL_ADD" ] && STATUS " Board adds: $CONFIG_BOOT_KERNEL_ADD" + [ -n "$CONFIG_BOOT_KERNEL_REMOVE" ] && STATUS " Board removes: $CONFIG_BOOT_KERNEL_REMOVE" + [ -n "$add" ] && STATUS " ISO params: $add" + local final="$params" + for rem in $CONFIG_BOOT_KERNEL_REMOVE; do + final=" $final " + final="${final// $rem / }" + final="${final# }" + final="${final% }" + done + final="$final $CONFIG_BOOT_KERNEL_ADD $add" + STATUS " Kernel cmdline: $(echo "$final" | xargs)" + INPUT "Boot (Y), make default (d), back to menu (b) [Y/d/b]:" -n 1 option_confirm + [ -z "$option_confirm" ] && option_confirm="y" + return 0 fi } parse_option() { + # Parse pipe-delimited boot entry: name|kexectype|kernel /path|initrd /path|append params + # Field 4 can be either "initrd /path" or "append ..." when no initrd is present. name=$(echo $option | cut -d\| -f1) - kernel=$(echo $option | cut -d\| -f3) + kernel=$(echo $option | cut -d\| -f3 | sed 's/^kernel //') + initrd=""; params="" + f4=$(echo $option | cut -d\| -f4) + case "$f4" in + initrd*) initrd="${f4#initrd }"; params=$(echo $option | cut -d\| -f5 | sed 's/append //' | xargs) ;; + append*) params=$(echo "$f4" | sed 's/^append //' | xargs) ;; + *) ;; + esac + LOG "parse_option: name='$name' kernel='$kernel' initrd='$initrd' params='${params:0:80}...'" +} + +# Return the combined compat marker for the current entry's initrd. +# Reads two Layer 1 compat files: +# /tmp/kexec_initrd_compat.txt — USB filesystem support +# /tmp/kexec_fb_compat.txt — DRM/KMS display driver (i915, etc.) +# +# Combined result: +# [OK] — BOTH filesystem AND display driver confirmed present +# [!] — EITHER filesystem or display driver confirmed missing +# (blank) — cannot verify (no modules in initrd) +# +# When the fb compat file has no entry for this initrd (no modules), +# the fs marker is returned alone. +# +# In CLI mode adds ANSI colors: green [OK], yellow [!]. +boot_marker() { + local m="" fb="" grn="" ylw="" rst="" + [ "$gui_menu" != "y" ] && { grn=$'\033[0;32m'; ylw=$'\033[1;33m'; rst=$'\033[0m'; } + if [ -n "$initrd" ] && [ -r "/tmp/kexec_initrd_compat.txt" ]; then + local ip=$(echo "$initrd" | sed 's|^/*||') + m=$(grep "^$ip " /tmp/kexec_initrd_compat.txt 2>/dev/null | head -1 | cut -d' ' -f2) + [ -n "$m" ] && LOG "boot_marker: initrd=$ip fs=$m" || LOG "boot_marker: initrd=$ip no fs entry" + if [ -r "/tmp/kexec_fb_compat.txt" ]; then + fb=$(grep "^$ip " /tmp/kexec_fb_compat.txt 2>/dev/null | head -1 | cut -d' ' -f2) + [ -n "$fb" ] && LOG "boot_marker: initrd=$ip fb=$fb" || LOG "boot_marker: initrd=$ip no fb entry" + # Combine both + if [ "$m" = "[OK]" ] && [ "$fb" = "[OK]" ]; then + : # both OK + elif [ "$m" = "[!]" ] || [ "$fb" = "[!]" ]; then + m="[!]" # either missing + else + m="" # can't verify + fi + fi + [ "$m" = "[OK]" ] && m="${grn}[OK]${rst}" + [ "$m" = "[!]" ] && m="${ylw}[!]${rst}" + fi + echo "$m" +} + +# Strip ISO-finding boot parameters for display only. +# The full params remain in the entry passed to kexec-boot.sh. +# These params are injected by kexec-iso-init.sh via -a and would +# clutter the menu if shown redundantly. Only affects menu display. +fmt_display_params() { + local dp="$1" + [ -z "$dp" ] && echo "" && return + echo "$dp" | sed \ + -e 's|iso-scan/filename=[^ ]*| |g' \ + -e 's|findiso=[^ ]*| |g' \ + -e 's|fromiso=[^ ]*| |g' \ + -e 's|img_dev=[^ ]*| |g' \ + -e 's|img_loop=[^ ]*| |g' \ + -e 's|iso=[^ ]*| |g' \ + -e 's|live-media=[^ ]*| |g' \ + -e 's| *| |g' \ + -e 's|^ ||' \ + -e 's| $||' | xargs +} + +# Format kernel/initrd for menu display: "[path | path]" +# Keeps directory context for short paths (live/vmlinuz) but falls back to +# basename for unreasonably long store paths (NixOS /nix/store/.../bzImage). +# 35-char threshold: typical paths like "boot/x86_64/loader/linux" fit; +# NixOS store paths with hashes exceed it. +fmt_boot_target() { + local k i + k=$(echo "$kernel" | sed 's|^/*||') + [ -z "$k" ] && k="$kernel" + [ "${#k}" -gt 35 ] && k=$(basename "$k") + i=$(echo "$initrd" | sed 's|^/*||') + [ "${#i}" -gt 35 ] && i=$(basename "$i") + if [ -n "$i" ]; then echo "[$k | $i]"; else echo "[$k]"; fi } scan_options() { - STATUS "Scanning for boot options" + STATUS "Scanning for unsigned boot options" option_file="/tmp/kexec_options.txt" scan_boot_options "$bootdir" "$config" "$option_file" if [ ! -s $option_file ]; then DIE "Failed to parse any boot options" fi - if [ "$unique" = 'y' ]; then - sort -r $option_file | uniq >$TMP_MENU_FILE - else - cp $option_file $TMP_MENU_FILE - fi + # Sort entries by name so users can scan the menu alphabetically. + # When -u (unique) is set, strip --- markers from append params first + # so entries differing only by GRUB's bootloader separator get deduped. + if [ "$unique" = 'y' ]; then + sed 's/|append \([^|]*\)---[^|]*/|append \1/g' "$option_file" | awk -F'|' '!seen[$1]++' >"$TMP_MENU_FILE" + else + cp "$option_file" "$TMP_MENU_FILE" + fi + DEBUG "kexec-select-boot: parsed boot options for user selection" + # Option entries are already logged as echo_entry by kexec-parse-boot.sh; + # no need to dump them again here. } save_default_option() { diff --git a/initrd/bin/media-scan.sh b/initrd/bin/media-scan.sh index c41890def..3c0752260 100755 --- a/initrd/bin/media-scan.sh +++ b/initrd/bin/media-scan.sh @@ -80,17 +80,19 @@ get_menu_option() { # create ISO menu options - search recursively for ISO files find /media -name "*.iso" -type f 2>/dev/null | sort -r > /tmp/iso_menu.txt || true if [ `cat /tmp/iso_menu.txt | wc -l` -gt 0 ]; then - option_confirm="" - while [ -z "$option" -a "$option_index" != "s" ] - do - get_menu_option - done - - MOUNTED_ISO="$option" - ISO="${option:7}" # remove /media/ to get device relative path - DO_WITH_DEBUG kexec-iso-init.sh "$MOUNTED_ISO" "$ISO" "$USB_BOOT_DEV" + while true; do + option="" + option_index="" + option_confirm="" + while [ -z "$option" -a "$option_index" != "s" ] + do + get_menu_option + done - DIE "Something failed in iso init" + MOUNTED_ISO="$option" + ISO="${option:7}" # remove /media/ to get device relative path + DO_WITH_DEBUG kexec-iso-init.sh "$MOUNTED_ISO" "$ISO" "$USB_BOOT_DEV" && break + done fi # No *.iso files on media, try ordinary bootable USB diff --git a/initrd/bin/unpack_initramfs.sh b/initrd/bin/unpack_initramfs.sh index 25f0b5caf..d35c6e5d6 100755 --- a/initrd/bin/unpack_initramfs.sh +++ b/initrd/bin/unpack_initramfs.sh @@ -35,7 +35,7 @@ consume_zeros() { next_byte='00' while [ "$next_byte" = "00" ]; do # if we reach EOF, next_byte becomes empty (dd does not fail) - next_byte="$(dd bs=1 count=1 status=none | xxd -p | tr -d ' ')" + next_byte="$(dd bs=1 count=1 status=none | xxd -p | tr -d '\n ')" done # if we finished due to nonzero byte (not EOF), then carry that byte if [ -n "$next_byte" ]; then @@ -44,10 +44,12 @@ consume_zeros() { } unpack_cpio() { - TRACE_FUNC ( cd "$dest_dir" - cpio -i "${CPIO_ARGS[@]}" 2>/dev/null + # GNU cpio exits non-zero when trailing data follows the TRAILER entry + # in a concatenated multi-segment archive. BusyBox cpio ignores it. + # Accept either exit code — extraction was successful either way. + cpio -i -d "${CPIO_ARGS[@]}" 2>/dev/null || true ) } @@ -61,33 +63,49 @@ unpack_first_segment() { mkdir -p "$dest_dir" # peek the beginning of the file to determine what type of content is next - magic="$(dd if="$unpack_archive" bs=6 count=1 status=none 2>/dev/null | xxd -p)" + magic="$(dd if="$unpack_archive" bs=6 count=1 status=none 2>/dev/null | xxd -p | tr -d '\n ')" + + # For plain cpio, find where the first TRAILER entry ends. GNU cpio reads + # past the first TRAILER and consumes subsequent segments; BusyBox cpio + # stops at the first. By limiting cpio's input to the first segment, + # both behave the same and remaining segments are processed correctly. + local segment_end=0 + case "$magic" in + 303730373031* | 303730373032*) + local trailer_off + trailer_off=$(grep -F -b -o "TRAILER!!!" "$unpack_archive" 2>/dev/null | head -1 | cut -d: -f1) || true + if [ -n "$trailer_off" ]; then + # TRAILER entry: header(110) + filename "TRAILER!!!" padded to 12 = 122 bytes + segment_end=$((trailer_off + 12)) + fi + ;; + esac # read this segment of the archive, then write the rest to the next file ( - # Magic values correspond to Linux init/initramfs.c (zero, cpio) and - # lib/decompress.c (gzip) case "$magic" in 00*) DEBUG "archive segment $magic: uncompressed cpio" - # Skip zero bytes and copy the first nonzero byte consume_zeros - # Copy the remaining data cat ;; 303730373031* | 303730373032*) # plain cpio DEBUG "archive segment $magic: plain cpio" - # Unpack the plain cpio, this stops reading after the trailer - unpack_cpio - # Copy the remaining data - cat + if [ "$segment_end" -gt 0 ]; then + # Feed exactly one segment to cpio so it doesn't consume + # subsequent segments (GNU cpio reads past the first TRAILER). + # Use dd bs=N count=1 — unlike head -c, it reads exactly N + # bytes without buffering extra data from stdin. + dd bs="$segment_end" count=1 status=none 2>/dev/null | ( + cd "$dest_dir" && cpio -i -d "${CPIO_ARGS[@]}" 2>/dev/null + ) || true + else + unpack_cpio + fi + cat || true ;; 1f8b* | 1f9e*) # gzip DEBUG "archive segment $magic: gzip" - # gunzip won't stop when reaching the end of the gzipped member, - # so we can't read another segment after this. We can't - # reasonably determine the member length either, this requires - # walking all the compressed blocks. gunzip | unpack_cpio ;; fd37*) # xz @@ -96,33 +114,10 @@ unpack_first_segment() { ;; 28b5*) # zstd DEBUG "archive segment $magic: zstd" - # Like gunzip, this will not stop when reaching the end of the - # frame, and determining the frame length requires walking all - # of its blocks. - (zstd-decompress -d || true) | unpack_cpio + (zstd-decompress -d 2>/dev/null || zstd -d 2>/dev/null || true) | unpack_cpio ;; *) # unknown DIE "Can't decompress initramfs archive, unknown type: $magic" - # The following are magic values for other compression formats - # but not added because not tested. - # TODO: open an issue for unsupported magic number reported on DIE. - # - #425a*) # bzip2 - # DEBUG "archive segment $magic: bzip2" - # bunzip2 | unpack_cpio - #;; - #5d00*) # lzma - # DEBUG "archive segment $magic: lzma" - # unlzma | unpack_cpio - #;; - #894c*) # lzo - # DEBUG "archive segment $magic: lzo" - # lzop -d | unpack_cpio - #;; - #0221*) # lz4 - # DEBUG "archive segment $magic: lz4" - # lz4 -d | unpack_cpio - # ;; ;; esac ) <"$unpack_archive" >"$rest_archive" @@ -142,4 +137,10 @@ while [ -s "$next_archive" ]; do unpack_first_segment "$next_archive" "$DEST_DIR" "$rest_archive" next_archive="/tmp/unpack_initramfs_next" mv "$rest_archive" "$next_archive" + # GNU cpio reads past the first TRAILER and consumes all cpio entries + # across concatenated segments, leaving only residual bytes. BusyBox + # cpio stops at the first TRAILER. If only a few bytes remain (< min + # cpio header = 110 bytes), we're done. + rest_size="$(stat -c %s "$next_archive" 2>/dev/null || echo 0)" + [ "$rest_size" -lt 110 ] && break done diff --git a/initrd/etc/DEBUG_LOG_COPY_INSTRUCTIONS b/initrd/etc/DEBUG_LOG_COPY_INSTRUCTIONS index a566fec84..0b3e50e1e 100644 --- a/initrd/etc/DEBUG_LOG_COPY_INSTRUCTIONS +++ b/initrd/etc/DEBUG_LOG_COPY_INSTRUCTIONS @@ -11,3 +11,8 @@ Welcome to the Recovery Shell! - 'cp /tmp/debug.log /tmp/measuring_trace.log /media/' # copy both logs - 'umount /media' # flush writes and safely unmount - Share both log files with developers. +- To sign an ISO with your GPG key for future USB boot (from recovery shell): + - 'mount-usb.sh --mode rw' # mount USB read-write at /media + - 'gpg --detach-sign --armor /media/ISOs/' # create .asc + - 'umount /media' # flush writes and safely unmount + - Reboot and select the ISO again — signature will be verified diff --git a/initrd/etc/functions.sh b/initrd/etc/functions.sh index f9b0694fd..80d7ac163 100644 --- a/initrd/etc/functions.sh +++ b/initrd/etc/functions.sh @@ -23,7 +23,6 @@ esac # Red is the universal "error/danger" signal; the "!!! ERROR:" text prefix # carries the same meaning for users who cannot distinguish red from other # colors, so color is an enhancement rather than the sole signal. -# debug.log and /dev/kmsg receive plain text (no ANSI). # Always visible in all output modes. DIE() { TRACE_FUNC @@ -201,8 +200,7 @@ NOTE() { echo >/dev/console 2>/dev/null echo -e "\033[3;37mNOTE:\033[0m $*" >/dev/console 2>/dev/null echo >/dev/console 2>/dev/null - # Log file: plain text - no ANSI codes in debug.log; echo -e so \n in the - # message produces real newlines in the log (multi-line NOTE support). + # Log file: echo -e so \n in the message produces real newlines echo -e "NOTE: $*" >>/tmp/debug.log # Sleep to bring the message to the user's awareness: NOTE is infrequent @@ -249,7 +247,6 @@ NOTE() { STATUS() { # Console: bold >> prefix to /dev/console - announces an action in progress. echo -e "\033[1m >>\033[0m $*" >/dev/console 2>/dev/null - # Log file: plain text - no ANSI codes in debug.log echo " >> $*" >>/tmp/debug.log } @@ -263,7 +260,6 @@ STATUS_OK() { # 2. Bold green color - instant visual scan for sighted users # (Same convention as Linux/systemd "[ OK ]" boot messages.) echo -e "\033[1;32m OK\033[0m $*" >/dev/console 2>/dev/null - # Log file: plain text - no ANSI codes in debug.log echo " OK $*" >>/tmp/debug.log } @@ -308,9 +304,6 @@ INFO() { echo "INFO: $*" | tee -a /tmp/debug.log /tmp/measuring_trace.log >/dev/null else # info mode: green text to /dev/console AND both log files. - # /dev/console = kernel console (follows console= kernel parameter): - # reaches serial, framebuffer, BMC — no process setup needed, callers - # never need to care about redirections. echo -e "\033[0;32m$*\033[0m" >/dev/console 2>/dev/null echo "INFO: $*" | tee -a /tmp/debug.log /tmp/measuring_trace.log >/dev/null fi @@ -2659,14 +2652,17 @@ scan_boot_options() { option_file="$3" if [ -r "$option_file" ]; then rm "$option_file"; fi - for i in $(find "$bootdir" -name "$config"); do + find "$bootdir" -name "$config" -print | while IFS= read -r i; do + case "$i" in + *EFI* | *efi* | *x86_64-efi*) continue ;; + esac DO_WITH_DEBUG kexec-parse-boot.sh "$bootdir" "$i" >>"$option_file" done # FC29/30+ may use BLS format grub config files # https://fedoraproject.org/wiki/Changes/BootLoaderSpecByDefault # only parse these if $option_file is still empty if [ ! -s "$option_file" ] && [ -d "$bootdir/loader/entries" ]; then - for i in $(find "$bootdir" -name "$config"); do + find "$bootdir" -name "$config" -print | while IFS= read -r i; do kexec-parse-bls.sh "$bootdir" "$i" "$bootdir/loader/entries" >>"$option_file" done fi diff --git a/tests/iso-parser/run.sh b/tests/iso-parser/run.sh new file mode 100755 index 000000000..17d2e820a --- /dev/null +++ b/tests/iso-parser/run.sh @@ -0,0 +1,845 @@ +#!/bin/bash +# ISO boot tool test harness +# +# Tests the actual Heads scripts generically: +# - kexec-parse-boot.sh parses any GRUB/syslinux config +# - kexec-parse-bls.sh parses BootLoaderSpec configs +# - unpack_initramfs.sh extracts any multi-segment initrd +# - kexec-iso-init.sh layered ISO boot flow +# +# Per-ISO expectations (all automated): +# PARSES - scanner finds >=1 boot entry +# KERNEL_OK - at least one entry's kernel path is a real file on the media +# (release ISOs include bonus/memtest entries; missing optional files OK) +# INITRD_OK - at least one entry with initrd has a real file (N/A if no initrd) +# FS_COMPAT - initrd has ext4 module or zero modules (built-in, e.g. Fedora) +# LOOPBACK - loopback.cfg classification: INLINE has menuentry, +# SOURCE names a file that exists; NONE is acceptable +# VARS_OK - at least one entry has no unresolved ${var} or $var remnants +# +# Usage: +# ./run.sh # mock trees only (fast) +# ./run.sh --with-isos [] # mock trees + ISOs in path +# ./run.sh --with-isos --iso-dir [--single ] # same with flags +# ./run.sh --help # show this help + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +WITH_ISOS="n" +ISO_DIR="" +SINGLE_ISO="" + +while [ $# -gt 0 ]; do + case "$1" in + --help|-h) + cat <<'HELP' +Usage: ./run.sh [OPTIONS] + +ISO boot tool test harness. Tests Heads scripts: kexec-parse-boot.sh, +kexec-parse-bls.sh, unpack_initramfs.sh, and kexec-iso-init.sh. + +Options: + --help, -h Show this help message and exit + --with-isos [] Enable real ISO tests. can be a directory + of *.iso files or a single .iso file. + --iso-dir Directory containing ISO files (alternative to + positional in --with-isos) + --single Test only one ISO file inside the ISO directory + +Examples: + ./run.sh mock trees only + ./run.sh --with-isos /path/to/isos custom ISO directory + ./run.sh --with-isos /path/to/foo.iso single ISO file + ./run.sh --with-isos --iso-dir /path --single foo.iso +HELP + exit 0 + ;; + --with-isos) + WITH_ISOS="y" + shift + if [ $# -gt 0 ] && [ "${1#-}" = "$1" ]; then + ISO_DIR="$1" + shift + fi + ;; + --iso-dir) + shift; ISO_DIR="$1"; shift ;; + --single) + shift; SINGLE_ISO="$1"; shift ;; + *) + echo "Unknown option: $1" + echo "Use --help for usage" + exit 1 + ;; + esac +done + +# No default — --with-isos requires an explicit path +if [ "$WITH_ISOS" = "y" ] && [ -z "$ISO_DIR" ]; then + echo "ERROR: --with-isos requires a path (directory or .iso file)" + echo " ./run.sh --with-isos /path/to/isos" + echo " ./run.sh --with-isos /path/to/file.iso" + exit 1 +fi +if [ -f "$ISO_DIR" ]; then + ISOS="$(dirname "$ISO_DIR")" + SINGLE_ISO="${SINGLE_ISO:-$(basename "$ISO_DIR")}" +else + ISOS="$ISO_DIR" +fi +TMPDIR="/tmp/iso_test_$$" +mkdir -p "$TMPDIR" + +# Check required tools +MISSING="" +for tool in cpio cp sed xxd; do + command -v "$tool" >/dev/null 2>&1 || MISSING="$MISSING $tool" +done +if [ -n "$MISSING" ]; then + echo "ERROR: missing required tool(s):$MISSING" + echo " These are needed for generating mock initrds and running Heads scripts." + [ "$WITH_ISOS" = "y" ] && command -v fuseiso >/dev/null 2>&1 || \ + [ "$WITH_ISOS" = "y" ] && echo " Also need fuseiso for --with-isos (apt install fuseiso)" + exit 1 +fi +# fuseiso required for ISO tests, checked per-section +if [ "$WITH_ISOS" = "y" ] && ! command -v fuseiso >/dev/null 2>&1; then + echo "ERROR: --with-isos requires fuseiso" + echo " Install: apt install fuseiso (or your distro's equivalent)" + exit 1 +fi + +PASS=0 +FAIL=0 +SKIP=0 + +cleanup() { + mount | grep "$TMPDIR" | awk '{print $3}' | while read m; do + fusermount -zu "$m" 2>/dev/null || true + done + rm -rf "$TMPDIR" 2>/dev/null || true +} +trap cleanup EXIT + +TESTDATA="$TMPDIR/testdata" +generate_mock_trees() { + local d="$TESTDATA" + # bls-format: BLS entries via loader/entries/*.conf + mkdir -p "$d/bls-format/boot/grub" "$d/bls-format/boot/loader/entries" && cat >"$d/bls-format/boot/grub/grub.cfg" <<'EOF' +set default="0" +set timeout=5 +EOF + cat >"$d/bls-format/boot/loader/entries/fedora-43.conf" <<'EOF' +title Fedora 43 +linux /vmlinuz-6.12.0 +initrd /initramfs-6.12.0.img +options quiet splash +EOF + touch "$d/bls-format/vmlinuz-6.12.0" "$d/bls-format/initramfs-6.12.0.img" + + # dash-separator: GRUB with --- marker in append + mkdir -p "$d/dash-separator/boot/grub" && cat >"$d/dash-separator/boot/grub/grub.cfg" <<'EOF' +menuentry "Test" { + linux /boot/vmlinuz quiet splash --- nomodeset + initrd /boot/initrd.img +} +EOF + touch "$d/dash-separator/boot/vmlinuz" "$d/dash-separator/boot/initrd.img" + + # deep-path-grub: long store paths like NixOS + mkdir -p "$d/deep-path-grub/boot/grub" "$d/deep-path-grub/boot" && cat >"$d/deep-path-grub/boot/grub/grub.cfg" <<'EOF' +menuentry "NixOS" { + linux /boot//nix/store/hash-linux-6.12/bzImage + initrd /boot//nix/store/hash-initrd/initrd +} +EOF + cat >"$d/deep-path-grub/boot/grub/loopback.cfg" <<'EOF' +source /boot/grub/grub.cfg +EOF + touch "$d/deep-path-grub/boot/vmlinuz" 2>/dev/null; mkdir -p "$d/deep-path-grub/boot/nix/store/hash-linux-6.12" "$d/deep-path-grub/boot/nix/store/hash-initrd" && touch "$d/deep-path-grub/boot/nix/store/hash-linux-6.12/bzImage" "$d/deep-path-grub/boot/nix/store/hash-initrd/initrd" + + # grub-vars: ${var} and $var references + mkdir -p "$d/grub-vars/boot/grub" "$d/grub-vars/casper" && cat >"$d/grub-vars/boot/grub/loopback.cfg" <<'EOF' +menuentry "Ubuntu" { + linux /casper/vmlinuz iso-scan/filename=${iso_path} quiet splash $isofile + initrd /casper/initrd +} +EOF + touch "$d/grub-vars/casper/vmlinuz" "$d/grub-vars/casper/initrd" + + # loopback-inline-vars: INLINE loopback.cfg with GRUB vars + mkdir -p "$d/loopback-inline-vars/boot/grub" "$d/loopback-inline-vars/casper" && cat >"$d/loopback-inline-vars/boot/grub/loopback.cfg" <<'EOF' +menuentry "Try Ubuntu" { + linux /casper/vmlinuz iso-scan/filename=${iso_path} quiet splash + initrd /casper/initrd +} +menuentry "Safe mode" { + linux /casper/vmlinuz nomodeset quiet splash + initrd /casper/initrd +} +EOF + touch "$d/loopback-inline-vars/casper/vmlinuz" "$d/loopback-inline-vars/casper/initrd" + + # loopback-source: SOURCE loopback.cfg + mkdir -p "$d/loopback-source/boot/grub" "$d/loopback-source/live" && cat >"$d/loopback-source/boot/grub/loopback.cfg" <<'EOF' +source /boot/grub/grub.cfg +EOF + cat >"$d/loopback-source/boot/grub/grub.cfg" <<'EOF' +menuentry "Debian Live" { + linux /live/vmlinuz boot=live quiet splash + initrd /live/initrd.img +} +menuentry "Failsafe" { + linux /live/vmlinuz memtest noapic + initrd /live/initrd.img +} +EOF + touch "$d/loopback-source/live/vmlinuz" "$d/loopback-source/live/initrd.img" + + # loopback-source-grub2: SOURCE loopback.cfg under grub2/ + mkdir -p "$d/loopback-source-grub2/boot/grub2" "$d/loopback-source-grub2/images/pxeboot" && cat >"$d/loopback-source-grub2/boot/grub2/loopback.cfg" <<'EOF' +source /boot/grub2/grub.cfg +EOF + cat >"$d/loopback-source-grub2/boot/grub2/grub.cfg" <<'EOF' +menuentry "Fedora Live" { + linux /images/pxeboot/vmlinuz root=live:CDLABEL=quiet rhgb rd.live.image + initrd /images/pxeboot/initrd.img +} +EOF + touch "$d/loopback-source-grub2/images/pxeboot/vmlinuz" "$d/loopback-source-grub2/images/pxeboot/initrd.img" + + # no-loopback: grub.cfg without loopback.cfg + mkdir -p "$d/no-loopback/boot/grub" "$d/no-loopback/casper" && cat >"$d/no-loopback/boot/grub/grub.cfg" <<'EOF' +menuentry "Ubuntu" { + linux /casper/vmlinuz quiet splash + initrd /casper/initrd +} +menuentry "Ubuntu safe" { + linux /casper/vmlinuz nomodeset quiet splash + initrd /casper/initrd +} +EOF + touch "$d/no-loopback/casper/vmlinuz" "$d/no-loopback/casper/initrd" + + # syslinux-iso: syslinux LABEL entries + mkdir -p "$d/syslinux-iso/boot/grub" "$d/syslinux-iso/isolinux" "$d/syslinux-iso/live" && cat >"$d/syslinux-iso/boot/grub/grub.cfg" <<'EOF' +menuentry "Debian Live" { + linux /live/vmlinuz boot=live quiet + initrd /live/initrd.img +} +EOF + cat >"$d/syslinux-iso/isolinux/isolinux.cfg" <<'EOF' +default live +label live + linux /live/vmlinuz + initrd /live/initrd.img + append boot=live quiet +EOF + touch "$d/syslinux-iso/live/vmlinuz" "$d/syslinux-iso/live/initrd.img" + + # tab-indented: GRUB with TAB characters + mkdir -p "$d/tab-indented/boot/grub" "$d/tab-indented/boot" && cat >"$d/tab-indented/boot/grub/grub.cfg" <<'EOF' +menuentry "Test" { + linux /boot/vmlinuz quiet splash + initrd /boot/initrd.img +} +EOF + touch "$d/tab-indented/boot/vmlinuz" "$d/tab-indented/boot/initrd.img" +} + +# Use the real Heads scripts under /tmp/ so all relative paths resolve. +# Heads scripts source . /etc/functions.sh and . /etc/gui_functions.sh; +# we copy them to /tmp/heads/etc/ and patch the scripts to match. +HEADSTMP="$TMPDIR/heads" +mkdir -p "$HEADSTMP/etc" "$HEADSTMP/bin" +cp "$SCRIPT_DIR/../../initrd/etc/functions.sh" "$HEADSTMP/etc/" +cp "$SCRIPT_DIR/../../initrd/etc/gui_functions.sh" "$HEADSTMP/etc/" + +# Set up /tmp/heads/bin/ scripts with corrected source paths +setup_host_script() { + local script="$1" out="$2" + sed "s|\. /etc/functions\.sh|. $HEADSTMP/etc/functions.sh|; s|\. /etc/gui_functions\.sh|. $HEADSTMP/etc/gui_functions.sh|" "$script" >"$out" + chmod +x "$out" + echo "$out" +} + +PARSER=$(setup_host_script "$SCRIPT_DIR/../../initrd/bin/kexec-parse-boot.sh" "$HEADSTMP/bin/kexec-parse-boot.sh") +BLS_PARSER=$(setup_host_script "$SCRIPT_DIR/../../initrd/bin/kexec-parse-bls.sh" "$HEADSTMP/bin/kexec-parse-bls.sh") +UNPACKER=$(setup_host_script "$SCRIPT_DIR/../../initrd/bin/unpack_initramfs.sh" "$HEADSTMP/bin/unpack_initramfs.sh") +SELECTOR=$(setup_host_script "$SCRIPT_DIR/../../initrd/bin/kexec-select-boot.sh" "$HEADSTMP/bin/kexec-select-boot.sh") + +# Extract boot_marker() and fmt_boot_target() as a sourceable snippet. +# These are the Heads formatting functions that determine how entries +# appear in the boot menu — the test harness must use the exact same code. +FORMAT_HELPERS="$HEADSTMP/bin/_format_helpers.sh" +{ + echo ". $HEADSTMP/etc/functions.sh" + sed -n '/^boot_marker()/,/^}/p' "$SCRIPT_DIR/../../initrd/bin/kexec-select-boot.sh" + sed -n '/^# Format kernel\/initrd/,/^}/p' "$SCRIPT_DIR/../../initrd/bin/kexec-select-boot.sh" | tail -n +2 +} > "$FORMAT_HELPERS" + +# Parse a pipe-delimited entry line into global vars +parse_entry() { + local entry="$1" + entry_name=$(echo "$entry" | cut -d'|' -f1) + entry_type=$(echo "$entry" | cut -d'|' -f2) + local rest + rest=$(echo "$entry" | cut -d'|' -f3-) + entry_kernel=""; entry_initrd=""; entry_append="" + local old_ifs="$IFS"; IFS='|' + for part in $rest; do + case "$part" in + kernel*) entry_kernel="${part#kernel }" ;; + initrd*) entry_initrd="${part#initrd }" ;; + append*) entry_append="${part#append }" ;; + esac + done + IFS="$old_ifs" +} + +# Check if entry has unresolved GRUB variables ($var or ${var}) +has_unresolved_vars() { + echo "$entry_kernel $entry_initrd $entry_append" | grep -qE '\$\{?[a-zA-Z_][a-zA-Z0-9_]*\}?' +} + +# Verify parsed entries against filesystem +# Returns: "TOTAL_K KERNEL INITRD VARS" where KERNEL/INITRD/VARS = OK/FAIL/N/A +verify_entries() { + local bootdir="$1" entries_file="$2" + local total + total=$(wc -l < "$entries_file" 2>/dev/null || echo 0) + + [ "$total" -eq 0 ] && { echo "0 FAIL N/A FAIL"; return; } + + local k_ok=0 i_ok=0 i_entries=0 v_ok=0 + while IFS= read -r entry; do + [ -z "$entry" ] && continue + parse_entry "$entry" + + # KERNEL_OK — use first word only (Xen entries have params after path) + local kpath="${entry_kernel%% *}" + [ -n "$kpath" ] && [ -f "$bootdir/${kpath#/}" ] && k_ok=$((k_ok+1)) + + # INITRD_OK + if [ -n "$entry_initrd" ]; then + i_entries=$((i_entries+1)) + local all_ok=true + for ip in $(echo "$entry_initrd" | tr ',' ' '); do + [ -f "$bootdir/${ip#/}" ] || all_ok=false + done + $all_ok && i_ok=$((i_ok+1)) + fi + + # VARS_OK + has_unresolved_vars || v_ok=$((v_ok+1)) + done < "$entries_file" + + local k="FAIL" i="N/A" v="FAIL" + [ "$k_ok" -gt 0 ] && k="OK" + [ "$i_entries" -gt 0 ] && [ "$i_ok" -gt 0 ] && i="OK" + [ "$v_ok" -gt 0 ] && v="OK" + echo "$total $k $i $v" +} + +# Classify loopback.cfg: NONE / INLINE / SOURCE / OTHER +# Returns classification and validates SOURCE file exists +classify_loopback() { + local mnt="$1" + for lb in "boot/grub/loopback.cfg" "boot/grub2/loopback.cfg"; do + [ -f "$mnt/$lb" ] || continue + local content + content=$(cat "$mnt/$lb") + if echo "$content" | grep -q 'menuentry'; then + echo "INLINE" + elif echo "$content" | grep -q '^source '; then + local src_file + src_file=$(echo "$content" | grep '^source ' | sed 's/^source //' | head -1) + if [ -n "$src_file" ] && [ -f "$mnt/$src_file" ]; then + echo "SOURCE" + else + echo "SOURCE(MISSING:$src_file)" + fi + else + echo "OTHER" + fi + return + done + echo "NONE" +} + +# Check initrd fs compatibility from parsed boot entries. +# Uses generic kmod lookup matching kexec-iso-init.sh's initrd_fs_type_to_kmod(). +# @param fstype filesystem type to check for (default ext4) +# Also prints per-initrd detail: path, .ko count, and [OK]/[!] status. +# Returns: OK / MOD / N/A summary for test counting. +# OK = at least one initrd has the needed module (as .ko or builtin) +# MOD = no initrd has it, but some have modules +# N/A = no initrd found +check_fs_compat() { + local mnt="$1" entries_file="$2" fstype="${3:-ext4}" + local initrd_paths="" initrd="" kmod + kmod=$(fstype_to_kmod "$fstype") + + # Collect all unique initrd paths from parsed boot entries + while IFS= read -r entry; do + [ -z "$entry" ] && continue + local f4 path + f4=$(echo "$entry" | cut -d\| -f4) + case "$f4" in + initrd\ *) path="${f4#initrd }"; [ -f "$mnt/$path" ] && case " $initrd_paths " in *" $path "*) ;; *) initrd_paths="$initrd_paths $path" ;; esac ;; + esac + done < "$entries_file" + [ -z "$initrd_paths" ] && echo "N/A" && return + + local best="N/A" + echo " Initrds:" >&2 + for p in $initrd_paths; do + initrd="$mnt/$p" + local unpack_dir + unpack_dir=$(mktemp -p "$TMPDIR" -d) + "$UNPACKER" "$initrd" "$unpack_dir" 2>/dev/null || true + + local ko_count mod_status + ko_count=$(find "$unpack_dir" -name "*.ko*" -type f 2>/dev/null | wc -l) + if [ "$ko_count" -eq 0 ]; then + mod_status="" # no modules → can't verify, no marker + elif find "$unpack_dir" -name "*.ko*" 2>/dev/null | grep -q "${kmod}" 2>/dev/null; then + mod_status="[OK]" + [ "$best" = "N/A" ] && best="OK" + elif grep -q "${kmod}" "$unpack_dir/lib/modules/"*/modules.builtin 2>/dev/null; then + mod_status="[OK]" + [ "$best" = "N/A" ] && best="OK" + else + mod_status="[!]" + [ "$best" != "OK" ] && best="MOD" + fi + printf " %-50s %5d .ko %s\n" "${p:0:50}" "$ko_count" "$mod_status" >&2 + rm -rf "$unpack_dir" 2>/dev/null || true + done + + echo "$best" +} + +# Print a table row for an ISO/test tree +# Usage: print_row "label" "entries" "kernel" "initrd" "fs_compat" "loopback" "vars" +print_row() { + printf "%-55s %-7s %-6s %-6s %-8s %-10s %s\n" "$1" "$2" "$3" "$4" "$5" "$6" "$7" +} + +# Check a single initrd for ext4 module support (with cache) +# Reports: [OK] if ext4.ko found in initrd, or no modules (built-in heuristic) +# [!] if initrd has modules but no ext4.ko +# empty if no initrd in entry +# NOTE: ext4 is almost always built into the kernel, not in the initrd. +# [!] does NOT mean boot will fail — it means we can't verify from initrd alone. +# Map blkid fstype to kernel module name (must match initrd_fs_type_to_kmod() +# in kexec-iso-init.sh). vfat/msdos use "fat", everything else matches the fstype. +fstype_to_kmod() { + case "$1" in + vfat|msdos) echo "fat" ;; + *) echo "$1" ;; + esac +} + +ESC=$'\033'; GRN="${ESC}[0;32m"; YLW="${ESC}[1;33m"; RST="${ESC}[0m" +# Check single initrd for a kernel module matching the given fstype (default: ext4). +# Returns [OK] if module found as .ko or in modules.builtin, [!] if initrd has +# modules but not the needed one, empty if no initrd or no modules (can't verify). +check_single_initrd() { + local path="$1" + local fstype="${2:-ext4}" + [ -f "$path" ] || { echo ""; return; } + local cache_key + cache_key=$(echo "$path$fstype" | tr '/' '_' | tr -d ' ') + [ -n "$cache_key" ] && [ -f "$TMPDIR/initrd_cache_$cache_key" ] && { cat "$TMPDIR/initrd_cache_$cache_key"; return; } + local ud + ud=$(mktemp -p "$TMPDIR" -d) + "$UNPACKER" "$path" "$ud" 2>/dev/null || true + local result="" kmod + kmod=$(fstype_to_kmod "$fstype") + local ha + ha=$(find "$ud" -name "*.ko*" -type f 2>/dev/null | head -1) + if [ -z "$ha" ]; then + result="" # no modules → can't verify, no marker + elif find "$ud" -name "*.ko*" 2>/dev/null | grep -q "${kmod}" 2>/dev/null; then + result="${GRN}[OK]${RST}" + elif grep -q "${kmod}" "$ud/lib/modules/"*/modules.builtin 2>/dev/null; then + result="${GRN}[OK]${RST}" + else + result="${YLW}[!]${RST}" + fi + rm -rf "$ud" 2>/dev/null + [ -n "$cache_key" ] && [ -n "$result" ] && echo "$result" > "$TMPDIR/initrd_cache_$cache_key" + [ -n "$result" ] && echo "$result" || echo "" +} + +# Process an ISO or mock tree: parse entries, verify, classify loopback, check fs compat +# Returns: "TOTAL KERNEL INITRD VARS" (space-separated, without trailing newline issues) +process_media() { + local label="$1" bootdir="$2" entries_file="$3" + local result_file="$TMPDIR/${label}_result.txt" + + # Parse entries + : > "$entries_file" + while IFS= read -r cfg; do + [ -f "$cfg" ] || continue + case "$cfg" in *EFI*|*efi*|*x86_64-efi*) continue ;; esac + "$PARSER" "$bootdir" "$cfg" >>"$entries_file" 2>/dev/null || true + done < <(find "$bootdir" -name '*.cfg' -type f 2>/dev/null) + + # BLS fallback + blsdir="$bootdir/boot/loader/entries" + if [ ! -s "$entries_file" ] && [ -d "$blsdir" ]; then + ref_cfg=$(find "$bootdir" -name 'grub.cfg' -type f | head -1) + [ -n "$ref_cfg" ] && "$BLS_PARSER" "$bootdir" "$ref_cfg" "$blsdir" >>"$entries_file" 2>/dev/null || true + fi + + # Verify entries + local result + result=$(verify_entries "$bootdir" "$entries_file") + echo "$result" > "$result_file" +} + +echo "============================================================" +echo " ISO BOOT TEST HARNESS" +echo "============================================================" +echo "" + +# ============================================================================ +# SECTION 1: Mock tree parser verification +# ============================================================================ +generate_mock_trees +echo "=== SECTION 1: Mock tree parser verification ===" +echo "" + +print_row "Mock tree" "Entries" "Kernel" "Initrd" "Vars" "Loopback" "" +print_row "-------" "-------" "------" "------" "----" "--------" "---" + +for testdir in "$TESTDATA"/*/; do + testname=$(basename "$testdir") + [ -d "$testdir" ] || continue + + lb=$(classify_loopback "$testdir") + entries_file="$TMPDIR/${testname}_entries.txt" + result_file="$TMPDIR/${testname}_result.txt" + process_media "$testname" "$testdir" "$entries_file" + + read -r total k_val i_val v_val < "$result_file" + + # PARSES + [ "$total" -gt 0 ] && PASS=$((PASS+1)) || { echo " FAIL: $testname: no boot entries parsed"; FAIL=$((FAIL+1)); } + # KERNEL ok + [ "$k_val" = "OK" ] && PASS=$((PASS+1)) || { [ "$k_val" != "N/A" ] && { echo " FAIL: $testname: no entry has a reachable kernel file"; FAIL=$((FAIL+1)); }; } + # INITRD ok + [ "$i_val" = "OK" ] && PASS=$((PASS+1)) || { [ "$i_val" != "N/A" ] && { echo " FAIL: $testname: no entry has a reachable initrd file"; FAIL=$((FAIL+1)); }; } + # VARS ok + [ "$v_val" = "OK" ] && PASS=$((PASS+1)) || { [ "$v_val" != "N/A" ] && { echo " FAIL: $testname: all entries have unresolved GRUB variables"; FAIL=$((FAIL+1)); }; } + + print_row "$testname" "$total" "$k_val" "$i_val" "$v_val" "$lb" "" +done + +echo "" +echo " Mock tree summary: parsers handle tabs, --- markers, GRUB vars, BLS, syslinux" +echo "" + +# ============================================================================ +# SECTION 2: unpack_initramfs.sh — generic multi-segment initrd unpacking +# ============================================================================ +echo "=== SECTION 2: unpack_initramfs.sh ===" +echo "" + +# Create minimal mock initrds to verify unpacker works +mk_initrd() { + local name="$1" ; shift + local dest="$TMPDIR/${name}_initrd.cpio" + local root="$TMPDIR/${name}_initrd_root" + rm -rf "$root" "$dest" 2>/dev/null + mkdir -p "$root" + for f in "$@"; do + mkdir -p "$(dirname "$root/$f")" + echo "content" > "$root/$f" + done + (cd "$root" && find . -type f | cpio -o -H newc --quiet 2>/dev/null) > "$dest" + echo "$dest" +} + +# Test 1: simple initrd (single file, no subdirs) +simple=$(mk_initrd "simple" "init") + +# Test 2: initrd with kernel modules (deep subdirs, no directory entries in cpio) +withmods=$(mk_initrd "withmods" \ + "lib/modules/6.1.0/kernel/fs/ext4/ext4.ko" \ + "lib/modules/6.1.0/kernel/fs/fat/fat.ko" \ + "lib/modules/6.1.0/kernel/drivers/usb/usb-storage.ko" \ + "init") + +# Test 3: multi-segment initrd (simulated by concatenating two cpio archives) +seg1=$(mk_initrd "seg1" "early/init") +seg2=$(mk_initrd "seg2" "main/init" "main/modules.ko") +multi="$TMPDIR/multi_initrd.cpio" +cat "$seg1" "$seg2" > "$multi" + +unpack_and_count() { + local initrd="$1" label="$2" expected_min="${3:-1}" + local dest="$TMPDIR/unpack_$$" + mkdir -p "$dest" + "$UNPACKER" "$initrd" "$dest" 2>/dev/null || true + local count + count=$(find "$dest" -type f 2>/dev/null | wc -l) + rm -rf "$dest" 2>/dev/null || true + [ "$count" -ge "$expected_min" ] && echo " PASS: $label ($count files)" && PASS=$((PASS+1)) || \ + { echo " FAIL: $label (expected >=$expected_min, got $count)"; FAIL=$((FAIL+1)); } +} + +unpack_and_count "$simple" "simple initrd" +unpack_and_count "$withmods" "initrd with kernel modules" 3 +unpack_and_count "$multi" "multi-segment initrd" 3 + +# Verify module detection in unpacked initrd +dest="$TMPDIR/mod_check_$$" +mkdir -p "$dest" +"$UNPACKER" "$withmods" "$dest" 2>/dev/null || true +for mod in ext4 fat; do + if find "$dest" -name "*.ko*" 2>/dev/null | grep -q "$mod"; then + echo " PASS: module $mod found" + PASS=$((PASS+1)) + else + echo " FAIL: module $mod not found" + FAIL=$((FAIL+1)) + fi +done +rm -rf "$dest" 2>/dev/null || true + +# Test: no kernel modules (minimal initrd) -> should produce no marker (can't verify) +nomods=$(mk_initrd "nomods" "init" "bin/systemd" "etc/fstab") +echo "" +echo " Initrd detail:" +check_and_report_initrd() { + local path="$1" label="$2" fstype="${3:-ext4}" + local ud="$TMPDIR/initrd_detail_$$" + mkdir -p "$ud" + "$UNPACKER" "$path" "$ud" 2>/dev/null || true + local kcount kmod result + kcount=$(find "$ud" -name "*.ko*" -type f 2>/dev/null | wc -l) + kmod=$(fstype_to_kmod "$fstype") + if [ "$kcount" -eq 0 ]; then + result="" + elif find "$ud" -name "*.ko*" 2>/dev/null | grep -q "${kmod}" 2>/dev/null; then + result="[OK]" + elif grep -q "${kmod}" "$ud/lib/modules/"*/modules.builtin 2>/dev/null; then + result="[OK]" + else + result="[!]" + fi + printf " %-35s %5d .ko %s\n" "$label" "$kcount" "$result" + rm -rf "$ud" 2>/dev/null || true +} +check_and_report_initrd "$simple" "simple initrd (no modules)" +check_and_report_initrd "$withmods" "with ext4/fat modules" "ext4" +check_and_report_initrd "$withmods" "with modules, check btrfs" "btrfs" +check_and_report_initrd "$multi" "multi-segment initrd" "ext4" +check_and_report_initrd "$nomods" "no modules (minimal)" "ext4" +echo " Legend: [OK]=module found [!]=modules present but not needed one (blank)=no modules can't verify" +echo "" + +dest="$TMPDIR/nomods_check_$$" +mkdir -p "$dest" +"$UNPACKER" "$nomods" "$dest" 2>/dev/null || true +ko_count=$(find "$dest" -name "*.ko*" -type f 2>/dev/null | wc -l) +if [ "$ko_count" -eq 0 ]; then + echo " PASS: no-modules initrd ($ko_count .ko files) -> no marker (can't verify)" + PASS=$((PASS+1)) +else + echo " FAIL: no-modules initrd unexpectedly has $ko_count .ko files" + FAIL=$((FAIL+1)) +fi +rm -rf "$dest" 2>/dev/null || true + +echo "" + +# ============================================================================ +# SECTION 3: Real ISO matrix with active verification +# ============================================================================ +echo "=== SECTION 3: Real ISO verification ===" +echo "" + +if [ "$WITH_ISOS" = "y" ]; then + print_row "ISO" "Entries" "Kernel" "Initrd" "FS_Compat" "Loopback" "Vars" + print_row "---" "-------" "------" "------" "---------" "--------" "----" + + for iso in $(find "$ISOS" -name "*.iso" -type f 2>/dev/null | sort); do + [ -f "$iso" ] || continue + iso_name=$(basename "$iso") + [ -n "$SINGLE_ISO" ] && [ "$iso_name" != "$SINGLE_ISO" ] && continue + mnt="$TMPDIR/mnt_$$_$RANDOM" + mkdir -p "$mnt" + if ! fuseiso -n "$iso" "$mnt" 2>/dev/null; then + printf "%-55s %s\n" "$iso_name" "MOUNTFAIL" + echo " FAIL: $iso_name: fuseiso mount failed" + FAIL=$((FAIL+1)) + rmdir "$mnt" 2>/dev/null || true + continue + fi + + entries_file="$TMPDIR/${iso_name}_entries.txt" + result_file="$TMPDIR/${iso_name}_result.txt" + process_media "$iso_name" "$mnt" "$entries_file" + read -r total k_val i_val v_val < "$result_file" + + # PARSES + KERNEL + INITRD + VARS + [ "$total" -gt 0 ] && PASS=$((PASS+1)) || { echo " FAIL: $iso_name: no boot entries parsed"; FAIL=$((FAIL+1)); } + [ "$k_val" = "OK" ] && PASS=$((PASS+1)) || { [ "$k_val" != "N/A" ] && { echo " FAIL: $iso_name: no entry has a reachable kernel file"; FAIL=$((FAIL+1)); }; } + [ "$i_val" = "OK" ] && PASS=$((PASS+1)) || { [ "$i_val" != "N/A" ] && { echo " FAIL: $iso_name: no entry has a reachable initrd file"; FAIL=$((FAIL+1)); }; } + [ "$v_val" = "OK" ] && PASS=$((PASS+1)) || { [ "$v_val" != "N/A" ] && { echo " FAIL: $iso_name: all entries have unresolved GRUB variables"; FAIL=$((FAIL+1)); }; } + + lb=$(classify_loopback "$mnt") + if echo "$lb" | grep -q "MISSING"; then + echo " FAIL: $iso_name: loopback.cfg source directive targets missing file" + FAIL=$((FAIL+1)) + elif [ "$lb" != "OTHER" ]; then + PASS=$((PASS+1)) + else + echo " FAIL: $iso_name: loopback.cfg has unrecognized format" + FAIL=$((FAIL+1)) + fi + + fs=$(check_fs_compat "$mnt" "$entries_file" 2>/dev/null) + if [ "$fs" = "OK" ] || [ "$fs" = "N/A" ] || [ "$fs" = "MOD" ]; then + PASS=$((PASS+1)) + else + echo " FAIL: $iso_name: initrd fs compatibility check returned: $fs" + FAIL=$((FAIL+1)) + fi + + print_row "$iso_name" "$total" "$k_val" "$i_val" "$fs" "$lb" "$v_val" + + fusermount -zu "$mnt" 2>/dev/null || true + rmdir "$mnt" 2>/dev/null || true + done + echo "" + echo " FS_Compat: OK=module found as .ko or builtin; MOD=modules present but not the needed one; N/A=no initrd found; (blank)=no modules, can't verify" + echo " Loopback legend: NONE=no loopback.cfg; INLINE=menuentry in loopback.cfg; SOURCE=source directive to another file" + echo " Samsung_SSD and Qubes dir are non-OS ISOs and may show unexpected results" + echo "" +else + echo " SKIP: real ISO tests (use --with-isos to enable)" + SKIP=$((SKIP+1)) +fi + +# ============================================================================ +# SECTION 4: Boot entries as the user sees them (per-ISO menu display) +# ============================================================================ +echo "=== SECTION 4: Boot entries (user-facing menu) ===" +echo "" + +if [ "$WITH_ISOS" = "y" ]; then + for iso in $(find "$ISOS" -name "*.iso" -type f 2>/dev/null | sort); do + [ -f "$iso" ] || continue + iso_name=$(basename "$iso") + [ -n "$SINGLE_ISO" ] && [ "$iso_name" != "$SINGLE_ISO" ] && continue + mnt="$TMPDIR/mnt_s4_$$_$RANDOM" + mkdir -p "$mnt" + fuseiso -n "$iso" "$mnt" 2>/dev/null || { rmdir "$mnt" 2>/dev/null; continue; } + + entries_file="$TMPDIR/s4_${iso_name}_entries.txt" + : > "$entries_file" + while IFS= read -r cfg; do + [ -f "$cfg" ] || continue + case "$cfg" in *EFI*|*efi*|*x86_64-efi*) continue ;; esac + "$PARSER" "$mnt" "$cfg" >>"$entries_file" 2>/dev/null || true + done < <(find "$mnt" -name '*.cfg' -type f 2>/dev/null) + + if [ -s "$entries_file" ]; then + echo " $iso_name" + echo " Menu:" + sed 's/|append \([^|]*\)---[^|]*/|append \1/g' "$entries_file" | sort -t\| -k1 -u > "$entries_file.sorted" + + # Write /tmp/kexec_initrd_compat.txt in the format Heads boot_marker() expects + compat_file="/tmp/kexec_initrd_compat.txt" + kmod=$(fstype_to_kmod "${CHECK_FSTYPE:-ext4}") + : > "$compat_file" + while IFS= read -r entry; do + [ -z "$entry" ] && continue + ef4=$(echo "$entry" | cut -d\| -f4) + case "$ef4" in + initrd\ *) rp="${ef4#initrd }" + [ -f "$mnt/$rp" ] || continue + grep -q "^${rp#/} " "$compat_file" 2>/dev/null && continue + ud=$(mktemp -p "$TMPDIR" -d) + "$UNPACKER" "$mnt/$rp" "$ud" 2>/dev/null || true + ha=$(find "$ud" -name "*.ko*" -type f 2>/dev/null | head -1) + if [ -z "$ha" ]; then + : # no modules → can't verify, no marker + elif find "$ud" -name "*.ko*" 2>/dev/null | grep -q "${kmod}" 2>/dev/null; then + echo "${rp#/} [OK]" >> "$compat_file" + elif grep -q "${kmod}" "$ud/lib/modules/"*/modules.builtin 2>/dev/null; then + echo "${rp#/} [OK]" >> "$compat_file" + else + echo "${rp#/} [!]" >> "$compat_file" + fi + rm -rf "$ud" 2>/dev/null + ;; + esac + done < "$entries_file.sorted" + + # Source the Heads formatting functions from the sourceable snippet + . "$FORMAT_HELPERS" + + n=0 + while IFS= read -r entry; do + [ -z "$entry" ] && continue + n=$((n+1)) + name=$(echo "$entry" | cut -d\| -f1) + kernel=$(echo "$entry" | cut -d\| -f3 | sed 's/^kernel //') + f4=$(echo "$entry" | cut -d\| -f4) + initrd=""; params="" + case "$f4" in + initrd\ *) initrd="${f4#initrd }"; params=$(echo "$entry" | cut -d\| -f5 | sed 's/append //' | xargs) ;; + append*) params=$(echo "$f4" | sed 's/^append //' | xargs) ;; + *) ;; + esac + gui_menu="n" + m=$(boot_marker) + t=$(fmt_boot_target) + if [ -n "$m" ]; then + printf ' %d. %s %s %s %s\n' "$n" "$m" "$name" "${params:+($params)}" "$t" + else + printf ' %d. %s %s %s\n' "$n" "$name" "${params:+($params)}" "$t" + fi + done < "$entries_file.sorted" + + echo " Confirmation:" + n=0 + while IFS= read -r entry; do + [ -z "$entry" ] && continue + n=$((n+1)) + en=$(echo "$entry" | cut -d\| -f1) + ek=$(echo "$entry" | cut -d\| -f3 | sed 's/^kernel //') + ef4=$(echo "$entry" | cut -d\| -f4) + ei=""; ep="" + case "$ef4" in + initrd*) ei="${ef4#initrd }"; ep=$(echo "$entry" | cut -d\| -f5 | sed 's/^append //' | xargs) ;; + append*) ep=$(echo "$ef4" | sed 's/^append //' | xargs) ;; + *) ;; + esac + echo " $n. $en" + echo " Kernel: $ek" + echo " Initrd: ${ei:--}" + echo " Params: ${ep:--}" + done < "$entries_file.sorted" + rm -f "$entries_file.sorted" + echo "" + fi + + fusermount -zu "$mnt" 2>/dev/null || true + rmdir "$mnt" 2>/dev/null || true + done +else + echo " (available with --with-isos)" + echo "" +fi + +echo "============================================================" +echo " RESULTS: $PASS passed, $FAIL failed, $SKIP skipped" +echo "============================================================" +[ "$FAIL" -eq 0 ] \ No newline at end of file