diff --git a/.github/workflows/nogui-smoke.yml b/.github/workflows/nogui-smoke.yml new file mode 100644 index 0000000..ed2f67e --- /dev/null +++ b/.github/workflows/nogui-smoke.yml @@ -0,0 +1,147 @@ +# Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +# SPDX-License-Identifier: BSD-3-Clause-Clear +# +# Headless (--nogui) integration smoke tests. +# +# These exercise the command-line contract that downstream automation +# (Yocto, signing pipelines) relies on, by invoking run.py externally on +# real Qualcomm boot binaries and checking the produced ELF actually +# contains the edited property: +# +# run.py --nogui --allow_unsigned --input_file +# --output_path --output_file +# --modify "/=" +# +# Two containers are covered: +# * xbl_config.elf - reassembled via the unsigned GenXBLConfig path +# (no sectools required) +# * uefi_dtbs.elf - a pure-DTB (.dtbs) ELF whose wrapper is rebuilt via +# `sectools elf-tool generate` (sectools required) +# +# All binaries are public "test-device" boot images / public security tools +# fetched from Qualcomm Software Center; nothing proprietary is committed. + +name: QDTE nogui smoke + +on: + pull_request: + branches: [ main ] + push: + branches: [ main ] + workflow_dispatch: + +permissions: + contents: read + +env: + # Public boot-binary / tools archives. Bump the build numbers here to + # refresh the fixtures; the embedded DTB names below are tied to them. + XBL_ZIP_URL: "https://softwarecenter.qualcomm.com/nexus/generic/product/chip/tech-package/QCM6490_bootbinaries.1.0/qcm6490_bootbinaries.1.0-test-device-public/00126/QCM6490_bootbinaries_00126.zip" + XBL_ELF_IN_ZIP: "QCM6490_bootbinaries/xbl_config.elf" + # The first DTB embedded in the reassembled ELF (by file offset); this is + # the one `fdtdump --scan` decodes, so the verified edit targets it. + XBL_DTB_NAME: "pre-ddr-kodiak-1.0.dtb" + + UEFI_ZIP_URL: "https://softwarecenter.qualcomm.com/nexus/generic/product/chip/tech-package/QRB2210_bootbinaries.1.0/qrb2210_bootbinaries.1.0-test-device-public/00010/Agatti_bootbinaries.zip" + UEFI_ELF_IN_ZIP: "Agatti_bootbinaries/uefi_dtbs.elf" + UEFI_DTB_NAME: "qcom,uefi-agatti-1.0.dtb" + + # Public Qualcomm Security Tools (sectools). The download URL 301-redirects, + # so curl is invoked with -L. ubuntu-latest runners are x86_64 -> Linux/. + SECTOOLS_ZIP_URL: "https://softwarecenter.qualcomm.com/api/download/software/tools/Qualcomm_Security_Tools/All/1.48.0/1.48.zip" + SECTOOLS_IN_ZIP: "1.48/Linux/sectools" + + FIXTURES_DIR: "${{ github.workspace }}/.smoke-fixtures" + +jobs: + nogui-smoke: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + # QDTE headless runtime deps only. With the headless import guards + # in place, --nogui needs neither tkinter nor fs/pyfatfs. + python -m pip install --upgrade pip + python -m pip install pyfdt six + # fdtdump (verification) lives in device-tree-compiler. + sudo apt-get update + sudo apt-get install -y device-tree-compiler + + - name: Cache test binaries + id: cache-fixtures + uses: actions/cache@v4 + with: + path: ${{ env.FIXTURES_DIR }} + key: smoke-fixtures-${{ hashFiles('.github/workflows/qcom-nogui-smoke.yml') }} + + - name: Fetch test binaries + if: steps.cache-fixtures.outputs.cache-hit != 'true' + run: | + set -euo pipefail + mkdir -p "$FIXTURES_DIR" + tmp="$(mktemp -d)" + + echo "::group::xbl_config.elf" + curl -fsSL -o "$tmp/xbl.zip" "$XBL_ZIP_URL" + unzip -o -j "$tmp/xbl.zip" "$XBL_ELF_IN_ZIP" -d "$FIXTURES_DIR" + echo "::endgroup::" + + echo "::group::uefi_dtbs.elf" + curl -fsSL -o "$tmp/uefi.zip" "$UEFI_ZIP_URL" + unzip -o -j "$tmp/uefi.zip" "$UEFI_ELF_IN_ZIP" -d "$FIXTURES_DIR" + echo "::endgroup::" + + echo "::group::sectools" + curl -fsSL -o "$tmp/sectools.zip" "$SECTOOLS_ZIP_URL" + unzip -o -j "$tmp/sectools.zip" "$SECTOOLS_IN_ZIP" -d "$FIXTURES_DIR/sectools" + chmod +x "$FIXTURES_DIR/sectools/sectools" + echo "::endgroup::" + + rm -rf "$tmp" + ls -l "$FIXTURES_DIR" "$FIXTURES_DIR/sectools" + + # Each smoke test runs run.py --nogui to edit one property, then reads + # it back from the produced ELF with `fdtdump --scan`, which locates and + # decodes the embedded DTB. The expected value is matched as the 8-digit + # hex word fdtdump prints (e.g. <0x00000007>). + + - name: nogui smoke - xbl_config.elf + run: | + set -euo pipefail + # xbl_config.elf reassembles via the unsigned GenXBLConfig path + # (no sectools). The hex literal also exercises the int(val, 0) fix. + out="$(mktemp -d)" + python run.py --nogui --allow_unsigned \ + --input_file "$FIXTURES_DIR/xbl_config.elf" \ + --output_path "$out" --output_file xbl_config.elf \ + --modify "$XBL_DTB_NAME/soc/ddr_common_dt/ddr_log_level_dt=0x7" + test -f "$out/xbl_config.elf" + fdtdump --scan "$out/xbl_config.elf" 2>/dev/null \ + | grep -q "ddr_log_level_dt = <0x00000007>" + echo "PASS: xbl_config.elf ddr_log_level_dt == 0x7" + + - name: nogui smoke - uefi_dtbs.elf + run: | + set -euo pipefail + # uefi_dtbs.elf is a pure-DTB (.dtbs) ELF whose wrapper is rebuilt + # via `sectools elf-tool generate`, so --sectools_dir is required. + out="$(mktemp -d)" + python run.py --nogui --allow_unsigned \ + --sectools_dir "$FIXTURES_DIR/sectools" \ + --input_file "$FIXTURES_DIR/uefi_dtbs.elf" \ + --output_path "$out" --output_file uefi_dtbs.elf \ + --modify "$UEFI_DTB_NAME/soc/quantum_dt/enter_quantum=0x1" + test -f "$out/uefi_dtbs.elf" + fdtdump --scan "$out/uefi_dtbs.elf" 2>/dev/null \ + | grep -q "enter_quantum = <0x00000001>" + echo "PASS: uefi_dtbs.elf enter_quantum == 0x1" + + echo "All nogui smoke tests passed." diff --git a/controller.py b/controller.py index 6c836b9..0632457 100644 --- a/controller.py +++ b/controller.py @@ -66,16 +66,32 @@ import Autocmd as cmd #nhlos parser lib -import non_hlos_parser +# +# The NON-HLOS.bin parser is only used by the GUI's FAT image browser +# (DTGUIController instantiated via the `else` branch in run()). Its +# transitive deps (`fs`, `pyfatfs`) are unnecessary for headless +# --nogui usage such as xbl_config.elf modification, so import lazily +# and fall back to a stub when those packages are not installed. The +# stub satisfies the DTGUIController class-definition contract without +# ever being instantiated under --nogui. +try: + import non_hlos_parser +except ImportError: + class non_hlos_parser: # type: ignore + class nhlos_Operator: + pass import get_qsahara_files QUTS_STATE = None quts_path = None +# The "quts2" key is only present in the Windows branch of +# flags.py:global_info (see the platform check there). Guard the +# lookup so the Linux startup path does not raise KeyError. if os.path.exists(gl_info["quts"]): quts_path = gl_info["quts"] -elif os.path.exists(gl_info["quts2"]): +elif "quts2" in gl_info and os.path.exists(gl_info["quts2"]): quts_path = gl_info["quts2"] if quts_path and os.path.exists(os.path.join(quts_path,'Common','ttypes.py'))\ and os.path.exists(os.path.join(quts_path,'ImageManagementService','ImageManagementService.py'))\ @@ -1696,7 +1712,11 @@ def run(root, **kwargs): return """ else: import ctypes - ctypes.windll.user32.ShowWindow(ctypes.windll.kernel32.GetConsoleWindow(), 0) + # ctypes.windll exists only on Windows; this call hides the console + # window behind the GUI there. Guard it so the GUI also launches on + # Linux/macOS, where windll is absent (AttributeError otherwise). + if hasattr(ctypes, "windll"): + ctypes.windll.user32.ShowWindow(ctypes.windll.kernel32.GetConsoleWindow(), 0) DTGUIController(root, **kwargs) # start processing events diff --git a/dtwrapper.py b/dtwrapper.py index 9071185..e75fc35 100644 --- a/dtwrapper.py +++ b/dtwrapper.py @@ -592,7 +592,12 @@ def apply(self, fdt): for val in self.val_new: #dtlogger.debug(type(val)) if isinstance(val,type('')): - vals.append(int(val)) + # int(val, 0) auto-detects the base from a literal + # prefix ('0x' hex, '0o' octal, '0b' binary, else + # decimal). Plain int(val) only accepts decimal and + # rejects '0x...' tokens that callers naturally + # supply when mirroring fdtdump output. + vals.append(int(val, 0)) else: vals.append(val) #dtlogger.debug("new list is %s"%vals) @@ -1006,7 +1011,18 @@ def __init__(self): def _update_dtb(self): self.dtb_mappings = {} if self._fdt is not None: - self.dtb = self._fdt.to_dtb(self.dtb_mappings) + # Upstream PyPI pyfdt (0.3, the only released version) exposes + # `to_dtb(self)` with no parameters. QDTE was developed against + # an internal fork whose `to_dtb()` accepts an out-dict that gets + # populated with byte-offset mappings for the GUI hex viewer. + # Probe both signatures so we work with either: pass the + # mappings dict if accepted, otherwise call the zero-arg form + # and leave `dtb_mappings` empty (the hex viewer simply will + # not highlight node offsets in that case). + try: + self.dtb = self._fdt.to_dtb(self.dtb_mappings) + except TypeError: + self.dtb = self._fdt.to_dtb() else: self.dtb = None diff --git a/run.py b/run.py index fb3767d..b35d4ce 100644 --- a/run.py +++ b/run.py @@ -14,6 +14,45 @@ decide upon an appropriate tool to use and set up the output profile file, etc. """ import sys + +# When invoked in --nogui mode, QDTE never instantiates any tkinter +# widgets -- the controller.run() nogui branch only uses DTWrapper and +# Autocmd. Install a permissive stub for `tkinter` (and its submodules) +# before anything else loads so that the pervasive top-level +# `import tkinter` / `from tkinter import E` / `class X(tk.Frame)` +# statements throughout QDTE succeed in environments where Tcl/Tk is +# not available, such as minimal Yocto native sysroots. +if '--nogui' in sys.argv: + import types + + class _TkStub: + """Pretends to be any tkinter symbol -- class, constant, callable. + Subclassable (so `class Foo(tk.Frame)` works), callable (so + `tk.Tk()` works if it ever runs), attribute-polymorphic.""" + def __init__(self, *args, **kwargs): + pass + def __getattr__(self, name): + return _TkStub + def __call__(self, *args, **kwargs): + return _TkStub() + def __class_getitem__(cls, item): + return _TkStub + + def _make_stub_module(name): + mod = types.ModuleType(name) + mod.__path__ = [] # mark as package so submodule imports resolve + # PEP 562: __getattr__ on a module handles `from import X` + # for unknown names by returning the stub class. + mod.__getattr__ = lambda n: _TkStub + return mod + + sys.modules['tkinter'] = _make_stub_module('tkinter') + for _sub in ('font', 'ttk', 'colorchooser', 'messagebox', + 'filedialog', 'simpledialog', 'scrolledtext'): + _stub = _make_stub_module('tkinter.' + _sub) + sys.modules['tkinter.' + _sub] = _stub + setattr(sys.modules['tkinter'], _sub, _stub) + import argparse import tkinter as tk import flags as gflags