]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
ukify: rework option parsing to support a config file
authorZbigniew Jędrzejewski-Szmek <zbyszek@in.waw.pl>
Thu, 20 Apr 2023 18:22:25 +0000 (20:22 +0200)
committerZbigniew Jędrzejewski-Szmek <zbyszek@in.waw.pl>
Fri, 5 May 2023 16:42:03 +0000 (18:42 +0200)
In some ways this is similar to mkosi: we have a argparse.ArgumentParser()
with a bunch of options, and a configparser.ConfigParser() with an
overlapping set of options. Many options are settable in both places, but
not all. In mkosi, we define this in three places (a dataclass, and a
function for argparse, and a function for configparser). Here, we have one
huge list of ConfigItem instances. Each instance specifies the full metadata
for both parsers. Argparse generates a --help string for all the options,
and we also append a config file sample to --help based on the ConfigItem
data:

$ python src/ukify/ukify.py --help|tail -n 25
config file:
  [UKI]
  Linux = LINUX
  Initrd = INITRD…
  Cmdline = TEXT|@PATH
  OSRelease = TEXT|@PATH
  DeviceTree = PATH
  Splash = BMP
  PCRPKey = KEY
  Uname = VERSION
  EFIArch = ia32|x64|arm|aa64|riscv64
  Stub = STUB
  PCRBanks = BANK…
  SigningEngine = ENGINE
  SecureBootPrivateKey = SB_KEY
  SecureBootCertificate = SB_CERT
  SignKernel = SIGN_KERNEL

  [PCRSignature:NAME]
  PCRPrivateKey = PATH
  PCRPublicKey = PATH
  Phases = PHASE-PATH…

While writing this I needed to check the argument parsing, so I added
a --summary switch. It just pretty-prints the resulting option dictionary:

$ python src/ukify/ukify.py /efi//3a9d668b4db749398a4a5e78a03bffa5/6.2.11-300.fc38.x86_64/linux /efi//3a9d668b4db749398a4a5e78a03bffa5/6.2.11-300.fc38.x86_64/initrd --pcr-private-key=PRIV.key --pcr-public-key=PUB.key --config=man/ukify-example.conf --summary
Host arch 'x86_64', EFI arch 'x64'
{'_groups': [0, 'initrd', 'system'],
 'cmdline': 'A1 B2 C3',
 'config': 'man/ukify-example.conf',
 'devicetree': None,
 'efi_arch': 'x64',
 'initrd': [PosixPath('initrd1'),
            PosixPath('initrd2'),
            PosixPath('initrd3'),
            PosixPath('/efi/3a9d668b4db749398a4a5e78a03bffa5/6.2.11-300.fc38.x86_64/initrd')],
 'linux': PosixPath('/efi/3a9d668b4db749398a4a5e78a03bffa5/6.2.11-300.fc38.x86_64/linux'),
 'measure': None,
 'os_release': PosixPath('/etc/os-release'),
 'output': 'linux.efi',
 'pcr_banks': ['sha1', 'sha384'],
 'pcr_private_keys': [PosixPath('PRIV.key'),
                      PosixPath('pcr-private-initrd-key.pem'),
                      PosixPath('pcr-private-system-key.pem')],
 'pcr_public_keys': [PosixPath('PUB.key'),
                     PosixPath('pcr-public-initrd-key.pem'),
                     PosixPath('pcr-public-system-key.pem')],
 'pcrpkey': None,
 'phase_path_groups': [None,
                       ['enter-initrd'],
                       ['enter-initrd:leave-initrd',
                        'enter-initrd:leave-initrd:sysinit',
                        'enter-initrd:leave-initrd:sysinit:ready']],
 'sb_cert': PosixPath('mkosi.secure-boot.crt'),
 'sb_key': PosixPath('mkosi.secure-boot.key'),
 'sections': [],
 'sign_kernel': None,
 'signing_engine': None,
 'splash': None,
 'stub': PosixPath('/usr/lib/systemd/boot/efi/linuxx64.efi.stub'),
 'summary': True,
 'tools': None,
 'uname': None}

With --summary, existence of input paths is not checked. I think we'll
want to show them, instead of throwing an error, but in red, similarly to
'bootctl list'.

This also fixes tests which were failing with e.g.
E       FileNotFoundError: [Errno 2] No such file or directory: '/ARG1'
=========================== short test summary info ============================
FAILED ../src/ukify/test/test_ukify.py::test_parse_args_minimal - FileNotFoun...
FAILED ../src/ukify/test/test_ukify.py::test_parse_args_many - FileNotFoundEr...
FAILED ../src/ukify/test/test_ukify.py::test_parse_sections - FileNotFoundErr...
=================== 3 failed, 10 passed, 3 skipped in 1.51s ====================

src/ukify/ukify.py

index 599f872bc1a1c560fd010cb6d41ceba5eaa60e6e..033acba3d7f843e92a5fdbf4ec1bbc41dbe69ff6 100755 (executable)
@@ -22,6 +22,7 @@
 # pylint: disable=too-many-branches,fixme
 
 import argparse
+import configparser
 import collections
 import dataclasses
 import fnmatch
@@ -29,10 +30,12 @@ import itertools
 import json
 import os
 import pathlib
+import pprint
 import re
 import shlex
 import shutil
 import subprocess
+import sys
 import tempfile
 import typing
 
@@ -84,18 +87,6 @@ def shell_join(cmd):
     return ' '.join(shlex.quote(str(x)) for x in cmd)
 
 
-def path_is_readable(s: typing.Optional[str]) -> typing.Optional[pathlib.Path]:
-    """Convert a filename string to a Path and verify access."""
-    if s is None:
-        return None
-    p = pathlib.Path(s)
-    try:
-        p.open().close()
-    except IsADirectoryError:
-        pass
-    return p
-
-
 def round_up(x, blocksize=4096):
     return (x + blocksize - 1) // blocksize * blocksize
 
@@ -337,11 +328,13 @@ def check_inputs(opts):
         if name in {'output', 'tools'}:
             continue
 
-        if not isinstance(value, pathlib.Path):
-            continue
-
-        # Open file to check that we can read it, or generate an exception
-        value.open().close()
+        if isinstance(value, pathlib.Path):
+            # Open file to check that we can read it, or generate an exception
+            value.open().close()
+        elif isinstance(value, list):
+            for item in value:
+                if isinstance(item, pathlib.Path):
+                    item.open().close()
 
     check_splash(opts.splash)
 
@@ -668,157 +661,412 @@ def make_uki(opts):
     print(f"Wrote {'signed' if opts.sb_key else 'unsigned'} {opts.output}")
 
 
-def parse_args(args=None):
+@dataclasses.dataclass(frozen=True)
+class ConfigItem:
+    @staticmethod
+    def config_list_prepend(namespace, group, dest, value):
+        "Prepend value to namespace.<dest>"
+
+        assert not group
+
+        old = getattr(namespace, dest, [])
+        setattr(namespace, dest, value + old)
+
+    @staticmethod
+    def config_set_if_unset(namespace, group, dest, value):
+        "Set namespace.<dest> to value only if it was None"
+
+        assert not group
+
+        if getattr(namespace, dest) is None:
+            setattr(namespace, dest, value)
+
+    @staticmethod
+    def config_set_group(namespace, group, dest, value):
+        "Set namespace.<dest>[idx] to value, with idx derived from group"
+
+        if group not in namespace._groups:
+            namespace._groups += [group]
+        idx = namespace._groups.index(group)
+
+        old = getattr(namespace, dest, None)
+        if old is None:
+            old = []
+        setattr(namespace, dest,
+                old + ([None] * (idx - len(old))) + [value])
+
+    @staticmethod
+    def parse_boolean(s: str) -> bool:
+        "Parse 1/true/yes/y/t/on as true and 0/false/no/n/f/off/None as false"
+        s_l = s.lower()
+        if s_l in {'1', 'true', 'yes', 'y', 't', 'on'}:
+            return True
+        if s_l in {'0', 'false', 'no', 'n', 'f', 'off'}:
+            return False
+        raise ValueError('f"Invalid boolean literal: {s!r}')
+
+    # arguments for argparse.ArgumentParser.add_argument()
+    name: typing.Union[str, typing.List[str]]
+    dest: str = None
+    metavar: str = None
+    type: typing.Callable = None
+    nargs: str = None
+    action: typing.Callable = None
+    default: typing.Any = None
+    version: str = None
+    choices: typing.List[str] = None
+    help: str = None
+
+    # metadata for config file parsing
+    config_key: str = None
+    config_push: typing.Callable[..., ...] = config_set_if_unset
+
+    def _names(self) -> typing.Tuple[str]:
+        return self.name if isinstance(self.name, tuple) else (self.name,)
+
+    def argparse_dest(self) -> str:
+        # It'd be nice if argparse exported this, but I don't see that in the API
+        if self.dest:
+            return self.dest
+        return self._names()[0].lstrip('-').replace('-', '_')
+
+    def add_to(self, parser: argparse.ArgumentParser):
+        kwargs = { key:val
+                   for key in dataclasses.asdict(self)
+                   if (key not in ('name', 'config_key', 'config_push') and
+                       (val := getattr(self, key)) is not None) }
+        args = self._names()
+        parser.add_argument(*args, **kwargs)
+
+    def apply_config(self, namespace, section, group, key, value) -> None:
+        assert f'{section}/{key}' == self.config_key
+        dest = self.argparse_dest()
+
+        if self.action == argparse.BooleanOptionalAction:
+            # We need to handle this case separately: the options are called
+            # --foo and --no-foo, and no argument is parsed. But in the config
+            # file, we have Foo=yes or Foo=no.
+            conv = self.parse_boolean
+        elif self.type:
+            conv = self.type
+        else:
+            conv = lambda s:s
+
+        if self.nargs == '*':
+            value = [conv(v) for v in value.split()]
+        else:
+            value = conv(value)
+
+        self.config_push(namespace, group, dest, value)
+
+    def config_example(self) -> typing.Tuple[typing.Optional[str]]:
+        if not self.config_key:
+            return None, None, None
+        section_name, key = self.config_key.split('/', 1)
+        if section_name.endswith(':'):
+            section_name += 'NAME'
+        if self.choices:
+            value = '|'.join(self.choices)
+        else:
+            value = self.metavar or self.argparse_dest().upper()
+        return (section_name, key, value)
+
+
+CONFIG_ITEMS = [
+    ConfigItem(
+        '--version',
+        action = 'version',
+        version = f'ukify {__version__}',
+    ),
+
+    ConfigItem(
+        '--summary',
+        help = 'print parsed config and exit',
+        action = 'store_true',
+    ),
+
+    ConfigItem(
+        'linux',
+        metavar = 'LINUX',
+        type = pathlib.Path,
+        nargs = '?',
+        help = 'vmlinuz file [.linux section]',
+        config_key = 'UKI/Linux',
+    ),
+
+    ConfigItem(
+        'initrd',
+        metavar = 'INITRD…',
+        type = pathlib.Path,
+        nargs = '*',
+        help = 'initrd files [.initrd section]',
+        config_key = 'UKI/Initrd',
+        config_push = ConfigItem.config_list_prepend,
+    ),
+
+    ConfigItem(
+        ('--config', '-c'),
+        metavar = 'PATH',
+        help = 'configuration file',
+    ),
+
+    ConfigItem(
+        '--cmdline',
+        metavar = 'TEXT|@PATH',
+        help = 'kernel command line [.cmdline section]',
+        config_key = 'UKI/Cmdline',
+    ),
+
+    ConfigItem(
+        '--os-release',
+        metavar = 'TEXT|@PATH',
+        help = 'path to os-release file [.osrel section]',
+        config_key = 'UKI/OSRelease',
+    ),
+
+    ConfigItem(
+        '--devicetree',
+        metavar = 'PATH',
+        type = pathlib.Path,
+        help = 'Device Tree file [.dtb section]',
+        config_key = 'UKI/DeviceTree',
+    ),
+    ConfigItem(
+        '--splash',
+        metavar = 'BMP',
+        type = pathlib.Path,
+        help = 'splash image bitmap file [.splash section]',
+        config_key = 'UKI/Splash',
+    ),
+    ConfigItem(
+        '--pcrpkey',
+        metavar = 'KEY',
+        type = pathlib.Path,
+        help = 'embedded public key to seal secrets to [.pcrpkey section]',
+        config_key = 'UKI/PCRPKey',
+    ),
+    ConfigItem(
+        '--uname',
+        metavar='VERSION',
+        help='"uname -r" information [.uname section]',
+        config_key = 'UKI/Uname',
+    ),
+
+    ConfigItem(
+        '--efi-arch',
+        metavar = 'ARCH',
+        choices = ('ia32', 'x64', 'arm', 'aa64', 'riscv64'),
+        help = 'target EFI architecture',
+        config_key = 'UKI/EFIArch',
+    ),
+
+    ConfigItem(
+        '--stub',
+        type = pathlib.Path,
+        help = 'path to the sd-stub file [.text,.data,… sections]',
+        config_key = 'UKI/Stub',
+    ),
+
+    ConfigItem(
+        '--section',
+        dest = 'sections',
+        metavar = 'NAME:TEXT|@PATH',
+        type = Section.parse_arg,
+        action = 'append',
+        default = [],
+        help = 'additional section as name and contents [NAME section]',
+    ),
+
+    ConfigItem(
+        '--pcr-banks',
+        metavar = 'BANK…',
+        type = parse_banks,
+        config_key = 'UKI/PCRBanks',
+    ),
+
+    ConfigItem(
+        '--signing-engine',
+        metavar = 'ENGINE',
+        help = 'OpenSSL engine to use for signing',
+        config_key = 'UKI/SigningEngine',
+    ),
+    ConfigItem(
+        '--secureboot-private-key',
+        dest = 'sb_key',
+        help = 'path to key file or engine-specific designation for SB signing',
+        config_key = 'UKI/SecureBootPrivateKey',
+    ),
+    ConfigItem(
+        '--secureboot-certificate',
+        dest = 'sb_cert',
+        help = 'path to certificate file or engine-specific designation for SB signing',
+        config_key = 'UKI/SecureBootCertificate',
+    ),
+
+    ConfigItem(
+        '--sign-kernel',
+        action = argparse.BooleanOptionalAction,
+        help = 'Sign the embedded kernel',
+        config_key = 'UKI/SignKernel',
+    ),
+
+    ConfigItem(
+        '--pcr-private-key',
+        dest = 'pcr_private_keys',
+        metavar = 'PATH',
+        type = pathlib.Path,
+        action = 'append',
+        help = 'private part of the keypair for signing PCR signatures',
+        config_key = 'PCRSignature:/PCRPrivateKey',
+        config_push = ConfigItem.config_set_group,
+    ),
+    ConfigItem(
+        '--pcr-public-key',
+        dest = 'pcr_public_keys',
+        metavar = 'PATH',
+        type = pathlib.Path,
+        action = 'append',
+        help = 'public part of the keypair for signing PCR signatures',
+        config_key = 'PCRSignature:/PCRPublicKey',
+        config_push = ConfigItem.config_set_group,
+    ),
+    ConfigItem(
+        '--phases',
+        dest = 'phase_path_groups',
+        metavar = 'PHASE-PATH…',
+        type = parse_phase_paths,
+        action = 'append',
+        help = 'phase-paths to create signatures for',
+        config_key = 'PCRSignature:/Phases',
+        config_push = ConfigItem.config_set_group,
+    ),
+
+    ConfigItem(
+        '--tools',
+        type = pathlib.Path,
+        action = 'append',
+        help = 'Directories to search for tools (systemd-measure, …)',
+    ),
+
+    ConfigItem(
+        ('--output', '-o'),
+        type = pathlib.Path,
+        help = 'output file path',
+    ),
+
+    ConfigItem(
+        '--measure',
+        action = argparse.BooleanOptionalAction,
+        help = 'print systemd-measure output for the UKI',
+    ),
+]
+
+CONFIGFILE_ITEMS = { item.config_key:item
+                     for item in CONFIG_ITEMS
+                     if item.config_key }
+
+
+def apply_config(namespace, filename=None):
+    if filename is None:
+        filename = namespace.config
+    if filename is None:
+        return
+
+    # 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))
+
+    cp = configparser.ConfigParser(
+        comment_prefixes='#',
+        inline_comment_prefixes='#',
+        delimiters='=',
+        empty_lines_in_values=False,
+        interpolation=None,
+        strict=False)
+    # Do not make keys lowercase
+    cp.optionxform = lambda option: option
+
+    cp.read(filename)
+
+    for section_name, section in cp.items():
+        idx = section_name.find(':')
+        if idx >= 0:
+            section_name, group = section_name[:idx+1], section_name[idx+1:]
+            if not section_name or not group:
+                raise ValueError('Section name components cannot be empty')
+            if ':' in group:
+                raise ValueError('Section name cannot contain more than one ":"')
+        else:
+            group = None
+        for key, value in section.items():
+            if item := CONFIGFILE_ITEMS.get(f'{section_name}/{key}'):
+                item.apply_config(namespace, section_name, group, key, value)
+            else:
+                print(f'Unknown config setting [{section_name}] {key}=')
+
+
+def config_example():
+    prev_section = None
+    for item in CONFIG_ITEMS:
+        section, key, value = item.config_example()
+        if section:
+            if prev_section != section:
+                if prev_section:
+                    yield ''
+                yield f'[{section}]'
+                prev_section = section
+            yield f'{key} = {value}'
+
+
+def create_parser():
     p = argparse.ArgumentParser(
         description='Build and sign Unified Kernel Images',
         allow_abbrev=False,
         usage='''\
 ukify [options…] [LINUX INITRD…]
-       ukify -h | --help
-''')
+''',
+        epilog='\n  '.join(('config file:', *config_example())),
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+    )
+
+    for item in CONFIG_ITEMS:
+        item.add_to(p)
 
     # Suppress printing of usage synopsis on errors
     p.error = lambda message: p.exit(2, f'{p.prog}: error: {message}\n')
 
-    p.add_argument('linux',
-                   metavar='LINUX',
-                   type=pathlib.Path,
-                   nargs="?",
-                   help='vmlinuz file [.linux section]')
-    p.add_argument('initrd',
-                   metavar='INITRD…',
-                   type=pathlib.Path,
-                   nargs='*',
-                   help='initrd files [.initrd section]')
-
-    p.add_argument('--cmdline',
-                   metavar='TEXT|@PATH',
-                   help='kernel command line [.cmdline section]')
-
-    p.add_argument('--os-release',
-                   metavar='TEXT|@PATH',
-                   help='path to os-release file [.osrel section]')
-
-    p.add_argument('--devicetree',
-                   metavar='PATH',
-                   type=pathlib.Path,
-                   help='Device Tree file [.dtb section]')
-    p.add_argument('--splash',
-                   metavar='BMP',
-                   type=pathlib.Path,
-                   help='splash image bitmap file [.splash section]')
-    p.add_argument('--pcrpkey',
-                   metavar='KEY',
-                   type=pathlib.Path,
-                   help='embedded public key to seal secrets to [.pcrpkey section]')
-    p.add_argument('--uname',
-                   metavar='VERSION',
-                   help='"uname -r" information [.uname section]')
-
-    p.add_argument('--efi-arch',
-                   metavar='ARCH',
-                   choices=('ia32', 'x64', 'arm', 'aa64', 'riscv64'),
-                   help='target EFI architecture')
-
-    p.add_argument('--stub',
-                   type=pathlib.Path,
-                   help='path to the sd-stub file [.text,.data,… sections]')
-
-    p.add_argument('--section',
-                   dest='sections',
-                   metavar='NAME:TEXT|@PATH',
-                   type=Section.parse_arg,
-                   action='append',
-                   default=[],
-                   help='additional section as name and contents [NAME section]')
-
-    p.add_argument('--pcr-private-key',
-                   dest='pcr_private_keys',
-                   metavar='PATH',
-                   type=pathlib.Path,
-                   action='append',
-                   help='private part of the keypair for signing PCR signatures')
-    p.add_argument('--pcr-public-key',
-                   dest='pcr_public_keys',
-                   metavar='PATH',
-                   type=pathlib.Path,
-                   action='append',
-                   help='public part of the keypair for signing PCR signatures')
-    p.add_argument('--phases',
-                   dest='phase_path_groups',
-                   metavar='PHASE-PATH…',
-                   type=parse_phase_paths,
-                   action='append',
-                   help='phase-paths to create signatures for')
-
-    p.add_argument('--pcr-banks',
-                   metavar='BANK…',
-                   type=parse_banks)
-
-    p.add_argument('--signing-engine',
-                   metavar='ENGINE',
-                   help='OpenSSL engine to use for signing')
-    p.add_argument('--secureboot-private-key',
-                   dest='sb_key',
-                   help='path to key file or engine-specific designation for SB signing')
-    p.add_argument('--secureboot-certificate',
-                   dest='sb_cert',
-                   help='path to certificate file or engine-specific designation for SB signing')
-
-    p.add_argument('--sign-kernel',
-                   action=argparse.BooleanOptionalAction,
-                   help='Sign the embedded kernel')
-
-    p.add_argument('--tools',
-                   type=pathlib.Path,
-                   action='append',
-                   help='Directories to search for tools (systemd-measure, ...)')
-
-    p.add_argument('--output', '-o',
-                   type=pathlib.Path,
-                   help='output file path')
-
-    p.add_argument('--measure',
-                   action=argparse.BooleanOptionalAction,
-                   help='print systemd-measure output for the UKI')
-
-    p.add_argument('--version',
-                   action='version',
-                   version=f'ukify {__version__}')
-
-    opts = p.parse_args(args)
+    return p
 
-    if opts.linux is not None:
-        path_is_readable(opts.linux)
-    for initrd in opts.initrd or ():
-        path_is_readable(initrd)
-    path_is_readable(opts.devicetree)
-    path_is_readable(opts.pcrpkey)
-    for key in opts.pcr_private_keys or ():
-        path_is_readable(key)
-    for key in opts.pcr_public_keys or ():
-        path_is_readable(key)
 
+def finalize_options(opts):
     if opts.cmdline and opts.cmdline.startswith('@'):
-        opts.cmdline = path_is_readable(opts.cmdline[1:])
-
-    if opts.os_release is not None and opts.os_release.startswith('@'):
-        opts.os_release = path_is_readable(opts.os_release[1:])
-    elif opts.os_release is None and opts.linux is not None:
+        opts.cmdline = pathlib.Path(opts.cmdline[1:])
+    elif opts.cmdline:
+        # Drop whitespace from the commandline. If we're reading from a file,
+        # we copy the contents verbatim. But configuration specified on the commandline
+        # or in the config file may contain additional whitespace that has no meaning.
+        opts.cmdline = ' '.join(opts.cmdline.split())
+
+    if opts.os_release and opts.os_release.startswith('@'):
+        opts.os_release = pathlib.Path(opts.os_release[1:])
+    elif not opts.os_release and opts.linux:
         p = pathlib.Path('/etc/os-release')
         if not p.exists():
-            p = path_is_readable('/usr/lib/os-release')
+            p = pathlib.Path('/usr/lib/os-release')
         opts.os_release = p
 
     if opts.efi_arch is None:
         opts.efi_arch = guess_efi_arch()
 
     if opts.stub is None:
-        opts.stub = path_is_readable(f'/usr/lib/systemd/boot/efi/linux{opts.efi_arch}.efi.stub')
+        opts.stub = pathlib.Path(f'/usr/lib/systemd/boot/efi/linux{opts.efi_arch}.efi.stub')
 
     if opts.signing_engine is None:
-        opts.sb_key = path_is_readable(opts.sb_key) if opts.sb_key else None
-        opts.sb_cert = path_is_readable(opts.sb_cert) if opts.sb_cert else None
+        if opts.sb_key:
+            opts.sb_key = pathlib.Path(opts.sb_key)
+        if opts.sb_cert:
+            opts.sb_cert = pathlib.Path(opts.sb_cert)
 
     if bool(opts.sb_key) ^ bool(opts.sb_cert):
         raise ValueError('--secureboot-private-key= and --secureboot-certificate= must be specified together')
@@ -826,6 +1074,27 @@ ukify [options…] [LINUX INITRD…]
     if opts.sign_kernel and not opts.sb_key:
         raise ValueError('--sign-kernel requires --secureboot-private-key= and --secureboot-certificate= to be specified')
 
+    if opts.output is None:
+        if opts.linux is None:
+            raise ValueError('--output= must be specified when building a PE addon')
+        suffix = '.efi' if opts.sb_key else '.unsigned.efi'
+        opts.output = opts.linux.name + suffix
+
+    for section in opts.sections:
+        section.check_name()
+
+    if opts.summary:
+        # TODO: replace pprint() with some fancy formatting.
+        pprint.pprint(vars(opts))
+        sys.exit()
+
+
+def parse_args(args=None):
+    p = create_parser()
+    opts = p.parse_args(args)
+
+    # 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)
     n_pcr_priv = None if opts.pcr_private_keys is None else len(opts.pcr_private_keys)
     n_phase_path_groups = None if opts.phase_path_groups is None else len(opts.phase_path_groups)
@@ -834,14 +1103,9 @@ ukify [options…] [LINUX INITRD…]
     if n_phase_path_groups is not None and n_phase_path_groups != n_pcr_priv:
         raise ValueError('--phases= specifications must match --pcr-private-key=')
 
-    if opts.output is None:
-        if opts.linux is None:
-            raise ValueError('--output= must be specified when building a PE addon')
-        suffix = '.efi' if opts.sb_key else '.unsigned.efi'
-        opts.output = opts.linux.name + suffix
+    apply_config(opts)
 
-    for section in opts.sections:
-        section.check_name()
+    finalize_options(opts)
 
     return opts