diff --git a/man/ukify.xml b/man/ukify.xml index 4531ac89b2..098dacfb99 100644 --- a/man/ukify.xml +++ b/man/ukify.xml @@ -23,9 +23,8 @@ /usr/lib/systemd/ukify - LINUX - INITRD OPTIONS + build @@ -35,13 +34,18 @@ Note: this command is experimental for now. While it is intended to become a regular component of systemd, it might still change in behaviour and interface. - ukify is a tool that combines components (e.g.: a kernel and an initrd with - a UEFI boot stub) to create a + ukify is a tool that combines components (usually a kernel, an initrd, and a + UEFI boot stub) to create a Unified Kernel Image (UKI) — a PE binary that can be executed by the firmware to start the embedded linux kernel. See systemd-stub7 for details about the stub. + The two primary options that should be specified for the build verb are + Linux=/, and + Initrd=/. Initrd= accepts multiple + whitespace-separated paths and can be specified multiple times. + Additional sections will be inserted into the UKI, either automatically or only if a specific option is provided. See the discussions of Cmdline=/, @@ -173,14 +177,14 @@ Linux=LINUX - positional argument LINUX + A path to the kernel binary. Initrd=INITRD... - positional argument INITRD + Zero or more initrd paths. In the configuration file, items are separated by whitespace. The initrds are combined in the order of specification, with the initrds specified in @@ -399,9 +403,9 @@ Minimal invocation - $ ukify \ - /lib/modules/6.0.9-300.fc37.x86_64/vmlinuz \ - /some/path/initramfs-6.0.9-300.fc37.x86_64.img \ + $ ukify build \ + --linux=/lib/modules/6.0.9-300.fc37.x86_64/vmlinuz \ + --initrd=/some/path/initramfs-6.0.9-300.fc37.x86_64.img \ --cmdline='quiet rw' @@ -411,10 +415,10 @@ All the bells and whistles - # /usr/lib/systemd/ukify \ - /lib/modules/6.0.9-300.fc37.x86_64/vmlinuz \ - early_cpio \ - /some/path/initramfs-6.0.9-300.fc37.x86_64.img \ + # /usr/lib/systemd/ukify build \ + --linux=/lib/modules/6.0.9-300.fc37.x86_64/vmlinuz \ + --initrd=early_cpio \ + --initrd=/some/path/initramfs-6.0.9-300.fc37.x86_64.img \ --pcr-private-key=pcr-private-initrd-key.pem \ --pcr-public-key=pcr-public-initrd-key.pem \ --phases='enter-initrd' \ @@ -468,9 +472,9 @@ Phases=enter-initrd:leave-initrd enter-initrd:leave-initrd:sysinit enter-initrd:leave-initrd:sysinit:ready -# /usr/lib/systemd/ukify -c ukify.conf \ - /lib/modules/6.0.9-300.fc37.x86_64/vmlinuz \ - /some/path/initramfs-6.0.9-300.fc37.x86_64.img +# /usr/lib/systemd/ukify -c ukify.conf build \ + --linux=/lib/modules/6.0.9-300.fc37.x86_64/vmlinuz \ + --initrd=/some/path/initramfs-6.0.9-300.fc37.x86_64.img One "initrd" (early_cpio) is specified in the config file, and @@ -482,7 +486,7 @@ Phases=enter-initrd:leave-initrd Kernel command line auxiliary PE - ukify \ + ukify build \ --secureboot-private-key=sb.key \ --secureboot-certificate=sb.cert \ --cmdline='debug' \ diff --git a/src/boot/efi/meson.build b/src/boot/efi/meson.build index b573e6996d..68c19fbcc9 100644 --- a/src/boot/efi/meson.build +++ b/src/boot/efi/meson.build @@ -1,6 +1,7 @@ # SPDX-License-Identifier: LGPL-2.1-or-later efi_config_h_dir = meson.current_build_dir() +efi_addon = '' if efi_arch != '' libefitest = static_library( @@ -376,6 +377,11 @@ foreach efi_elf_binary : efi_elf_binaries if name.startswith('linux') boot_stubs += exe endif + + # This is supposed to match exactly one time + if name == 'addon@0@.efi.stub'.format(efi_arch) + efi_addon = exe.full_path() + endif endforeach alias_target('systemd-boot', boot_targets) diff --git a/src/kernel-install/60-ukify.install.in b/src/kernel-install/60-ukify.install.in index 7c29f7e8af..0927bd7a2e 100755 --- a/src/kernel-install/60-ukify.install.in +++ b/src/kernel-install/60-ukify.install.in @@ -183,11 +183,10 @@ def call_ukify(opts): # The solution with runpy gives a dictionary, which isn't great, but will do. ukify = runpy.run_path(UKIFY, run_name='ukify') - # Create "empty" namespace. We want to override just a few settings, - # so it doesn't make sense to duplicate all the fields. We use a hack - # to pre-populate the namespace like argparse would, all defaults. - # We need to specify the two mandatory arguments to not get an error. - opts2 = ukify['create_parser']().parse_args(('A','B')) + # Create "empty" namespace. We want to override just a few settings, so it + # doesn't make sense to configure everything. We pretend to parse an empty + # argument set to prepopulate the namespace with the defaults. + opts2 = ukify['create_parser']().parse_args(()) opts2.config = config_file_location() opts2.uname = opts.kernel_version diff --git a/src/test/meson.build b/src/test/meson.build index 84d642bf6e..fe1a0fec21 100644 --- a/src/test/meson.build +++ b/src/test/meson.build @@ -15,6 +15,10 @@ test_env.set('SYSTEMD_LANGUAGE_FALLBACK_MAP', language_fallback_map) test_env.set('PATH', project_build_root + ':' + path) test_env.set('PROJECT_BUILD_ROOT', project_build_root) +if efi_addon != '' + test_env.set('EFI_ADDON', efi_addon) +endif + ############################################################ generate_sym_test_py = find_program('generate-sym-test.py') diff --git a/src/ukify/test/test_ukify.py b/src/ukify/test/test_ukify.py index 692b7a384b..eae82c7f88 100755 --- a/src/ukify/test/test_ukify.py +++ b/src/ukify/test/test_ukify.py @@ -50,9 +50,9 @@ def test_round_up(): assert ukify.round_up(4097) == 8192 def test_namespace_creation(): - ns = ukify.create_parser().parse_args(('A','B')) - assert ns.linux == pathlib.Path('A') - assert ns.initrd == [pathlib.Path('B')] + ns = ukify.create_parser().parse_args(()) + assert ns.linux is None + assert ns.initrd is None def test_config_example(): ex = ukify.config_example() @@ -87,7 +87,7 @@ def test_apply_config(tmp_path): Phases = {':'.join(ukify.KNOWN_PHASES)} ''')) - ns = ukify.create_parser().parse_args(('A','B')) + ns = ukify.create_parser().parse_args(()) ns.linux = None ns.initrd = [] ukify.apply_config(ns, config) @@ -143,7 +143,7 @@ def test_parse_args_minimal(): assert opts.os_release in (pathlib.Path('/etc/os-release'), pathlib.Path('/usr/lib/os-release')) -def test_parse_args_many(): +def test_parse_args_many_deprecated(): opts = ukify.parse_args( ['/ARG1', '///ARG2', '/ARG3 WITH SPACE', '--cmdline=a b c', @@ -186,9 +186,57 @@ def test_parse_args_many(): assert opts.output == pathlib.Path('OUTPUT') assert opts.measure is False +def test_parse_args_many(): + opts = ukify.parse_args( + ['build', + '--linux=/ARG1', + '--initrd=///ARG2', + '--initrd=/ARG3 WITH SPACE', + '--cmdline=a b c', + '--os-release=K1=V1\nK2=V2', + '--devicetree=DDDDTTTT', + '--splash=splash', + '--pcrpkey=PATH', + '--uname=1.2.3', + '--stub=STUBPATH', + '--pcr-private-key=PKEY1', + '--pcr-public-key=PKEY2', + '--pcr-banks=SHA1,SHA256', + '--signing-engine=ENGINE', + '--secureboot-private-key=SBKEY', + '--secureboot-certificate=SBCERT', + '--sign-kernel', + '--no-sign-kernel', + '--tools=TOOLZ///', + '--output=OUTPUT', + '--measure', + '--no-measure', + ]) + assert opts.linux == pathlib.Path('/ARG1') + assert opts.initrd == [pathlib.Path('/ARG2'), pathlib.Path('/ARG3 WITH SPACE')] + assert opts.cmdline == 'a b c' + assert opts.os_release == 'K1=V1\nK2=V2' + assert opts.devicetree == pathlib.Path('DDDDTTTT') + assert opts.splash == pathlib.Path('splash') + assert opts.pcrpkey == pathlib.Path('PATH') + assert opts.uname == '1.2.3' + assert opts.stub == pathlib.Path('STUBPATH') + assert opts.pcr_private_keys == [pathlib.Path('PKEY1')] + assert opts.pcr_public_keys == [pathlib.Path('PKEY2')] + assert opts.pcr_banks == ['SHA1', 'SHA256'] + assert opts.signing_engine == 'ENGINE' + assert opts.sb_key == 'SBKEY' + assert opts.sb_cert == 'SBCERT' + assert opts.sign_kernel is False + assert opts.tools == [pathlib.Path('TOOLZ/')] + assert opts.output == pathlib.Path('OUTPUT') + assert opts.measure is False + def test_parse_sections(): opts = ukify.parse_args( - ['/ARG1', '/ARG2', + ['build', + '--linux=/ARG1', + '--initrd=/ARG2', '--section=test:TESTTESTTEST', '--section=test2:@FILE', ]) @@ -239,7 +287,10 @@ def test_config_priority(tmp_path): ''')) opts = ukify.parse_args( - ['/ARG1', '///ARG2', '/ARG3 WITH SPACE', + ['build', + '--linux=/ARG1', + '--initrd=///ARG2', + '--initrd=/ARG3 WITH SPACE', '--cmdline= a b c ', '--os-release=K1=V1\nK2=V2', '--devicetree=DDDDTTTT', @@ -302,7 +353,7 @@ def test_help(capsys): assert '--section' in out.out assert not out.err -def test_help_error(capsys): +def test_help_error_deprecated(capsys): with pytest.raises(SystemExit): ukify.parse_args(['a', 'b', '--no-such-option']) out = capsys.readouterr() @@ -310,6 +361,14 @@ def test_help_error(capsys): assert '--no-such-option' in out.err assert len(out.err.splitlines()) == 1 +def test_help_error(capsys): + with pytest.raises(SystemExit): + ukify.parse_args(['build', '--no-such-option']) + out = capsys.readouterr() + assert not out.out + assert '--no-such-option' in out.err + assert len(out.err.splitlines()) == 1 + @pytest.fixture(scope='session') def kernel_initrd(): try: @@ -326,7 +385,7 @@ def kernel_initrd(): initrd = f"{item['root']}{item['initrd'][0].split(' ')[0]}" except (KeyError, IndexError): continue - return [linux, initrd] + return ['--linux', linux, '--initrd', initrd] else: return None @@ -345,7 +404,11 @@ def test_basic_operation(kernel_initrd, tmpdir): pytest.skip('linux+initrd not found') output = f'{tmpdir}/basic.efi' - opts = ukify.parse_args(kernel_initrd + [f'--output={output}']) + opts = ukify.parse_args([ + 'build', + *kernel_initrd, + f'--output={output}', + ]) try: ukify.check_inputs(opts) except OSError as e: @@ -362,6 +425,7 @@ def test_sections(kernel_initrd, tmpdir): output = f'{tmpdir}/basic.efi' opts = ukify.parse_args([ + 'build', *kernel_initrd, f'--output={output}', '--uname=1.2.3', @@ -385,15 +449,22 @@ def test_sections(kernel_initrd, tmpdir): def test_addon(kernel_initrd, tmpdir): output = f'{tmpdir}/addon.efi' - opts = ukify.parse_args([ + args = [ + 'build', f'--output={output}', '--cmdline=ARG1 ARG2 ARG3', '--section=.test:CONTENTZ', - ]) + ] + if stub := os.getenv('EFI_ADDON'): + args += [f'--stub={stub}'] + expected_exceptions = () + else: + expected_exceptions = FileNotFoundError, + opts = ukify.parse_args(args) try: ukify.check_inputs(opts) - except OSError as e: + except expected_exceptions as e: pytest.skip(str(e)) ukify.make_uki(opts) @@ -416,7 +487,8 @@ def test_uname_scraping(kernel_initrd): if kernel_initrd is None: pytest.skip('linux+initrd not found') - uname = ukify.Uname.scrape(kernel_initrd[0]) + assert kernel_initrd[0] == '--linux' + uname = ukify.Uname.scrape(kernel_initrd[1]) assert re.match(r'\d+\.\d+\.\d+', uname) def test_efi_signing_sbsign(kernel_initrd, tmpdir): @@ -431,6 +503,7 @@ def test_efi_signing_sbsign(kernel_initrd, tmpdir): output = f'{tmpdir}/signed.efi' opts = ukify.parse_args([ + 'build', *kernel_initrd, f'--output={output}', '--uname=1.2.3', @@ -474,6 +547,7 @@ def test_efi_signing_pesign(kernel_initrd, tmpdir): output = f'{tmpdir}/signed.efi' opts = ukify.parse_args([ + 'build', *kernel_initrd, f'--output={output}', '--uname=1.2.3', @@ -501,10 +575,6 @@ def test_efi_signing_pesign(kernel_initrd, tmpdir): def test_pcr_signing(kernel_initrd, tmpdir): if kernel_initrd is None: pytest.skip('linux+initrd not found') - if os.getuid() != 0: - pytest.skip('must be root to access tpm2') - if subprocess.call(['systemd-creds', 'has-tpm2', '-q']) != 0: - pytest.skip('tpm2 is not available') ourdir = pathlib.Path(__file__).parent pub = unbase64(ourdir / 'example.tpm2-pcr-public.pem.base64') @@ -512,6 +582,7 @@ def test_pcr_signing(kernel_initrd, tmpdir): output = f'{tmpdir}/signed.efi' opts = ukify.parse_args([ + 'build', *kernel_initrd, f'--output={output}', '--uname=1.2.3', @@ -562,10 +633,6 @@ def test_pcr_signing(kernel_initrd, tmpdir): def test_pcr_signing2(kernel_initrd, tmpdir): if kernel_initrd is None: pytest.skip('linux+initrd not found') - if os.getuid() != 0: - pytest.skip('must be root to access tpm2') - if subprocess.call(['systemd-creds', 'has-tpm2', '-q']) != 0: - pytest.skip('tpm2 is not available') ourdir = pathlib.Path(__file__).parent pub = unbase64(ourdir / 'example.tpm2-pcr-public.pem.base64') @@ -578,8 +645,12 @@ def test_pcr_signing2(kernel_initrd, tmpdir): microcode.write(b'1234567890') output = f'{tmpdir}/signed.efi' + assert kernel_initrd[0] == '--linux' opts = ukify.parse_args([ - kernel_initrd[0], microcode.name, kernel_initrd[1], + 'build', + *kernel_initrd[:2], + f'--initrd={microcode.name}', + *kernel_initrd[2:], f'--output={output}', '--uname=1.2.3', '--cmdline=ARG1 ARG2 ARG3', diff --git a/src/ukify/ukify.py b/src/ukify/ukify.py index 19896afac3..a9c21601df 100755 --- a/src/ukify/ukify.py +++ b/src/ukify/ukify.py @@ -19,7 +19,9 @@ # pylint: disable=missing-docstring,invalid-name,import-outside-toplevel # pylint: disable=consider-using-with,unspecified-encoding,line-too-long # pylint: disable=too-many-locals,too-many-statements,too-many-return-statements -# pylint: disable=too-many-branches,fixme +# pylint: disable=too-many-branches,too-many-lines,too-many-instance-attributes +# pylint: disable=too-many-arguments,unnecessary-lambda-assignment,fixme +# pylint: disable=unused-argument import argparse import configparser @@ -436,9 +438,9 @@ def call_systemd_measure(uki, linux, opts): def join_initrds(initrds): - if len(initrds) == 0: + if not initrds: return None - elif len(initrds) == 1: + if len(initrds) == 1: return initrds[0] seq = [] @@ -478,6 +480,9 @@ def pe_add_sections(uki: UKI, output: str): pe.FILE_HEADER.IMAGE_FILE_LOCAL_SYMS_STRIPPED = True # Old stubs might have been stripped, leading to unaligned raw data values, so let's fix them up here. + # pylint thinks that Structure doesn't have various members that it has… + # pylint: disable=no-member + for i, section in enumerate(pe.sections): oldp = section.PointerToRawData oldsz = section.SizeOfRawData @@ -745,6 +750,7 @@ class ConfigItem: ) -> None: "Set namespace.[idx] to value, with idx derived from group" + # pylint: disable=protected-access if group not in namespace._groups: namespace._groups += [group] idx = namespace._groups.index(group) @@ -814,7 +820,10 @@ class ConfigItem: else: conv = lambda s:s - if self.nargs == '*': + # This is a bit ugly, but --initrd is the only option which is specified + # with multiple args on the command line and a space-separated list in the + # config file. + if self.name == '--initrd': value = [conv(v) for v in value.split()] else: value = conv(value) @@ -834,7 +843,16 @@ class ConfigItem: return (section_name, key, value) +VERBS = ('build',) + CONFIG_ITEMS = [ + ConfigItem( + 'positional', + metavar = 'VERB', + nargs = '*', + help = f"operation to perform ({','.join(VERBS)})", + ), + ConfigItem( '--version', action = 'version', @@ -848,20 +866,18 @@ CONFIG_ITEMS = [ ), ConfigItem( - 'linux', - metavar = 'LINUX', + '--linux', type = pathlib.Path, - nargs = '?', help = 'vmlinuz file [.linux section]', config_key = 'UKI/Linux', ), ConfigItem( - 'initrd', - metavar = 'INITRD…', + '--initrd', + metavar = 'INITRD', type = pathlib.Path, - nargs = '*', - help = 'initrd files [.initrd section]', + action = 'append', + help = 'initrd file [part of .initrd section]', config_key = 'UKI/Initrd', config_push = ConfigItem.config_list_prepend, ), @@ -1068,7 +1084,7 @@ def apply_config(namespace, filename=None): # Fill in ._groups based on --pcr-public-key=, --pcr-private-key=, and --phases=. assert '_groups' not in namespace n_pcr_priv = len(namespace.pcr_private_keys or ()) - namespace._groups = list(range(n_pcr_priv)) + namespace._groups = list(range(n_pcr_priv)) # pylint: disable=protected-access cp = configparser.ConfigParser( comment_prefixes='#', @@ -1193,6 +1209,20 @@ def parse_args(args=None): p = create_parser() opts = p.parse_args(args) + # Figure out which syntax is being used, one of: + # ukify verb --arg --arg --arg + # ukify linux initrd… + if len(opts.positional) == 1 and opts.positional[0] in VERBS: + opts.verb = opts.positional[0] + elif opts.linux or opts.initrd: + raise ValueError('--linux/--initrd options cannot be used with positional arguments') + else: + print("Assuming obsolete commandline syntax with no verb. Please use 'build'.") + if opts.positional: + opts.linux = pathlib.Path(opts.positional[0]) + opts.initrd = [pathlib.Path(arg) for arg in opts.positional[1:]] + opts.verb = 'build' + # Check that --pcr-public-key=, --pcr-private-key=, and --phases= # have either the same number of arguments are are not specified at all. n_pcr_pub = None if opts.pcr_public_keys is None else len(opts.pcr_public_keys) @@ -1213,6 +1243,7 @@ def parse_args(args=None): def main(): opts = parse_args() check_inputs(opts) + assert opts.verb == 'build' make_uki(opts)