mirror of
https://github.com/systemd/systemd.git
synced 2026-08-11 17:45:59 +00:00
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 commit1ef7c15bd8) (cherry picked from commite61272caf1)
This commit is contained in:
committed by
Luca Boccassi
parent
0c2e0eb97e
commit
b3bdf9b2a0
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user