]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
ukify: show all sections and profiles in inspect JSON output
authorPaul Meyer <katexochen0@gmail.com>
Sun, 7 Jun 2026 17:07:41 +0000 (19:07 +0200)
committerZbigniew Jędrzejewski-Szmek <zbyszek@in.waw.pl>
Thu, 2 Jul 2026 11:26:33 +0000 (13:26 +0200)
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 <noreply@anthropic.com>
Signed-off-by: Paul Meyer <katexochen0@gmail.com>
man/ukify.xml
src/ukify/test/test_ukify.py
src/ukify/ukify.py

index 0e818dbc4fb90e9c82a6aee887d854b6ae71ea41..094ecdf9f6c06a1d171400b8e6e1c4124a7795f4 100644 (file)
         <varlistentry>
           <term><option>--json</option></term>
 
-          <listitem><para>Generate JSON output (with <command>inspect</command> verb).</para>
+          <listitem><para>Generate JSON output (with <command>inspect</command> verb). The output is an
+          object whose keys are the names of the sections shared between all profiles (i.e. those
+          preceding the first <literal>.profile</literal> section), each mapping to an object describing
+          the section by its <literal>size</literal> and <literal>sha256</literal>, plus a
+          <literal>text</literal> 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
+          <literal>_profiles</literal> key holds an array with one object per profile, each keyed by
+          section name in the same way (starting with its <literal>.profile</literal> section).</para>
 
           <xi:include href="version-info.xml" xpointer="v255"/></listitem>
         </varlistentry>
index 11f843ddb601574f4c976c98f8fd4f20b7160dd6..bed9da4dd2a92e8e4dbbeada8995895853edb6b4 100755 (executable)
@@ -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:
index 7e3dd9038cbd7f274ee0237bbc70b8d24729583a..074575902cc649aae1db30e3adc279827f160f7b 100755 (executable)
@@ -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)