diff --git a/base/images/tests/README.md b/base/images/tests/README.md index 32c07b5b2a4..335b60f7162 100644 --- a/base/images/tests/README.md +++ b/base/images/tests/README.md @@ -176,6 +176,7 @@ base/images/ | `installed_package_sizes` | session | `dict[str, int]` | Installed RPM name → on-disk size in bytes (`rpm --root`, `%{SIZE}`) | | `disk_info` | session | `DiskInfo \| None` | VM only | | `partition_table` | session | `list[PartitionInfo]` | VM only — auto-skips on containers | +| `boot_entry_option_lines` | session | `dict[Path, list[str]]` | BLS boot entry path → raw kernel option lines | | `podman_client` | session | `DockerClient \| None` | python-on-whales Podman client; None for non-container images | | `container_image_ref` | session | `str \| None` | Loaded image ID (cached); None for non-container | | `running_container` | function | `ContainerInstance` | Fresh container per test — auto-skips on VMs | diff --git a/base/images/tests/cases/static/test_fips.py b/base/images/tests/cases/static/test_fips.py new file mode 100644 index 00000000000..3c5b98b7488 --- /dev/null +++ b/base/images/tests/cases/static/test_fips.py @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: MIT +"""Validate kernel FIPS boot configuration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from utils.parsers import parse_boot_entry_kernel_options + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.mark.require_capability("machine-bootable") +def test_kernel_fips_matches_image_variant( + boot_entry_option_lines: dict[Path, list[str]], + capabilities: set[str], +) -> None: + expected_fips_options = ["fips=1"] if "fips-enabled" in capabilities else [] + options_by_entry = parse_boot_entry_kernel_options(boot_entry_option_lines) + + for entry, options in options_by_entry.items(): + fips_options = [ + option for option in options if option.partition("=")[0] == "fips" + ] + assert fips_options == expected_fips_options, ( + "Kernel FIPS options do not match the image's capabilities " + f"in {entry}" + ) diff --git a/base/images/tests/cases/static/test_swap.py b/base/images/tests/cases/static/test_swap.py index 7dbeb202a4c..0416f2c3334 100644 --- a/base/images/tests/cases/static/test_swap.py +++ b/base/images/tests/cases/static/test_swap.py @@ -14,6 +14,7 @@ import pytest from utils.extract import read_text_confined +from utils.parsers import parse_boot_entry_kernel_options if TYPE_CHECKING: from pathlib import Path @@ -40,19 +41,27 @@ # such lines must be dropped before handing the text to it. ZRAM_GLOBAL_DIRECTIVE_RE = re.compile(r"^\s*set!\S+\s*=.*$") -# BLS boot entries and the grub2 config both embed the effective kernel -# command line; zram-generator enables a default zram0 swap device when -# "systemd.zram" is present with no value, or any value other than "0"/ -# "false" (see zram-generator.conf(5)). -KERNEL_CMDLINE_GLOB_PATHS = ( - "boot/loader/entries/*.conf", - "boot/efi/loader/entries/*.conf", - "boot/grub2/grub.cfg", -) +# zram-generator enables a default zram0 swap device when "systemd.zram" +# is present with no value, or any value other than "0"/"false" +# (see zram-generator.conf(5)). +GRUB_CONFIG_PATH = "boot/grub2/grub.cfg" SYSTEMD_ZRAM_OPTION_RE = re.compile(r"\bsystemd\.zram(?:=(\S+))?\b") SYSTEMD_ZRAM_DISABLED_VALUES = {"0", "false", "no", "off"} +def _is_enabling_zram_option(option: str) -> bool: + name, separator, value = option.partition("=") + return ( + name == "systemd.zram" + and (not separator or value.lower() not in SYSTEMD_ZRAM_DISABLED_VALUES) + ) + + +def _find_enabling_zram_options(cmdline_text: str) -> list[str]: + options = (match.group(0) for match in SYSTEMD_ZRAM_OPTION_RE.finditer(cmdline_text)) + return [option for option in options if _is_enabling_zram_option(option)] + + @pytest.mark.require_capability("machine-bootable") def test_no_swap_partition(partition_table: list[PartitionInfo]) -> None: """Bootable images must not include a swap partition.""" @@ -138,7 +147,10 @@ def test_no_zram_swap_device(rootfs: Path) -> None: @pytest.mark.require_capability("machine-bootable") -def test_no_zram_swap_kernel_cmdline(rootfs: Path) -> None: +def test_no_zram_swap_kernel_cmdline( + rootfs: Path, + boot_entry_option_lines: dict[Path, list[str]], +) -> None: """Bootable images must not enable zram swap via the kernel command line. zram-generator activates a default ``zram0`` swap device when the @@ -147,14 +159,23 @@ def test_no_zram_swap_kernel_cmdline(rootfs: Path) -> None: on-disk ``zram-generator.conf``. """ enabling_entries: list[str] = [] - for glob_pattern in KERNEL_CMDLINE_GLOB_PATHS: - for conf_path in sorted(rootfs.glob(glob_pattern)): - rel_path = str(conf_path.relative_to(rootfs)) - cmdline_text = read_text_confined(rootfs, rel_path) - for match in SYSTEMD_ZRAM_OPTION_RE.finditer(cmdline_text): - value = match.group(1) - if value is None or value.lower() not in SYSTEMD_ZRAM_DISABLED_VALUES: - enabling_entries.append(f"{rel_path}: {match.group(0)!r}") + options_by_entry = parse_boot_entry_kernel_options(boot_entry_option_lines) + for entry, options in options_by_entry.items(): + rel_path = entry.relative_to(rootfs) + enabling_entries.extend( + f"{rel_path}: {option!r}" + for option in options + if _is_enabling_zram_option(option) + ) + + grub_config = rootfs / GRUB_CONFIG_PATH + if grub_config.exists(): + enabling_entries.extend( + f"{GRUB_CONFIG_PATH}: {option!r}" + for option in _find_enabling_zram_options( + read_text_confined(rootfs, GRUB_CONFIG_PATH) + ) + ) assert not enabling_entries, ( f"Expected no 'systemd.zram' kernel cmdline option enabling swap, found: {enabling_entries}" diff --git a/base/images/tests/conftest.py b/base/images/tests/conftest.py index e5ed30d2abe..51a5cefd21a 100644 --- a/base/images/tests/conftest.py +++ b/base/images/tests/conftest.py @@ -307,6 +307,32 @@ def partition_table(disk_info: DiskInfo | None, image_type: str) -> list[Partiti return disk_info.partitions +@pytest.fixture(scope="session") +def boot_entry_option_lines(rootfs: Path) -> dict[Path, list[str]]: + """Raw kernel option lines keyed by BLS boot entry path.""" + entries_dirs = ( + rootfs / "boot" / "loader" / "entries", + rootfs / "boot" / "efi" / "loader" / "entries", + ) + entries = sorted( + { + entry + for entries_dir in entries_dirs + for entry in entries_dir.glob("*.conf") + } + ) + option_lines_by_entry: dict[Path, list[str]] = {} + for entry in entries: + option_lines: list[str] = [] + relative_entry = entry.relative_to(rootfs).as_posix() + for line in read_text_confined(rootfs, relative_entry).splitlines(): + fields = line.split(maxsplit=1) + if fields and fields[0] == "options": + option_lines.append(fields[1] if len(fields) > 1 else "") + option_lines_by_entry[entry] = option_lines + return option_lines_by_entry + + # --------------------------------------------------------------------------- # Container runtime fixtures # --------------------------------------------------------------------------- diff --git a/base/images/tests/utils/parsers.py b/base/images/tests/utils/parsers.py index 7e6d0161371..4d4a8e41f7b 100644 --- a/base/images/tests/utils/parsers.py +++ b/base/images/tests/utils/parsers.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging +import shlex import subprocess from typing import TYPE_CHECKING @@ -45,6 +46,38 @@ def parse_os_release(content: str) -> dict[str, str]: return result +def parse_boot_entry_kernel_options( + option_lines_by_entry: dict[Path, list[str]], +) -> dict[Path, list[str]]: + """Parse one literal kernel option line from each BLS boot entry.""" + if not option_lines_by_entry: + raise ValueError("no BLS boot loader entries found") + + options_by_entry: dict[Path, list[str]] = {} + for entry, option_lines in option_lines_by_entry.items(): + if len(option_lines) != 1: + raise ValueError( + f"expected exactly one options line in {entry}, " + f"found {len(option_lines)}" + ) + try: + options = shlex.split(option_lines[0]) + except ValueError as exc: + raise ValueError( + f"failed to parse boot loader options in {entry}: {exc}" + ) from exc + if not options: + raise ValueError(f"boot loader options are empty in {entry}") + indirections = [option for option in options if option.startswith("$")] + if indirections: + raise ValueError( + f"boot loader option indirection is not supported in {entry}: " + f"{indirections}. This is likely a test issue." + ) + options_by_entry[entry] = options + return options_by_entry + + def query_rpm_package_sizes(rootfs: Path) -> dict[str, int]: """Query installed RPM package on-disk sizes via ``rpm --root``.