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 <noreply@anthropic.com>
Signed-off-by: Paul Meyer <katexochen0@gmail.com>
(cherry picked from commit 1ef7c15bd8)
(cherry picked from commit e61272caf1)
This commit is contained in:
Paul Meyer
2026-06-07 19:07:41 +02:00
committed by Luca Boccassi
parent 0c2e0eb97e
commit b3bdf9b2a0
3 changed files with 180 additions and 14 deletions

View File

@@ -335,7 +335,14 @@
<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>

View File

@@ -704,6 +704,115 @@ def test_inspect(kernel_initrd, tmp_path, capsys, osrel=True):
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:

View File

@@ -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)