<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>
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:
'.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:
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}')
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')
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:
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)