diff --git a/.github/workflows/build-rpm.yml b/.github/workflows/build-rpm.yml index 8f219d6..e7f3d44 100644 --- a/.github/workflows/build-rpm.yml +++ b/.github/workflows/build-rpm.yml @@ -202,6 +202,29 @@ jobs: - name: alinux4 test_image_tag: "alinux4-20260710" runtime_image: "alibaba-cloud-linux-4-registry.cn-hangzhou.cr.aliyuncs.com/alinux4/alinux4:latest" + # Pinned-stub 258 + RAM matrix on the uki/noenc/disk combo, per distro. + # The 2:4G entry surfaces the SizeOfImage hole on a real pinned stub; + # the reassembly fix should make it boot. Baseline rows carry neither + # var, so the Makefile emits neither flag there (single boot, distro stub). + include: + - bootloader: uki + rootfs_enc: noenc + delta_location: disk + distro: + name: alinux3 + test_image_tag: "alinux3-20251030" + runtime_image: "alibaba-cloud-linux-3-registry.cn-hangzhou.cr.aliyuncs.com/alinux3/alinux3:latest" + uki_stub_version: "258" + boot_matrix: "2:4G 4:8G 4:16G" + - bootloader: uki + rootfs_enc: noenc + delta_location: disk + distro: + name: alinux4 + test_image_tag: "alinux4-20260710" + runtime_image: "alibaba-cloud-linux-4-registry.cn-hangzhou.cr.aliyuncs.com/alinux4/alinux4:latest" + uki_stub_version: "258" + boot_matrix: "2:4G 4:8G 4:16G" runs-on: ubuntu-latest needs: build env: @@ -252,7 +275,7 @@ jobs: set -e sed -i -E 's|https?://mirrors.cloud.aliyuncs.com/|https://mirrors.aliyun.com/|g' /etc/yum.repos.d/*.repo yum update -y - yum install -y git make + yum install -y git make zstd " - name: Download artifacts @@ -288,9 +311,13 @@ jobs: path: repo - name: Run convert test + env: + # Empty for baseline rows; make imports these from the environment. + BOOT_MATRIX: ${{ matrix.boot_matrix }} + UKI_STUB_VERSION: ${{ matrix.uki_stub_version }} run: | - docker exec -w /workspace/repo test-container bash -c " - make run-convert-test-case BOOTLOADER=${{ matrix.bootloader }} ROOTFS_ENC=${{ matrix.rootfs_enc }} DELTA_LOCATION=${{ matrix.delta_location }} INPUT_IMAGE=/workspace/test-images/test-image.qcow2 CRYPTPILOT_FDE_RPM=${{ steps.install-rpm.outputs.cryptpilot_fde_rpm_container }} QEMU_RAM=4G + docker exec -w /workspace/repo --env BOOT_MATRIX --env UKI_STUB_VERSION test-container bash -c " + make run-convert-test-case BOOTLOADER=${{ matrix.bootloader }} ROOTFS_ENC=${{ matrix.rootfs_enc }} DELTA_LOCATION=${{ matrix.delta_location }} INPUT_IMAGE=/workspace/test-images/test-image.qcow2 CRYPTPILOT_FDE_RPM=${{ steps.install-rpm.outputs.cryptpilot_fde_rpm_container }} " - name: Print convert diagnostic log diff --git a/Makefile b/Makefile index 3aa53d6..51cb1ee 100644 --- a/Makefile +++ b/Makefile @@ -212,7 +212,7 @@ install-convert-test-depend: .PHONY: run-convert-test-case run-convert-test-case: install-convert-test-depend - bash tests/test-convert.sh --rpm $(CRYPTPILOT_FDE_RPM) --bootloader $(BOOTLOADER) --rootfs-$(ROOTFS_ENC) --delta-location $(DELTA_LOCATION) $(if $(INPUT_IMAGE),--input $(INPUT_IMAGE),) $(if $(QEMU_RAM),--ram $(QEMU_RAM),) + bash tests/test-convert.sh --rpm $(CRYPTPILOT_FDE_RPM) --bootloader $(BOOTLOADER) --rootfs-$(ROOTFS_ENC) --delta-location $(DELTA_LOCATION) $(if $(INPUT_IMAGE),--input $(INPUT_IMAGE),) $(if $(BOOT_MATRIX),--boot-matrix "$(BOOT_MATRIX)",) $(if $(UKI_STUB_VERSION),--uki-stub-version $(UKI_STUB_VERSION),) .PHONE: shellcheck shellcheck: diff --git a/cryptpilot-convert.sh b/cryptpilot-convert.sh index 1ca672d..6ae7e11 100755 --- a/cryptpilot-convert.sh +++ b/cryptpilot-convert.sh @@ -1296,6 +1296,93 @@ pe_align_up() { echo $(($1 + $2 - $1 % $2)) } +# Patch a Linux kernel bzImage's PE/COFF OptionalHeader.ImageBase to 0, in +# place. systemd's UKI stub (linuxx64.efi.stub) >= 258 and < 260 computes each +# inner kernel section's load offset as (section.VirtualAddress - ImageBase) +# and rejects the image when that underflows, printing +# "Section would write outside of memory" (systemd src/boot/linux.c, issue +# #40342). Kernels v5.7..v6.6 are built with ImageBase = CONFIG_PHYSICAL_START +# (default 0x1000000) while their section RVAs (~0x4000) are below it, so a +# pinned 258/259 stub cannot boot them (e.g. alinux3 5.10); systemd fixed it in +# >= 260 (PR #40429) and mainline kernel reverted to ImageBase=0 in v6.7. +# +# Setting ImageBase to 0 makes the buggy stub's math yield the correct RVA. It +# is safe per the PE spec: ImageBase is the *preferred* load address, not used +# by the position-independent kernel at runtime (the stub loads it into its own +# base-0 buffer regardless). Caveat: changes the kernel hash -> re-sign and +# re-measure. No-op (returns 0) if the file is not a valid PE kernel or +# ImageBase is already 0; returns 1 only if it looks like a PE but cannot be +# patched (truncated headers, unknown optional-header magic, write/verify +# failure). +patch_kernel_image_base_zero() { + local file=$1 + [ -n "${file:-}" ] || { echo 'ERROR: patch_kernel_image_base_zero: no file given' >&2; return 1; } + [ -f "$file" ] || { echo "ERROR: kernel file not found: $file" >&2; return 1; } + command -v python3 >/dev/null 2>&1 || { echo "ERROR: python3 required to patch kernel ImageBase; cannot patch ${file##*/}" >&2; return 1; } + + python3 - "$file" <<'PYEOF' +import sys, struct, os +path = sys.argv[1] +name = os.path.basename(path) + +def note(m): print('NOTE: %s: %s' % (name, m)) +def err(m): print('ERROR: %s: %s' % (name, m), file=sys.stderr) + +try: + size = os.path.getsize(path) + if size < 0x40: + note('too small to be a PE kernel; skip ImageBase patch') + sys.exit(0) + with open(path, 'r+b') as f: + head = f.read(min(size, 4096)) + if head[0:2] != b'MZ': + note('no MZ DOS magic; skip ImageBase patch') + sys.exit(0) + if len(head) < 0x40: + err('truncated DOS header; cannot patch ImageBase') + sys.exit(1) + e_lfanew = struct.unpack_from(' len(head) or head[e_lfanew:e_lfanew+4] != b'PE\x00\x00': + err('bad PE signature; cannot patch ImageBase') + sys.exit(1) + opt_off = e_lfanew + 4 + 20 + if opt_off + 28 > len(head): + err('truncated PE optional header; cannot patch ImageBase') + sys.exit(1) + magic = struct.unpack_from(' len(head): + err('truncated PE headers; cannot patch ImageBase') + sys.exit(1) + cur = struct.unpack_from(fmt, head, ib_off)[0] + if cur == 0: + note('ImageBase already 0; no patch needed') + sys.exit(0) + f.seek(ib_off) + f.write(struct.pack(fmt, 0)) + f.flush() + os.fsync(f.fileno()) + f.seek(ib_off) + chk = struct.unpack_from(fmt, f.read(w), 0)[0] + if chk != 0: + err('ImageBase patch verify failed (still 0x%x)' % chk) + sys.exit(1) + print('PATCHED %s: PE ImageBase 0x%x -> 0x0 (file offset 0x%x, %d bytes)' % (name, cur, ib_off, w)) + sys.exit(0) +except SystemExit: + raise +except Exception as e: + err('ImageBase patch failed: %s' % e) + sys.exit(1) +PYEOF +} + # Rebuild $2 (a dracut-generated UKI) on top of a pristine copy of the stub $1, # placing the payload sections right after the stub's own ones. uki_reassemble() { @@ -1305,7 +1392,6 @@ uki_reassemble() { local name size vma s local stub_sections uki_sections payload="" replaced="" local dumpdir build_stub dump_args=() add_args=() - local objcopy_help local size_of_image file_size if ! command -v objdump > /dev/null 2>&1; then @@ -1313,6 +1399,11 @@ uki_reassemble() { return 1 fi + # Release the scratch dir and any half-written relayout on every exit path + # (including set -e aborts from the cp/mv/objcopy below), so a failure + # never leaks /tmp/cryptpilot-uki-XXXXXX across runs. + trap '[ -n "$dumpdir" ] && rm -rf "$dumpdir" "${uki}.relayout"' RETURN + align=$(pe_header_field "$stub" SectionAlignment) image_base=$(pe_header_field "$stub" ImageBase) if [ -z "$align" ] || [ -z "$image_base" ]; then @@ -1353,6 +1444,11 @@ uki_reassemble() { case "$stub_sections" in *" $s "*) if [ "$(pe_section_size "$uki" "$s")" = "$(pe_section_size "$stub" "$s")" ]; then + # SBAT is a revocation list; flag it so operators can audit + # which version is kept when both sides carry the same size. + if [ "$s" = ".sbat" ]; then + echo "NOTE: stub and dracut both ship .sbat with the same size; keeping the stub's, dropping dracut's" >&2 + fi continue fi replaced="${replaced} ${s}" @@ -1397,16 +1493,18 @@ uki_reassemble() { add_args+=(--add-section "${s}=${dumpdir}/${s}" --change-section-vma "${s}=$(printf '0x%x' "$offs")") offs=$(pe_align_up $((offs + size)) "$align") done - # Keep the output ImageBase identical to the stub's, otherwise the RVAs - # would be computed against a different base again. Only PE-aware binutils - # know this option. - objcopy_help=$(objcopy --help 2>/dev/null || true) - case "$objcopy_help" in - *--image-base*) add_args+=(--image-base="$(printf '0x%x' "$image_base")") ;; - esac + # The final objcopy copies build_stub (a byte copy of the stub) to the + # output, so it inherits the stub's ImageBase from the PE optional header. + # Pinning --image-base to the same value is a no-op (verified on binutils + # 2.35: outputs are byte-identical with and without it), and on binutils + # that lack the flag it cannot be passed at all. Rely on preservation; the + # post-reassembly SizeOfImage sanity check below catches any regression. build_stub="${dumpdir}/stub.efi" - cp "$stub" "$build_stub" + if ! cp "$stub" "$build_stub"; then + echo "ERROR: failed to copy the efi stub $stub to $build_stub" >&2 + return 1 + fi for s in $replaced; do # Separate pass: removing and adding the same section name in one # objcopy run is ambiguous. @@ -1422,7 +1520,10 @@ uki_reassemble() { echo "ERROR: failed to reassemble the UKI from the efi stub" >&2 return 1 fi - mv "${uki}.relayout" "$uki" + if ! mv "${uki}.relayout" "$uki"; then + echo "ERROR: failed to move the relayouted UKI ${uki}.relayout into place" >&2 + return 1 + fi rm -rf "$dumpdir" size_of_image=$(pe_header_field "$uki" SizeOfImage) @@ -1438,6 +1539,14 @@ uki_reassemble() { } if [ "${uki:-false}" = true ]; then + # objcopy/objdump are needed to patch the cmdline and to fix the section + # layout below. Fail before dracut spends minutes building the UKI. + for tool in objcopy objdump; do + if ! command -v "$tool" > /dev/null 2>&1; then + echo "ERROR: $tool is needed to build the UKI, install binutils" >&2 + exit 1 + fi + done # dracut --uefi needs the systemd UEFI stub (linuxx64.efi.stub) at # /usr/lib/systemd/boot/efi/ to assemble a UKI. Two sourcing modes, # selected by uki_stub_version (the literal "distro" sentinel must match @@ -1488,6 +1597,26 @@ if [ "${uki:-false}" = true ]; then echo "Fixing UKI section layout" uki_reassemble /usr/lib/systemd/boot/efi/linuxx64.efi.stub "$TMP_UKI_FILE" + # Workaround for systemd stub 258/259 + kernels whose PE ImageBase is + # non-zero (v5.7..v6.6, e.g. alinux3 5.10): the stub rejects them with + # "Section would write outside of memory" (systemd #40342, fixed in >= 260 + # by PR #40429). Patch ONLY the kernel embedded in the UKI's .linux section + # to ImageBase=0 (objcopy dump/update-section, mirroring the cmdline patch + # below); /boot/vmlinuz is never touched. No-op if ImageBase is already 0 + # (>= v6.7 kernels / alinux4). Only for pinned stubs in the buggy range. + if [ "${uki:-false}" = true ] && [ "${uki_stub_version:-distro}" != "distro" ]; then + _stub_major="" + case "${uki_stub_version}" in + [0-9]*) _stub_major=${uki_stub_version%%[!0-9]*} ;; + esac + if [ -n "${_stub_major}" ] && [ "${_stub_major}" -lt 260 ]; then + objcopy --dump-section .linux="/tmp/.linux.bin" "$TMP_UKI_FILE" + patch_kernel_image_base_zero /tmp/.linux.bin || \ + echo "WARNING: kernel ImageBase patch failed (boot may fail with 'Load Error' on stub < 260)" >&2 + objcopy --update-section .linux="/tmp/.linux.bin" "$TMP_UKI_FILE" + fi + fi + echo "Patching cmdline in UKI" # The generated cmdline will have a leading space, remove it objcopy --dump-section .cmdline="/tmp/cmdline_full.bin" "$TMP_UKI_FILE" diff --git a/tests/test-convert.sh b/tests/test-convert.sh index ffb7c60..174841a 100755 --- a/tests/test-convert.sh +++ b/tests/test-convert.sh @@ -8,8 +8,10 @@ # --rootfs-enc (flag) rootfs encryption enabled # --rootfs-noenc (flag) rootfs encryption disabled # --delta-location ram | disk | disk-persist -# --ram Pin QEMU guest RAM (e.g. 4G, 4096M). Defaults to -# 80% of host MemTotal. Also set via QEMU_RAM_SIZE. +# --boot-matrix Space-separated "cpu:ram" tokens (e.g. "2:4G 4:8G"). +# Reuses the converted image across the matrix. If +# omitted, a single boot runs with nproc and 80% of +# host MemTotal. # # Usage: # ./tests/test-convert.sh --rpm --bootloader --rootfs-enc|--rootfs-noenc --delta-location @@ -347,6 +349,9 @@ run_convert() { if [[ "${use_uki}" == "true" ]]; then cmd+=("--uki") + if [[ -n "${UKI_STUB_VERSION:-}" ]]; then + cmd+=("--uki-stub-version" "${UKI_STUB_VERSION}") + fi fi if [[ "${use_encryption}" == "true" ]]; then @@ -552,8 +557,15 @@ ensure_docker_runtime() { test_qemu_boot() { local test_name="$1" local output_image="$2" + local cpu_cores="${3:-$(nproc)}" + # Pin guest RAM via the 4th arg (matrix loop) or fall back to 80% of host + # MemTotal (read from /proc/meminfo of this privileged runtime container, + # which reflects the CI runner's physical memory). A low value (e.g. 4G) + # surfaces the UKI "SizeOfImage hole" class of regressions, since UEFI + # LoadImage() must allocate SizeOfImage bytes of contiguous memory up front. + local ram_size="${4:-$(awk '/MemTotal/{printf "%d", $2 * 0.8 / 1024}' /proc/meminfo)}" - log::step "Testing QEMU boot for: ${test_name}" + log::step "Testing QEMU boot for: ${test_name} (cpu=${cpu_cores}, ram=${ram_size})" # Alinux 4 ships real Docker (not podman-docker) and the test container # has no init system, so dockerd must be started explicitly. No-op on @@ -562,19 +574,11 @@ test_qemu_boot() { return 1 fi - local boot_log="${WORKDIR}/${test_name}-boot.log" - - # Pin guest RAM when --ram / QEMU_RAM_SIZE is given; otherwise fall back to - # 80% of host MemTotal (read from /proc/meminfo of this privileged runtime - # container, which reflects the CI runner's physical memory). Pinning a - # low value (e.g. 4G) is what surfaces the UKI "SizeOfImage hole" class of - # regressions, since UEFI LoadImage() must allocate SizeOfImage bytes of - # contiguous memory up front. - local ram_size="${QEMU_RAM_SIZE:-$(awk '/MemTotal/{printf "%d", $2 * 0.8 / 1024}' /proc/meminfo)}" - log::info "Starting QEMU container with UEFI boot mode (RAM_SIZE=${ram_size})" + local boot_log="${WORKDIR}/${test_name}-cpu${cpu_cores}-ram${ram_size}-boot.log" + log::info "Starting QEMU container with UEFI boot mode (CPU_CORES=${cpu_cores}, RAM_SIZE=${ram_size})" # Start QEMU container in background - local container_name="qemu-test-${test_name}-$$" + local container_name="qemu-test-${test_name}-cpu${cpu_cores}-ram${ram_size}-$$" # When we launched dockerd ourselves (Alinux 4), it runs with no bridge, so # containers get no eth0 and the qemus entrypoint aborts asking for # VM_NET_DEV. Use the host network namespace so qemus can find an @@ -590,7 +594,7 @@ test_qemu_boot() { -e "IMAGE=${output_image}" \ -e BOOT="" \ -e "KVM=N" \ - -e "CPU_CORES=$(nproc)" \ + -e "CPU_CORES=${cpu_cores}" \ -e "RAM_SIZE=${ram_size}" \ --entrypoint /bin/bash \ --name "${container_name}" \ @@ -606,7 +610,7 @@ test_qemu_boot() { log::info "QEMU container started: ${container_name}" # Stream logs to file and check for boot status - local timeout=360 # 6 minutes timeout + local timeout=540 # 9 minutes; TCG under GitHub runner contention can push a 2-min boot well past 6. local elapsed=0 local check_interval=2 local boot_success=false @@ -645,6 +649,16 @@ test_qemu_boot() { log::error "Kernel panic detected - boot failed!" break fi + + # Check for UEFI StartImage failure: OVMF loaded the UKI but + # StartImage returned "Load Error" (e.g. stub/kernel handover + # incompatibility), then dropped to the EFI shell. This fails in + # seconds; without this marker the loop would wait the full timeout. + if grep -q -i "failed to start Boot.*: Load Error" "${boot_log}" 2>/dev/null || \ + grep -q -i "UEFI Interactive Shell" "${boot_log}" 2>/dev/null; then + log::error "UEFI StartImage failed (Load Error / EFI shell) - boot failed!" + break + fi done # Stop log capture (kill the docker logs process) @@ -668,7 +682,54 @@ test_qemu_boot() { fi log::error "QEMU boot test failed for: ${test_name}" return 1 - fi + fi +} + +# Boot the already-converted image across a vCPU/RAM matrix, reusing the +# single output.qcow2 (each boot layers a fresh COW on the read-only base), +# so the expensive convert runs once and only the cheap boot is repeated. +# Matrix entries are "cpu:ram" tokens (ram is a QEMU size, e.g. 4G or 16384M). +# Set via the --boot-matrix option. When empty, a single boot runs with the +# host's nproc and 80% of MemTotal (the original auto behavior). +test_qemu_boot_matrix() { + local test_name="$1" + local output_image="$2" + local matrix="${BOOT_MATRIX:-}" + + # No matrix given: single boot, auto cpu/ram (original behavior). + if [[ -z "$matrix" ]]; then + test_qemu_boot "$test_name" "$output_image" + return $? + fi + + local combo cpu ram + for combo in $matrix; do + cpu="${combo%%:*}" + ram="${combo##*:}" + if [[ -z "$cpu" || -z "$ram" || "$combo" != *:* ]]; then + log::error "Invalid boot matrix entry '${combo}' (expected cpu:ram, e.g. 2:4G)" + return 1 + fi + # NOTE: 16G guest RAM needs a runner with >=16G physical RAM; on a + # 16G runner it sits at the OOM edge and may fail flakily. + log::step "Boot matrix entry: cpu=${cpu}, ram=${ram}" + # Retry once per combo: TCG on contended GitHub runners makes the + # occasional boot exceed the timeout even though the image is fine, + # and a retry usually clears it. + local attempt + for attempt in 1 2; do + if test_qemu_boot "$test_name" "$output_image" "$cpu" "$ram"; then + break + fi + if [[ "$attempt" -eq 1 ]]; then + log::warn "Boot combo cpu=${cpu}, ram=${ram} failed (attempt 1/2), retrying..." + continue + fi + log::error "Boot matrix failed at cpu=${cpu}, ram=${ram} for: ${test_name}" + return 1 + done + done + return 0 } @@ -728,8 +789,10 @@ run_test_case() { return 1 fi - # Test QEMU boot - if ! test_qemu_boot "${test_name}" "${output_image}"; then + # Test QEMU boot across the vCPU/RAM matrix. The converted image is reused + # (each boot layers a fresh COW on the read-only base), so the expensive + # convert runs once. + if ! test_qemu_boot_matrix "${test_name}" "${output_image}"; then return 1 fi @@ -759,10 +822,15 @@ Required: --delta-location Delta partition location: ram | disk | disk-persist Options: - --input Use specified qcow2 image instead of downloading - --ram Pin QEMU guest RAM (e.g. 4G, 4096M). Defaults to 80% of - host MemTotal when not given. Overridable via QEMU_RAM_SIZE. - --help Show this help message + --input Use specified qcow2 image instead of downloading + --boot-matrix Space-separated "cpu:ram" tokens to boot the converted + image with, e.g. "2:4G 4:8G 8:16G 12:16G 16:16G". The + converted image is reused across the matrix. When omitted, + a single boot runs with the host's nproc and 80% of MemTotal. + --uki-stub-version Pin the systemd UEFI stub version (e.g. 258) used + to assemble the UKI. Only meaningful with --bootloader uki. + When omitted, the distro stub is used. + --help Show this help message Examples: $(basename "$0") --rpm ./cryptpilot-fde-guest-*.rpm --bootloader uki --rootfs-enc --delta-location ram @@ -803,8 +871,12 @@ main() { custom_input="$2" shift 2 ;; - --ram) - QEMU_RAM_SIZE="$2" + --boot-matrix) + BOOT_MATRIX="$2" + shift 2 + ;; + --uki-stub-version) + UKI_STUB_VERSION="$2" shift 2 ;; --help|-h) @@ -833,6 +905,11 @@ main() { fatal "Invalid or missing --bootloader: must be 'uki' or 'grub'" fi + # --uki-stub-version only matters for UKI builds. + if [[ -n "${UKI_STUB_VERSION:-}" && "${bootloader}" != "uki" ]]; then + fatal "--uki-stub-version is only meaningful with --bootloader uki" + fi + # Validate --rootfs-enc / --rootfs-noenc if [[ -z "${rootfs_enc}" ]]; then show_help