From: Paul Meyer Date: Sun, 7 Jun 2026 17:07:41 +0000 (+0200) Subject: ukify: show all sections and profiles in inspect JSON output X-Git-Url: http://git.ipfire.org/gitweb/?a=commitdiff_plain;h=1ef7c15bd81eb6a46cd6a3d6b006c20bbae9b078;p=thirdparty%2Fsystemd.git ukify: show all sections and profiles in inspect JSON output The JSON output keyed every section by name, so a UKI with repeated sections only showed the last one: in a multi-profile UKI all but one .profile/.cmdline/.pcrsig were dropped, as were extra .dtbauto/.efifw. Report the shared base sections by name at the top level (so .cmdline etc. stay where they were), each profile as its own by-name object under a new "_profiles" array, and the alternative-set sections (.dtbauto, .efifw) as arrays. Co-developed-by: Claude Opus 4.8 Signed-off-by: Paul Meyer --- diff --git a/man/ukify.xml b/man/ukify.xml index 0e818dbc4fb..094ecdf9f6c 100644 --- a/man/ukify.xml +++ b/man/ukify.xml @@ -335,7 +335,14 @@ - Generate JSON output (with inspect verb). + Generate JSON output (with inspect verb). The output is an + object whose keys are the names of the sections shared between all profiles (i.e. those + preceding the first .profile section), each mapping to an object describing + the section by its size and sha256, plus a + text field for textual sections. UKI sections that can occur multiple times + (in the same profile) are represented as array. If the image contains profiles, an additional + _profiles key holds an array with one object per profile, each keyed by + section name in the same way (starting with its .profile section). diff --git a/src/ukify/test/test_ukify.py b/src/ukify/test/test_ukify.py index 11f843ddb60..bed9da4dd2a 100755 --- a/src/ukify/test/test_ukify.py +++ b/src/ukify/test/test_ukify.py @@ -774,6 +774,114 @@ def test_inspect_no_osrel(kernel_initrd, tmp_path, capsys): test_inspect(kernel_initrd, tmp_path, capsys, osrel=False) +def build_inspect_uki(*args): + # Build a UKI for the inspect-JSON tests. Building needs a stub; CI provides the addon stub via + # $EFI_ADDON (see test_addon), so build addon-style (no --linux) to need only that one. Skip if + # the stub is unavailable. + stub = os.getenv('EFI_ADDON') + if not stub: + pytest.skip('EFI_ADDON not set') + opts = ukify.parse_args(['build', f'--stub={stub}', *args, *arg_tools]) + try: + ukify.check_inputs(opts) + except FileNotFoundError as e: + pytest.skip(str(e)) + ukify.make_uki(opts) + + +def test_inspect_json_profiles(tmp_path, capsys): + # Build two standalone profile PE binaries, each carrying its own '.cmdline'. + profiles = [] + for i in range(2): + profile = f'{tmp_path}/profile{i}.efi' + build_inspect_uki(f'--profile=ID=profile{i}', f'--cmdline=PROFILE{i}ARG', f'--output={profile}') + profiles.append(profile) + + # Build the base UKI joining both profiles. The result has a shared base '.cmdline' plus one + # '.cmdline' (and one '.profile') per profile, including the implicit base profile. + output = f'{tmp_path}/base.efi' + build_inspect_uki( + '--cmdline=BASEARG', + '--uname=1.2.3', + *(f'--join-profile={p}' for p in profiles), + f'--output={output}', + ) + + opts = ukify.parse_args(['inspect', output, '--json=short']) + ukify.inspect_sections(opts) + result = json.loads(capsys.readouterr().out) + + # Shared base sections stay keyed by name at the top level, just as for a profile-less UKI, so + # the base '.cmdline' is not overwritten by the profile-specific ones. + assert result['.cmdline']['text'] == 'BASEARG' + assert result['.uname']['text'] == '1.2.3' + + # Implicit base profile (ID=main) plus the two joined profiles, each keyed by section name and + # starting with its '.profile' section. + assert len(result['_profiles']) == 3 + assert [p['.profile']['text'] for p in result['_profiles']] == ['ID=main', 'ID=profile0', 'ID=profile1'] + + # Each joined profile's distinct '.cmdline' survives instead of being overwritten. + profile_cmdlines = [p['.cmdline']['text'] for p in result['_profiles'] if '.cmdline' in p] + assert profile_cmdlines == ['PROFILE0ARG', 'PROFILE1ARG'] + + shutil.rmtree(tmp_path) + + +def test_inspect_json_alternative_set_sections(tmp_path, capsys): + # '.dtbauto' and '.efifw' are alternative-sets (one entry per hardware variant): every occurrence + # must be preserved, and they are always reported as a list, even for a single entry. The two are + # built differently ('.efifw' via --efifw on a directory, measure=False) but flow through the same + # list path, so both are exercised. + def inspect(output): + opts = ukify.parse_args(['inspect', str(output), '--json=short']) + ukify.inspect_sections(opts) + return json.loads(capsys.readouterr().out) + + dtbs = [] + for i in range(2): + dtb = tmp_path / f'dtb{i}' + dtb.write_bytes(f'DTB{i}'.encode()) + dtbs.append(dtb) + + # --efifw takes a directory containing exactly one firmware file; the fwid is the directory name. + efifws = [] + for i in range(2): + fwdir = tmp_path / f'fw{i}' + fwdir.mkdir() + (fwdir / 'firmware.bin').write_bytes(f'EFIFW{i}'.encode()) + efifws.append(fwdir) + + output = f'{tmp_path}/alt.efi' + build_inspect_uki( + '--cmdline=ARG', + *(f'--devicetree-auto={d}' for d in dtbs), + *(f'--efifw={d}' for d in efifws), + f'--output={output}', + ) + result = inspect(output) + + # No profiles, so no '_profiles' key and single sections stay plain objects (backwards compatible). + assert '_profiles' not in result + assert result['.cmdline']['text'] == 'ARG' + + # Both occurrences of each alternative-set section are preserved as a list, none dropped. + for name in ('.dtbauto', '.efifw'): + assert isinstance(result[name], list), name + assert len(result[name]) == 2, name + assert all('sha256' in d for d in result[name]), name + assert result[name][0]['sha256'] != result[name][1]['sha256'], name + + # A single entry is still reported as a (one-element) list, not a bare object. + single = f'{tmp_path}/single.efi' + build_inspect_uki(f'--devicetree-auto={dtbs[0]}', f'--efifw={efifws[0]}', f'--output={single}') + result = inspect(single) + assert isinstance(result['.dtbauto'], list) and len(result['.dtbauto']) == 1 + assert isinstance(result['.efifw'], list) and len(result['.efifw']) == 1 + + shutil.rmtree(tmp_path) + + @pytest.mark.skipif(not slow_tests, reason='slow') def test_pcr_signing(kernel_initrd, tmp_path): if kernel_initrd is None: diff --git a/src/ukify/ukify.py b/src/ukify/ukify.py index 7e3dd9038cb..074575902cc 100755 --- a/src/ukify/ukify.py +++ b/src/ukify/ukify.py @@ -419,6 +419,11 @@ DEFAULT_SECTIONS_TO_SHOW = { '.profile': 'text', } # fmt: skip +# Sections that may legitimately appear more than once within a single profile: they carry one entry +# per hardware variant and the firmware picks the matching one at boot. 'inspect --json' therefore +# always reports them as a list, and add_section() allows them to repeat. +MULTI_INSTANCE_SECTIONS = ('.dtbauto', '.efifw') + @dataclasses.dataclass class Section: @@ -490,9 +495,8 @@ class UKI: if s.name == '.profile': start = i + 1 - multiple_allowed_sections = ['.dtbauto', '.efifw'] if any( - section.name == s.name for s in self.sections[start:] if s.name not in multiple_allowed_sections + section.name == s.name for s in self.sections[start:] if s.name not in MULTI_INSTANCE_SECTIONS ): raise ValueError(f'Duplicate section {section.name}') @@ -1695,14 +1699,15 @@ def generate_keys(opts: UkifyConfig) -> None: def inspect_section( opts: UkifyConfig, section: pefile.SectionStructure, -) -> tuple[str, Optional[dict[str, Union[int, str]]]]: - name = pe_strip_section_name(section.Name) - - # find the config for this section in opts and whether to show it + name: str, + force: bool = False, +) -> Optional[dict[str, Union[int, str]]]: + # find the config for this section in opts and whether to show it ('force' is used for the + # '.profile' delimiters, which must always appear in the JSON profile structure) config = opts.sections_by_name.get(name, None) - show = config or opts.all or (name in DEFAULT_SECTIONS_TO_SHOW and not opts.sections) + show = force or config or opts.all or (name in DEFAULT_SECTIONS_TO_SHOW and not opts.sections) if not show: - return name, None + return None ttype = config.output_mode if config else DEFAULT_SECTIONS_TO_SHOW.get(name, 'binary') @@ -1732,7 +1737,26 @@ def inspect_section( text = textwrap.indent(cast(str, struct['text']).rstrip(), ' ' * 4) print(f' text:\n{text}') - return name, struct + return struct + + +def add_section_to_profile(profile: dict[str, Any], name: str, desc: dict[str, Union[int, str]]) -> None: + # Sections in MULTI_INSTANCE_SECTIONS can occur multiple times within the same profile so always + # report them as a list even when there's a single entry. Every other section is unique within a + # profile and is reported as a plain object keyed by name. A repeat of such a section means the + # image is malformed; warn, but still report every instance by turning the entry into a list so + # nothing is dropped. + if name in MULTI_INSTANCE_SECTIONS: + profile.setdefault(name, []) + profile[name] += [desc] + elif name not in profile: + profile[name] = desc + else: + print(f'Unexpected duplicate {name!r} section, reporting all instances as a list', file=sys.stderr) + if isinstance(profile[name], list): + profile[name] += [desc] + else: + profile[name] = [profile[name], desc] def inspect_sections(opts: UkifyConfig) -> None: @@ -1740,10 +1764,36 @@ def inspect_sections(opts: UkifyConfig) -> None: for file in opts.files: pe = pefile.PE(file, fast_load=True) - gen = (inspect_section(opts, section) for section in pe.sections) - descs = {key: val for (key, val) in gen if val} - if opts.json != 'off': - json.dump(descs, sys.stdout, indent=indent) + + # A UKI is a flat list of PE sections with profile structure: the sections before the first + # '.profile' section belong to the base profile, and each subsequent '.profile' section + # introduces a further profile whose sections (up to the next '.profile') override the base + # ones. For JSON output the base profile's sections are reported by name at the root (so e.g. + # '.cmdline' refers to the base profile) and the further profiles as separate by-name objects + # in the '_profiles' list. That key is '_profiles' and not 'profiles' so it can never collide + # with a section of that name: PE section names are at most 8 bytes, so a 9-byte key is never + # a section name. + emit_json = opts.json != 'off' + base: dict[str, Any] = {} + profiles: list[dict[str, Any]] = [] + profile = base # sections fill the base profile until the first '.profile' delimiter + + for section in pe.sections: + name = pe_strip_section_name(section.Name) + if emit_json and name == '.profile': + profile = {} + profiles += [profile] + + # The '.profile' delimiter is forced into the structure so every profile object carries + # its identity, even when --section filters out the other sections. + desc = inspect_section(opts, section, name, force=emit_json and name == '.profile') + if desc and emit_json: + add_section_to_profile(profile, name, desc) + + if emit_json: + if profiles: + base['_profiles'] = profiles + json.dump(base, sys.stdout, indent=indent) @dataclasses.dataclass(frozen=True)