[Concept,02/16] test: pxe: Add unit tests for PXE/extlinux parser

Message ID 20260109015323.3411528-3-sjg@u-boot.org
State New
Headers
Series test: pxe: Add some decent tests for the PXE/extlinux parser |

Commit Message

Simon Glass Jan. 9, 2026, 1:53 a.m. UTC
  From: Simon Glass <simon.glass@canonical.com>

Add comprehensive tests for the PXE/extlinux.conf parser APIs. The tests
verify that config files can be parsed and label properties inspected
without loading kernel/initrd/FDT files.

The C test (test/boot/pxe.c) validates all struct pxe_label fields:
- String fields: name, menu, kernel, kernel_label, config, append,
  initrd, fdt, fdtdir, fdtoverlays
- Integer fields: ipappend, attempted, localboot, localboot_val,
  kaslrseed, num

The test also verifies struct pxe_menu fields: title, default_label,
fallback_label, bmp, timeout, prompt.

Parser keywords exercised include: kernel, linux, fit (with #config
syntax), append, initrd, fdt, fdtdir, fdtoverlays, localboot, ipappend,
kaslrseed, menu title, timeout, prompt, default, fallback, background.

The Python wrapper (test/py/tests/test_pxe_parser.py) creates a FAT
filesystem image with an extlinux.conf file and passes it to the C test.

Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: Simon Glass <simon.glass@canonical.com>
---

 test/boot/Makefile               |   1 +
 test/boot/pxe.c                  | 197 +++++++++++++++++++++++++++++++
 test/cmd_ut.c                    |   2 +
 test/py/tests/test_pxe_parser.py | 182 ++++++++++++++++++++++++++++
 4 files changed, 382 insertions(+)
 create mode 100644 test/boot/pxe.c
 create mode 100644 test/py/tests/test_pxe_parser.py
  

Patch

diff --git a/test/boot/Makefile b/test/boot/Makefile
index 21f533cdc4c..ceb863969dd 100644
--- a/test/boot/Makefile
+++ b/test/boot/Makefile
@@ -4,6 +4,7 @@ 
 
 ifdef CONFIG_UT_BOOTSTD
 obj-$(CONFIG_BOOTSTD) += bootdev.o bootstd_common.o bootflow.o bootmeth.o
+obj-$(CONFIG_CMDLINE) += pxe.o
 obj-$(CONFIG_FIT) += image.o
 obj-$(CONFIG_$(PHASE_)FIT_PRINT) += fit_print.o
 obj-$(CONFIG_BLK_LUKS) += luks.o
diff --git a/test/boot/pxe.c b/test/boot/pxe.c
new file mode 100644
index 00000000000..49d8d160389
--- /dev/null
+++ b/test/boot/pxe.c
@@ -0,0 +1,197 @@ 
+// SPDX-License-Identifier: GPL-2.0+
+/*
+ * PXE parser tests - C implementation for Python wrapper
+ *
+ * Copyright 2026 Canonical Ltd
+ *
+ * These tests verify the extlinux.conf parser APIs.
+ */
+
+#include <dm.h>
+#include <env.h>
+#include <fs_legacy.h>
+#include <mapmem.h>
+#include <pxe_utils.h>
+#include <test/test.h>
+#include <test/ut.h>
+
+/* Define test macro for pxe suite - no init function needed */
+#define PXE_TEST_ARGS(_name, _flags, ...) \
+	UNIT_TEST_ARGS(_name, _flags, pxe, __VA_ARGS__)
+
+/* Argument indices */
+#define PXE_ARG_FS_IMAGE	0	/* Path to filesystem image */
+#define PXE_ARG_CFG_PATH	1	/* Path to config file within image */
+
+/* Memory address for loading files */
+#define PXE_LOAD_ADDR		0x01000000
+
+/**
+ * struct pxe_test_info - context for the test getfile callback
+ *
+ * @uts: Unit test state for assertions
+ */
+struct pxe_test_info {
+	struct unit_test_state *uts;
+};
+
+/**
+ * pxe_test_getfile() - Read a file from the host filesystem
+ *
+ * This callback is used by the PXE parser to read included files.
+ */
+static int pxe_test_getfile(struct pxe_context *ctx, const char *file_path,
+			    ulong *addrp, ulong align,
+			    enum bootflow_img_t type, ulong *sizep)
+{
+	loff_t len_read;
+	int ret;
+
+	if (!*addrp)
+		return -ENOTSUPP;
+
+	ret = fs_set_blk_dev("host", "0:0", FS_TYPE_ANY);
+	if (ret)
+		return ret;
+	ret = fs_legacy_read(file_path, *addrp, 0, 0, &len_read);
+	if (ret)
+		return ret;
+	*sizep = len_read;
+
+	return 0;
+}
+
+/**
+ * Test parsing an extlinux.conf file
+ *
+ * This test:
+ * 1. Binds a filesystem image containing extlinux.conf
+ * 2. Parses the config using parse_pxefile()
+ * 3. Verifies the parsed labels can be inspected
+ * 4. Verifies label properties are accessible
+ */
+static int pxe_test_parse_norun(struct unit_test_state *uts)
+{
+	const char *fs_image = ut_str(PXE_ARG_FS_IMAGE);
+	const char *cfg_path = ut_str(PXE_ARG_CFG_PATH);
+	ulong addr = PXE_LOAD_ADDR;
+	struct pxe_test_info info;
+	struct pxe_context ctx;
+	struct pxe_label *label;
+	struct pxe_menu *cfg;
+	int ret;
+
+	ut_assertnonnull(fs_image);
+	ut_assertnonnull(cfg_path);
+
+	info.uts = uts;
+
+	/* Bind the filesystem image */
+	ut_assertok(run_commandf("host bind 0 %s", fs_image));
+
+	/* Set up the PXE context */
+	ut_assertok(pxe_setup_ctx(&ctx, pxe_test_getfile, &info, true, cfg_path,
+				  false, false, NULL));
+
+	/* Read the config file into memory */
+	ret = get_pxe_file(&ctx, cfg_path, addr);
+	ut_asserteq(1, ret);  /* get_pxe_file returns 1 on success */
+
+	/* Parse the config file */
+	cfg = parse_pxefile(&ctx, addr);
+	ut_assertnonnull(cfg);
+
+	/* Verify menu properties */
+	ut_asserteq_str("Test Boot Menu", cfg->title);
+	ut_asserteq_str("linux", cfg->default_label);
+	ut_asserteq_str("rescue", cfg->fallback_label);
+	ut_asserteq_str("/boot/background.bmp", cfg->bmp);
+	ut_asserteq(50, cfg->timeout);
+	ut_asserteq(1, cfg->prompt);
+
+	/* Verify first label: linux (with fdt, fdtoverlays) */
+	label = list_first_entry(&cfg->labels, struct pxe_label, list);
+	ut_asserteq_str("", label->num);  /* only set when menu is built */
+	ut_asserteq_str("linux", label->name);
+	ut_asserteq_str("Boot Linux", label->menu);
+	ut_asserteq_str("/vmlinuz", label->kernel_label);
+	ut_asserteq_str("/vmlinuz", label->kernel);
+	ut_assertnull(label->config);
+	ut_asserteq_str("root=/dev/sda1 quiet", label->append);
+	ut_asserteq_str("/initrd.img", label->initrd);
+	ut_asserteq_str("/dtb/board.dtb", label->fdt);
+	ut_assertnull(label->fdtdir);
+	ut_asserteq_str("/dtb/overlay1.dtbo /dtb/overlay2.dtbo",
+			label->fdtoverlays);
+	ut_asserteq(0, label->ipappend);
+	ut_asserteq(0, label->attempted);
+	ut_asserteq(0, label->localboot);
+	ut_asserteq(0, label->localboot_val);
+	ut_asserteq(1, label->kaslrseed);
+
+	/* Verify second label: rescue (linux keyword, fdtdir, ipappend) */
+	label = list_entry(label->list.next, struct pxe_label, list);
+	ut_asserteq_str("", label->num);
+	ut_asserteq_str("rescue", label->name);
+	ut_asserteq_str("Rescue Mode", label->menu);
+	ut_asserteq_str("/vmlinuz-rescue", label->kernel_label);
+	ut_asserteq_str("/vmlinuz-rescue", label->kernel);
+	ut_assertnull(label->config);
+	ut_asserteq_str("single", label->append);
+	ut_assertnull(label->initrd);
+	ut_assertnull(label->fdt);
+	ut_asserteq_str("/dtb/", label->fdtdir);
+	ut_assertnull(label->fdtoverlays);
+	ut_asserteq(3, label->ipappend);
+	ut_asserteq(0, label->attempted);
+	ut_asserteq(0, label->localboot);
+	ut_asserteq(0, label->localboot_val);
+	ut_asserteq(0, label->kaslrseed);
+
+	/* Verify third label: local (localboot only) */
+	label = list_entry(label->list.next, struct pxe_label, list);
+	ut_asserteq_str("", label->num);
+	ut_asserteq_str("local", label->name);
+	ut_asserteq_str("Local Boot", label->menu);
+	ut_assertnull(label->kernel_label);
+	ut_assertnull(label->kernel);
+	ut_assertnull(label->config);
+	ut_assertnull(label->append);
+	ut_assertnull(label->initrd);
+	ut_assertnull(label->fdt);
+	ut_assertnull(label->fdtdir);
+	ut_assertnull(label->fdtoverlays);
+	ut_asserteq(0, label->ipappend);
+	ut_asserteq(0, label->attempted);
+	ut_asserteq(1, label->localboot);
+	ut_asserteq(1, label->localboot_val);
+	ut_asserteq(0, label->kaslrseed);
+
+	/* Verify fourth label: fitboot (fit keyword sets kernel and config) */
+	label = list_entry(label->list.next, struct pxe_label, list);
+	ut_asserteq_str("", label->num);
+	ut_asserteq_str("fitboot", label->name);
+	ut_asserteq_str("FIT Boot", label->menu);
+	ut_asserteq_str("/boot/image.fit#config-1", label->kernel_label);
+	ut_asserteq_str("/boot/image.fit", label->kernel);
+	ut_asserteq_str("#config-1", label->config);
+	ut_asserteq_str("console=ttyS0", label->append);
+	ut_assertnull(label->initrd);
+	ut_assertnull(label->fdt);
+	ut_assertnull(label->fdtdir);
+	ut_assertnull(label->fdtoverlays);
+	ut_asserteq(0, label->ipappend);
+	ut_asserteq(0, label->attempted);
+	ut_asserteq(0, label->localboot);
+	ut_asserteq(0, label->localboot_val);
+	ut_asserteq(0, label->kaslrseed);
+
+	/* Clean up */
+	destroy_pxe_menu(cfg);
+	pxe_destroy_ctx(&ctx);
+
+	return 0;
+}
+PXE_TEST_ARGS(pxe_test_parse_norun, UTF_CONSOLE | UTF_MANUAL,
+	{ "fs_image", UT_ARG_STR },
+	{ "cfg_path", UT_ARG_STR });
diff --git a/test/cmd_ut.c b/test/cmd_ut.c
index a35ac69434d..f8ff02bdbc7 100644
--- a/test/cmd_ut.c
+++ b/test/cmd_ut.c
@@ -71,6 +71,7 @@  SUITE_DECL(measurement);
 SUITE_DECL(mem);
 SUITE_DECL(optee);
 SUITE_DECL(pci_mps);
+SUITE_DECL(pxe);
 SUITE_DECL(seama);
 SUITE_DECL(setexpr);
 SUITE_DECL(upl);
@@ -100,6 +101,7 @@  static struct suite suites[] = {
 	SUITE(mem, "memory-related commands"),
 	SUITE(optee, "OP-TEE"),
 	SUITE(pci_mps, "PCI Express Maximum Payload Size"),
+	SUITE(pxe, "PXE/extlinux parser tests"),
 	SUITE(seama, "seama command parameters loading and decoding"),
 	SUITE(setexpr, "setexpr command"),
 	SUITE(upl, "Universal payload support"),
diff --git a/test/py/tests/test_pxe_parser.py b/test/py/tests/test_pxe_parser.py
new file mode 100644
index 00000000000..c9af135b17d
--- /dev/null
+++ b/test/py/tests/test_pxe_parser.py
@@ -0,0 +1,182 @@ 
+# SPDX-License-Identifier: GPL-2.0+
+# Copyright 2026 Canonical Ltd
+
+"""
+Test the PXE/extlinux parser APIs
+
+These tests verify that the extlinux.conf parser can be used independently
+to inspect boot labels without loading kernel/initrd/FDT files.
+
+Tests are implemented in C (test/boot/pxe.c) and called from here.
+Python handles filesystem image setup and configuration.
+"""
+
+import os
+import pytest
+
+from fs_helper import FsHelper
+
+
+def create_extlinux_conf(srcdir, labels, menu_opts=None):
+    """Create an extlinux.conf file with the given labels
+
+    Args:
+        srcdir (str): Directory to create the extlinux directory in
+        labels (list): List of dicts with label properties:
+            - name: Label name (required)
+            - menu: Menu label text (optional)
+            - kernel: Kernel path (optional)
+            - linux: Linux kernel path (alternative to kernel)
+            - initrd: Initrd path (optional)
+            - append: Kernel arguments (optional)
+            - fdt: Device tree path (optional)
+            - fdtdir: Device tree directory (optional)
+            - fdtoverlays: Device tree overlays (optional)
+            - localboot: Local boot flag (optional)
+            - ipappend: IP append flags (optional)
+            - fit: FIT config path (optional)
+            - kaslrseed: Enable KASLR seed (optional)
+            - default: If True, this is the default label (optional)
+        menu_opts (dict): Menu-level options:
+            - title: Menu title
+            - timeout: Timeout in tenths of a second
+            - prompt: Prompt flag
+            - fallback: Fallback label name
+            - ontimeout: Label to boot on timeout
+            - background: Background image path
+            - say: Message to print
+            - include: File to include
+
+    Returns:
+        str: Path to the config file relative to srcdir
+    """
+    if menu_opts is None:
+        menu_opts = {}
+
+    extdir = os.path.join(srcdir, 'extlinux')
+    os.makedirs(extdir, exist_ok=True)
+
+    conf_path = os.path.join(extdir, 'extlinux.conf')
+    with open(conf_path, 'w', encoding='ascii') as fd:
+        # Menu-level options
+        title = menu_opts.get('title', 'Test Boot Menu')
+        fd.write(f'menu title {title}\n')
+        fd.write(f"timeout {menu_opts.get('timeout', 1)}\n")
+        if 'prompt' in menu_opts:
+            fd.write(f"prompt {menu_opts['prompt']}\n")
+        if 'fallback' in menu_opts:
+            fd.write(f"fallback {menu_opts['fallback']}\n")
+        if 'ontimeout' in menu_opts:
+            fd.write(f"ontimeout {menu_opts['ontimeout']}\n")
+        if 'background' in menu_opts:
+            fd.write(f"menu background {menu_opts['background']}\n")
+        if 'say' in menu_opts:
+            fd.write(f"say {menu_opts['say']}\n")
+        if 'include' in menu_opts:
+            fd.write(f"include {menu_opts['include']}\n")
+
+        for label in labels:
+            if label.get('default'):
+                fd.write(f"default {label['name']}\n")
+
+        for label in labels:
+            fd.write(f"\nlabel {label['name']}\n")
+            if 'menu' in label:
+                fd.write(f"    menu label {label['menu']}\n")
+            if 'kernel' in label:
+                fd.write(f"    kernel {label['kernel']}\n")
+            if 'linux' in label:
+                fd.write(f"    linux {label['linux']}\n")
+            if 'initrd' in label:
+                fd.write(f"    initrd {label['initrd']}\n")
+            if 'append' in label:
+                fd.write(f"    append {label['append']}\n")
+            if 'fdt' in label:
+                fd.write(f"    fdt {label['fdt']}\n")
+            if 'fdtdir' in label:
+                fd.write(f"    fdtdir {label['fdtdir']}\n")
+            if 'fdtoverlays' in label:
+                fd.write(f"    fdtoverlays {label['fdtoverlays']}\n")
+            if 'localboot' in label:
+                fd.write(f"    localboot {label['localboot']}\n")
+            if 'ipappend' in label:
+                fd.write(f"    ipappend {label['ipappend']}\n")
+            if 'fit' in label:
+                fd.write(f"    fit {label['fit']}\n")
+            if label.get('kaslrseed'):
+                fd.write("    kaslrseed\n")
+
+    return '/extlinux/extlinux.conf'
+
+
+@pytest.fixture
+def pxe_image(u_boot_config):
+    """Create a filesystem image with an extlinux.conf file"""
+    fsh = FsHelper(u_boot_config, 'vfat', 4, prefix='pxe_test')
+    fsh.setup()
+
+    # Create a simple extlinux.conf with multiple labels
+    labels = [
+        {
+            'name': 'linux',
+            'menu': 'Boot Linux',
+            'kernel': '/vmlinuz',
+            'initrd': '/initrd.img',
+            'append': 'root=/dev/sda1 quiet',
+            'fdt': '/dtb/board.dtb',
+            'fdtoverlays': '/dtb/overlay1.dtbo /dtb/overlay2.dtbo',
+            'kaslrseed': True,
+            'default': True,
+        },
+        {
+            'name': 'rescue',
+            'menu': 'Rescue Mode',
+            'linux': '/vmlinuz-rescue',  # test 'linux' keyword
+            'append': 'single',
+            'fdtdir': '/dtb/',
+            'ipappend': '3',
+        },
+        {
+            'name': 'local',
+            'menu': 'Local Boot',
+            'localboot': '1',
+        },
+        {
+            'name': 'fitboot',
+            'menu': 'FIT Boot',
+            'fit': '/boot/image.fit#config-1',
+            'append': 'console=ttyS0',
+        },
+    ]
+
+    menu_opts = {
+        'title': 'Test Boot Menu',
+        'timeout': 50,
+        'prompt': 1,
+        'fallback': 'rescue',
+        'ontimeout': 'linux',
+        'background': '/boot/background.bmp',
+    }
+
+    cfg_path = create_extlinux_conf(fsh.srcdir, labels, menu_opts)
+
+    # Create the filesystem
+    fsh.mk_fs()
+
+    yield fsh.fs_img, cfg_path
+
+    # Cleanup
+    if not u_boot_config.persist:
+        fsh.cleanup()
+
+
+@pytest.mark.boardspec('sandbox')
+class TestPxeParser:
+    """Test PXE/extlinux parser APIs via C unit tests"""
+
+    def test_pxe_parse(self, ubman, pxe_image):
+        """Test parsing an extlinux.conf and verifying label properties"""
+        fs_img, cfg_path = pxe_image
+        with ubman.log.section('Test PXE parse'):
+            ubman.run_ut('pxe', 'pxe_test_parse',
+                         fs_image=fs_img, cfg_path=cfg_path)