diff --git a/.flake8 b/.flake8 index 5cd19e5..daa88e3 100644 --- a/.flake8 +++ b/.flake8 @@ -8,6 +8,7 @@ max-line-length = 88 # Include scripts to check in addition to the default *.py. filename = *.py, + ./eos-esp-generator, ./eos-migrate-chromium-profile, ./eos-migrate-firefox-profile, ./eos-update-flatpak-repos, diff --git a/Makefile.am b/Makefile.am index 0deb9b3..57b9a3f 100644 --- a/Makefile.am +++ b/Makefile.am @@ -39,6 +39,7 @@ dist_systemduserunit_DATA = \ $(NULL) dist_systemdgenerator_SCRIPTS = \ + eos-esp-generator \ eos-live-boot-generator \ eos-vm-generator \ $(NULL) diff --git a/eos-esp-generator b/eos-esp-generator new file mode 100755 index 0000000..c518f9d --- /dev/null +++ b/eos-esp-generator @@ -0,0 +1,601 @@ +#!/usr/bin/env python3 + +# Copyright © 2023 Endless OS Foundation, LLC +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or (at +# your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +# 02110-1301, USA. + +"""\ +EOS EFI System Partition (ESP) mount generator + +This program is responsible for mounting the EFI System Partition (ESP) +on EOS systems. It's heavily inspired by systemd-gpt-auto-generator with +a few policy changes that are not easily expressed there: + +* Both GPT and MBR partition tables are supported. + +* The EFI LoaderDevicePartUUID variable is validated but not required. + This allows for usage with bootloaders such as GRUB that do not + implement the boot loader interface. + +* Mounting at /boot is allowed even when the /efi directory exists. This + allows use of a generic OS commit that can be used on systems where + /boot data exists in the ESP or not. + +* When the ESP is mounted at /boot, it is made world readable to allow + unprivileged ostree admin operations to succeed. + +The units created with this generator take precedence over +systemd-gpt-auto-generator. +""" + +from argparse import ArgumentParser, RawDescriptionHelpFormatter +from dataclasses import dataclass +import json +import logging +import os +from pathlib import Path +import shlex +import subprocess +import sys +from systemd.journal import JournalHandler +from textwrap import dedent + +progname = os.path.basename(__file__) +logger = logging.getLogger(progname) + +ESP_GPT_PARTTYPE = 'c12a7328-f81f-11d2-ba4b-00a0c93ec93b' +ESP_MBR_PARTTYPE = 'ef' +XBOOTLDR_GPT_PARTTYPE = 'bc13c2ff-59e6-4262-a352-b275fd6f7172' +LOADER_EFI_VENDOR = '4a67b082-0a4c-41cf-b6c7-440b29bb8c4f' +LOADER_DEVICE_PART_UUID_NAME = 'LoaderDevicePartUUID' +LOADER_DEVICE_PART_UUID_EFIVAR = f'{LOADER_DEVICE_PART_UUID_NAME}-{LOADER_EFI_VENDOR}' + + +class EspError(Exception): + """ESP generator errors""" + pass + + +def run(cmd, *args, **kwargs): + """Run a command with logging""" + logger.debug(f'> {shlex.join(cmd)}') + return subprocess.run(cmd, *args, **kwargs) + + +def get_mount_data(root=Path('/')): + """Gather mounted filesystems + + Returns a list of mounted filesystem dicts. + """ + # If /boot or /efi are automounts, trigger the real mounts on + # top of them. An access test is enough to trigger the mounts. + # The directories should be kept open until mountinfo parsing + # completes, but this should be good enough. + root.joinpath('boot').exists() + root.joinpath('efi').exists() + + cmd = ( + 'findmnt', + '--real', + '--json', + '--list', + '--canonicalize', + '--evaluate', + '--nofsroot', + '--output', + 'TARGET,SOURCE,MAJ:MIN,FSTYPE,FSROOT,OPTIONS', + ) + + opts = { + 'check': True, + 'stdout': subprocess.PIPE, + 'text': True, + 'encoding': 'utf-8', + } + + proc = run(cmd, **opts) + data = json.loads(proc.stdout).get('filesystems', {}) + logger.debug(f'mounts: {data}') + return data + + +def get_fstab_data(): + """Gather filesystem mount configuration + + Returns a list of fstab entry dicts. + """ + cmd = ( + 'findmnt', + '--fstab', + '--json', + '--list', + '--canonicalize', + '--evaluate', + '--nofsroot', + '--output', + 'TARGET,SOURCE,MAJ:MIN,FSTYPE,FSROOT,OPTIONS', + ) + + opts = { + 'check': True, + 'stdout': subprocess.PIPE, + 'text': True, + 'encoding': 'utf-8', + } + + proc = run(cmd, **opts) + data = json.loads(proc.stdout).get('filesystems', {}) + logger.debug(f'fstab: {data}') + return data + + +def _disk_to_partitions(disk): + """Convert sfdisk JSON to partition entries""" + table = disk['partitiontable'] + for part in table['partitions']: + # Lower case the partition type and UUIDs (if present) to + # normalize later comparisons. + part['type'] = part['type'].lower() + if 'uuid' in part: + part['uuid'] = part['uuid'].lower() + part.update({ + 'disk_node': table['device'], + 'disk_label': table['label'], + 'disk_id': table['id'].lower(), + }) + yield part + + +def get_partition_data(): + """Gather disk partition data + + Returns a list of partition dicts. + """ + partitions = [] + + # Find the block devices that have partitions. + sys_block = Path('/sys/block') + for block in sys_block.iterdir(): + # If none of the sysfs device subdirs have partition entries, + # then the block device doesn't have partitions. + if not next(block.glob('*/partition'), None): + continue + + disk_dev = f'/dev/{block.name}' + cmd = ( + 'sfdisk', + '--json', + disk_dev, + ) + + opts = { + 'check': True, + 'stdout': subprocess.PIPE, + 'text': True, + 'encoding': 'utf-8', + } + + proc = run(cmd, **opts) + disk_data = json.loads(proc.stdout) + partitions += _disk_to_partitions(disk_data) + + logger.debug(f'partitions: {partitions}') + return partitions + + +def read_efivar(name, root=Path('/')): + """Read an EFI variable value + + Reads the data from efivarfs mounted at /sys/firmware/efi/efivars. + Only the variable value is returned, not the attributes. + """ + varpath = root / 'sys/firmware/efi/efivars' / name + logger.debug(f'Reading EFI variable {varpath}') + try: + with open(varpath, 'rb') as f: + value = f.read() + except FileNotFoundError: + logger.debug(f'EFI variable {name} not set') + return None + + # Skip the first 4 bytes, those are the 32 bit attribute mask. + if len(value) < 4: + logger.warning(f'Invalid EFI variable {name} is less than 4 bytes') + return None + return value[4:] + + +def read_efivar_utf16_string(name, root=Path('/')): + """Read an EFI variable UTF-16 string + + If the EFI variable doesn't exist, None is returned. Any nul + terminating bytes will be removed. + """ + value = read_efivar(name, root) + if value is None: + return None + + logger.debug(f'EFI variable {name} contents: {value}') + + # Systemd appends 3 nul bytes for some reason. If there are an odd + # number of bytes, ignore the last one so there are an appropriate + # number of utf16 bytes. + end = len(value) + if end % 2 == 1: + end -= 1 + + # Ignore any trailing nul byte pairs. + while end > 0: + if value[end - 2:end] != b'\0\0': + break + end -= 2 + + return value[:end].decode('utf-16', errors='replace') + + +def systemd_escape_path(path): + """Escape a path for usage in systemd unit names""" + proc = run( + ('systemd-escape', '--path', str(path)), + check=True, + stdout=subprocess.PIPE, + text=True, + encoding='utf-8', + ) + return proc.stdout.strip() + + +@dataclass +class EspMount: + """ESP mount specification + + Describes the parameters for mounting the ESP. The write_units() + method can be used to create the systemd units from a generator. + """ + source: str + target: str + type: str = 'vfat' + umask: str = '0077' + + def write_units(self, unit_dir): + source_escaped = systemd_escape_path(self.source) + target_escaped = systemd_escape_path(self.target) + automount_unit = unit_dir / f'{target_escaped}.automount' + mount_unit = unit_dir / f'{target_escaped}.mount' + local_fs_wants = unit_dir / 'local-fs.target.wants' / automount_unit.name + + logger.info(f'Writing unit {automount_unit}') + automount_unit.write_text(dedent(f"""\ + # Automatically generated by {progname} + + [Unit] + Description=EFI System Partition Automount + + [Automount] + Where={self.target} + TimeoutIdleSec=2min + """)) + + logger.info(f'Writing unit {mount_unit}') + mount_unit.write_text(dedent(f"""\ + # Automatically generated by {progname} + + [Unit] + Description=EFI System Partition Automount + Requires=systemd-fsck@{source_escaped}.service + After=systemd-fsck@{source_escaped}.service + After=blockdev@{source_escaped}.target + + [Mount] + What={self.source} + Where={self.target} + Type={self.type} + Options=umask={self.umask},noauto,rw + """)) + + # Create a symlink for the automount unit in local-fs.target.wants. + logger.info(f'Creating {local_fs_wants} symlink') + local_fs_wants.parent.mkdir(parents=True, exist_ok=True) + link = os.path.relpath(automount_unit, local_fs_wants.parent) + os.symlink(link, local_fs_wants) + + +class EspGenerator: + """Generator for mounting the ESP + + Locates the ESP device and determines the path to mount it at. Call + get_esp_mount() to retrieve an EspMount instance describing the + configuration. + + Note that the root parameter is used inconsistently. It is only + intended for testing and should not be used in normal operation. + """ + def __init__(self, root=Path('/')): + self.root = root + self.mounts = get_mount_data(self.root) + self.fstab = get_fstab_data() + self.partitions = get_partition_data() + + def get_esp_mount(self): + """Get the ESP mount specification + + Returns an EspMount or None. + """ + # Don't mount units in the initrd. + if os.getenv('SYSTEMD_IN_INITRD', '0') == '1': + logger.info('Skipping ESP mounting in initrd') + return None + + esp_dev = self._get_esp_dev() + if not esp_dev: + logger.info('No ESP device found, skipping mounting') + return None + logger.info(f'ESP device: {esp_dev}') + + esp_path = self._get_esp_path(esp_dev) + if not esp_path: + logger.info('No ESP mount path determined, skipping mounting') + return None + logger.info(f'ESP mount path: {esp_path}') + + # If the desired path is in fstab, don't do anything. + fstab_entry = self._get_fstab(target=esp_path) + if fstab_entry: + logger.info( + f'{fstab_entry["source"]} configured for mounting at {esp_path}, ' + 'skipping mounting' + ) + return None + + # If the ESP is mounted at /boot, it needs to be world readable + # since it's also accessed by ostree and some operations are + # expected to work unprivileged. + umask = '0022' if esp_path == '/boot' else '0077' + + mount = EspMount(source=esp_dev, target=esp_path, umask=umask) + logger.info(f'Created ESP mount instance {mount}') + return mount + + def _get_esp_dev(self): + # FIXME: This only considers the partitions on the root + # filesystem disk, but that's wrong in the dual boot case. + esp_disk_part = self._get_root_partition() + esp_disk_pttype = esp_disk_part['disk_label'] + esp_disk_parts = self._get_disk_partitions(esp_disk_part) + + esp_dev_part = {} + if esp_disk_pttype == 'gpt': + esp_dev_part = self._get_partition( + esp_disk_parts, + type=ESP_GPT_PARTTYPE, + ) + if not esp_dev_part: + logger.debug( + f'No GPT partition with ESP type {ESP_GPT_PARTTYPE} found ' + f'in {esp_disk_parts}' + ) + return None + + # Validate LoaderDevicePartUUID if it's set. + loader_part_uuid = read_efivar_utf16_string( + LOADER_DEVICE_PART_UUID_EFIVAR, + self.root, + ) + if loader_part_uuid is not None: + loader_part_uuid = loader_part_uuid.lower() + logger.debug( + f'Found EFI var {LOADER_DEVICE_PART_UUID_NAME} is ' + f'{loader_part_uuid}' + ) + esp_dev_uuid = esp_dev_part['uuid'] + if loader_part_uuid != esp_dev_uuid: + logger.info( + f'EFI variable {LOADER_DEVICE_PART_UUID_NAME} ' + f'{loader_part_uuid} does not match located ESP UUID ' + f'{esp_dev_part["uuid"]}' + ) + return None + else: + logger.debug(f'EFI var {LOADER_DEVICE_PART_UUID_NAME} not set') + elif esp_disk_pttype == 'dos': + esp_dev_part = self._get_partition( + esp_disk_parts, + type=ESP_MBR_PARTTYPE, + ) + if not esp_dev_part: + logger.debug( + f'No MBR partition with ESP type {ESP_MBR_PARTTYPE} found ' + f'in {esp_disk_parts}' + ) + else: + logger.warning( + f'Unexpected partition type "{esp_disk_pttype}" for ' + f'{esp_disk_part["disk_node"]}' + ) + + return esp_dev_part.get('node') + + def _get_esp_path(self, esp_dev): + has_xbootldr = self._root_has_xbootldr() + has_efi_path = self.root.joinpath('efi').exists() + + def _use_efi_if_exists(): + if not has_efi_path: + logger.info('Would use /efi for mount path, but it does not exist') + return None + return '/efi' + + if has_xbootldr: + logger.debug('ESP device has XBOOTLDR partition, using /efi as mount path') + return _use_efi_if_exists() + + boot_mount = self._get_mount(target='/boot') + if boot_mount: + if boot_mount['source'] == esp_dev: + logger.debug( + f'Using /boot for mount path since {esp_dev} already mounted there' + ) + return '/boot' + else: + logger.debug( + f'{boot_mount["source"]} is mounted at /boot, ' + 'using /efi as mount path' + ) + return _use_efi_if_exists() + else: + boot_fstab = self._get_fstab(target='/boot') + if boot_fstab and boot_fstab['source'] != esp_dev: + logger.debug( + f'/boot is in fstab using {boot_fstab["source"]}, ' + f'using /efi as mount path' + ) + return _use_efi_if_exists() + return '/boot' + + return None + + def _get_root_partition(self): + root_mount = self._get_mount(target='/') + if not root_mount: + raise EspError(f'Could not find / mount in {self.mounts}') + root_dev = root_mount['source'] + root_part = self._get_partition(node=root_dev) + if not root_part: + raise EspError(f'Could not find / device {root_dev} in {self.partitions}') + logger.debug(f'Determined root partition {root_part}') + return root_part + + def _get_disk_partitions(self, partition): + return self._filter_data(self.partitions, disk_node=partition['disk_node']) + + def _root_has_xbootldr(self): + root_part = self._get_root_partition() + root_disk_parts = self._get_disk_partitions(root_part) + xbootldr_part = self._get_partition( + root_disk_parts, + disk_label='gpt', + type=XBOOTLDR_GPT_PARTTYPE, + ) + return bool(xbootldr_part) + + @staticmethod + def _filter_data(data, **kwargs): + if not kwargs: + raise ValueError('Must provide filter values') + + def _test(entry): + for name, value in kwargs.items(): + if entry[name] != value: + return False + return True + + return filter(_test, data) + + def _get_mount(self, data=None, **kwargs): + if data is None: + data = self.mounts + return next(self._filter_data(data, **kwargs), {}) + + def _get_fstab(self, data=None, **kwargs): + if data is None: + data = self.fstab + return next(self._filter_data(data, **kwargs), {}) + + def _get_partition(self, data=None, **kwargs): + if data is None: + data = self.partitions + return next(self._filter_data(data, **kwargs), {}) + + +def main(): + """ESP generator main entry point""" + doclines = __doc__.splitlines() + ap = ArgumentParser( + description=doclines[0], + epilog='\n'.join(doclines[2:]), + formatter_class=RawDescriptionHelpFormatter, + ) + ap.add_argument( + 'normal_dir', + metavar='NORMAL', + help='Normal generator output directory', + ) + ap.add_argument( + 'early_dir', + metavar='EARLY', + help='Early generator output directory', + ) + ap.add_argument( + 'late_dir', + metavar='LATE', + help='Late generator output directory', + ) + ap.add_argument( + '-n', + '--dry-run', + action='store_true', + help='only show what would be done', + ) + ap.set_defaults(log_level=logging.INFO) + log_level_group = ap.add_mutually_exclusive_group() + log_level_group.add_argument( + '--quiet', + dest='log_level', + action='store_const', + const=logging.WARNING, + help='only log warning messages', + ) + log_level_group.add_argument( + '--debug', + dest='log_level', + action='store_const', + const=logging.DEBUG, + help='log debug messages', + ) + args = ap.parse_args() + + # Setup logging. If we're run by systemd (SYSTEMD_SCOPE is set when + # generators run), only use a journal handler. + journal_handler = JournalHandler(SYSLOG_IDENTIFIER=progname) + journal_handler.setFormatter(logging.Formatter(fmt='%(name)s:%(message)s')) + handlers = [journal_handler] + if 'SYSTEMD_SCOPE' in os.environ: + # Tweak the logger name since the program name is superfluous in + # the journal. + logger.name = 'main' + + # For now just use debug level in systemd. Later we can try to + # use the systemd log level for the program log level. + args.log_level = logging.DEBUG + else: + # Add a stderr logger like would normally be done by + # basicConfig. + handlers.append(logging.StreamHandler(sys.stderr)) + logging.basicConfig(level=args.log_level, handlers=handlers) + + generator = EspGenerator() + esp_mount = generator.get_esp_mount() + if esp_mount and not args.dry_run: + # The normal generator directory is used so that we take + # precedence over systemd-gpt-auto-generator. However, this is + # the same level as systemd-fstab-generator, so we need to take + # care to not override fstab entries. + unit_dir = Path(args.normal_dir) + esp_mount.write_units(unit_dir) + + +if __name__ == '__main__': + main() diff --git a/tests/Makefile.am b/tests/Makefile.am index 7aef022..f7fcd3c 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -6,7 +6,27 @@ EXTRA_DIST = \ __init__.py \ check-syntax \ conftest.py \ + data/espgen-efi-fstab-amd64-fstab.json \ + data/espgen-efi-fstab-amd64-mounts-reload.json \ + data/espgen-gpt-amd64-disk-root.json \ + data/espgen-gpt-amd64-fstab.json \ + data/espgen-gpt-amd64-mounts-init.json \ + data/espgen-gpt-amd64-mounts-reload.json \ + data/espgen-mbr-amd64-disk-root.json \ + data/espgen-mbr-amd64-fstab.json \ + data/espgen-mbr-amd64-mounts-init.json \ + data/espgen-mbr-amd64-mounts-reload.json \ + data/espgen-payg-amd64-disk-root.json \ + data/espgen-payg-amd64-fstab.json \ + data/espgen-payg-amd64-mounts-init.json \ + data/espgen-payg-amd64-mounts-reload.json \ + data/espgen-payg-xbootldr-amd64-disk-root.json \ + data/espgen-payg-xbootldr-amd64-fstab.json \ + data/espgen-payg-xbootldr-amd64-mounts-init.json \ + data/espgen-payg-xbootldr-amd64-mounts-reload.json \ + espgen-test-data.sh \ run-tests \ + test_esp_generator.py \ test_image_boot.py \ test_live_boot_generator.py \ test_live_storage.py \ diff --git a/tests/data/espgen-efi-fstab-amd64-fstab.json b/tests/data/espgen-efi-fstab-amd64-fstab.json new file mode 100644 index 0000000..0c02914 --- /dev/null +++ b/tests/data/espgen-efi-fstab-amd64-fstab.json @@ -0,0 +1,6 @@ +{ + "filesystems": [ + {"target":"/", "source":"/dev/vda3", "fstype":"ext4", "options":"errors=remount-ro"}, + {"target":"/efi", "source":"/dev/vda1", "fstype":"vfat", "options":"umask=0077"} + ] +} diff --git a/tests/data/espgen-efi-fstab-amd64-mounts-reload.json b/tests/data/espgen-efi-fstab-amd64-mounts-reload.json new file mode 100644 index 0000000..10a362c --- /dev/null +++ b/tests/data/espgen-efi-fstab-amd64-mounts-reload.json @@ -0,0 +1,10 @@ +{ + "filesystems": [ + {"target":"/sysroot", "source":"/dev/vda3", "maj:min":"252:3", "fstype":"ext4", "fsroot":"/", "options":"rw,relatime,errors=remount-ro"}, + {"target":"/", "source":"/dev/vda3", "maj:min":"252:3", "fstype":"ext4", "fsroot":"/ostree/deploy/eos/deploy/aa2329cfadd1063eff87d788d10fa55c7efe2bbfa3f4b415919cce7f69dc0e97.0", "options":"rw,relatime,errors=remount-ro"}, + {"target":"/boot", "source":"/dev/vda3", "maj:min":"252:3", "fstype":"ext4", "fsroot":"/boot", "options":"rw,relatime,errors=remount-ro"}, + {"target":"/usr", "source":"/dev/vda3", "maj:min":"252:3", "fstype":"ext4", "fsroot":"/ostree/deploy/eos/deploy/aa2329cfadd1063eff87d788d10fa55c7efe2bbfa3f4b415919cce7f69dc0e97.0/usr", "options":"ro,relatime,errors=remount-ro"}, + {"target":"/var", "source":"/dev/vda3", "maj:min":"252:3", "fstype":"ext4", "fsroot":"/ostree/deploy/eos/var", "options":"rw,relatime,errors=remount-ro"}, + {"target":"/efi", "source":"/dev/vda1", "maj:min":"252:1", "fstype":"vfat", "fsroot":"/", "options":"rw,relatime,fmask=0077,dmask=0077,codepage=437,iocharset=iso8859-1,shortname=mixed,errors=remount-ro"} + ] +} diff --git a/tests/data/espgen-gpt-amd64-disk-root.json b/tests/data/espgen-gpt-amd64-disk-root.json new file mode 100644 index 0000000..eadb984 --- /dev/null +++ b/tests/data/espgen-gpt-amd64-disk-root.json @@ -0,0 +1,16 @@ +{ + "partitiontable": { + "label":"gpt", + "id":"265411DE-4D52-7A4E-AC09-20C0807725A0", + "device":"/dev/vda", + "unit":"sectors", + "firstlba":2048, + "lastlba":62914526, + "sectorsize":512, + "partitions": [ + {"node":"/dev/vda1", "start":2048, "size":126976, "type":"C12A7328-F81F-11D2-BA4B-00A0C93EC93B", "uuid":"F24CBC5A-C030-7E4F-8F7F-6B735FE9B0DE"}, + {"node":"/dev/vda2", "start":129024, "size":2048, "type":"21686148-6449-6E6F-744E-656564454649", "uuid":"896BE2AC-8255-B145-8282-C94675860B6C"}, + {"node":"/dev/vda3", "start":131072, "size":62783455, "type":"4F68BCE3-E8CD-4DB1-96E7-FBCAF984B709", "uuid":"B9C23C3C-32C2-4344-95B8-B90A819D1CA6"} + ] + } +} diff --git a/tests/data/espgen-gpt-amd64-fstab.json b/tests/data/espgen-gpt-amd64-fstab.json new file mode 100644 index 0000000..3e6aac6 --- /dev/null +++ b/tests/data/espgen-gpt-amd64-fstab.json @@ -0,0 +1,5 @@ +{ + "filesystems": [ + {"target":"/", "source":"/dev/vda3", "fstype":"ext4", "options":"errors=remount-ro"} + ] +} diff --git a/tests/data/espgen-gpt-amd64-mounts-init.json b/tests/data/espgen-gpt-amd64-mounts-init.json new file mode 100644 index 0000000..db2a087 --- /dev/null +++ b/tests/data/espgen-gpt-amd64-mounts-init.json @@ -0,0 +1,8 @@ +{ + "filesystems": [ + {"target":"/sysroot", "source":"/dev/vda3", "maj:min":"252:3", "fstype":"ext4", "fsroot":"/", "options":"rw,relatime"}, + {"target":"/", "source":"/dev/vda3", "maj:min":"252:3", "fstype":"ext4", "fsroot":"/ostree/deploy/eos/deploy/aa2329cfadd1063eff87d788d10fa55c7efe2bbfa3f4b415919cce7f69dc0e97.0", "options":"rw,relatime"}, + {"target":"/boot", "source":"/dev/vda3", "maj:min":"252:3", "fstype":"ext4", "fsroot":"/boot", "options":"rw,relatime"}, + {"target":"/usr", "source":"/dev/vda3", "maj:min":"252:3", "fstype":"ext4", "fsroot":"/ostree/deploy/eos/deploy/aa2329cfadd1063eff87d788d10fa55c7efe2bbfa3f4b415919cce7f69dc0e97.0/usr", "options":"ro,relatime"} + ] +} diff --git a/tests/data/espgen-gpt-amd64-mounts-reload.json b/tests/data/espgen-gpt-amd64-mounts-reload.json new file mode 100644 index 0000000..10a362c --- /dev/null +++ b/tests/data/espgen-gpt-amd64-mounts-reload.json @@ -0,0 +1,10 @@ +{ + "filesystems": [ + {"target":"/sysroot", "source":"/dev/vda3", "maj:min":"252:3", "fstype":"ext4", "fsroot":"/", "options":"rw,relatime,errors=remount-ro"}, + {"target":"/", "source":"/dev/vda3", "maj:min":"252:3", "fstype":"ext4", "fsroot":"/ostree/deploy/eos/deploy/aa2329cfadd1063eff87d788d10fa55c7efe2bbfa3f4b415919cce7f69dc0e97.0", "options":"rw,relatime,errors=remount-ro"}, + {"target":"/boot", "source":"/dev/vda3", "maj:min":"252:3", "fstype":"ext4", "fsroot":"/boot", "options":"rw,relatime,errors=remount-ro"}, + {"target":"/usr", "source":"/dev/vda3", "maj:min":"252:3", "fstype":"ext4", "fsroot":"/ostree/deploy/eos/deploy/aa2329cfadd1063eff87d788d10fa55c7efe2bbfa3f4b415919cce7f69dc0e97.0/usr", "options":"ro,relatime,errors=remount-ro"}, + {"target":"/var", "source":"/dev/vda3", "maj:min":"252:3", "fstype":"ext4", "fsroot":"/ostree/deploy/eos/var", "options":"rw,relatime,errors=remount-ro"}, + {"target":"/efi", "source":"/dev/vda1", "maj:min":"252:1", "fstype":"vfat", "fsroot":"/", "options":"rw,relatime,fmask=0077,dmask=0077,codepage=437,iocharset=iso8859-1,shortname=mixed,errors=remount-ro"} + ] +} diff --git a/tests/data/espgen-mbr-amd64-disk-root.json b/tests/data/espgen-mbr-amd64-disk-root.json new file mode 100644 index 0000000..a08b170 --- /dev/null +++ b/tests/data/espgen-mbr-amd64-disk-root.json @@ -0,0 +1,13 @@ +{ + "partitiontable": { + "label":"dos", + "id":"0x265411de", + "device":"/dev/vda", + "unit":"sectors", + "sectorsize":512, + "partitions": [ + {"node":"/dev/vda1", "start":2048, "size":126976, "type":"ef"}, + {"node":"/dev/vda2", "start":131072, "size":62783455, "type":"83", "bootable":true} + ] + } +} diff --git a/tests/data/espgen-mbr-amd64-fstab.json b/tests/data/espgen-mbr-amd64-fstab.json new file mode 100644 index 0000000..fe3793f --- /dev/null +++ b/tests/data/espgen-mbr-amd64-fstab.json @@ -0,0 +1,5 @@ +{ + "filesystems": [ + {"target":"/", "source":"/dev/vda2", "fstype":"ext4", "options":"errors=remount-ro"} + ] +} diff --git a/tests/data/espgen-mbr-amd64-mounts-init.json b/tests/data/espgen-mbr-amd64-mounts-init.json new file mode 100644 index 0000000..4d25414 --- /dev/null +++ b/tests/data/espgen-mbr-amd64-mounts-init.json @@ -0,0 +1,8 @@ +{ + "filesystems": [ + {"target":"/sysroot", "source":"/dev/vda2", "maj:min":"252:2", "fstype":"ext4", "fsroot":"/", "options":"rw,relatime"}, + {"target":"/", "source":"/dev/vda2", "maj:min":"252:2", "fstype":"ext4", "fsroot":"/ostree/deploy/eos/deploy/aa2329cfadd1063eff87d788d10fa55c7efe2bbfa3f4b415919cce7f69dc0e97.0", "options":"rw,relatime"}, + {"target":"/boot", "source":"/dev/vda2", "maj:min":"252:2", "fstype":"ext4", "fsroot":"/boot", "options":"rw,relatime"}, + {"target":"/usr", "source":"/dev/vda2", "maj:min":"252:2", "fstype":"ext4", "fsroot":"/ostree/deploy/eos/deploy/aa2329cfadd1063eff87d788d10fa55c7efe2bbfa3f4b415919cce7f69dc0e97.0/usr", "options":"ro,relatime"} + ] +} diff --git a/tests/data/espgen-mbr-amd64-mounts-reload.json b/tests/data/espgen-mbr-amd64-mounts-reload.json new file mode 100644 index 0000000..edd3de7 --- /dev/null +++ b/tests/data/espgen-mbr-amd64-mounts-reload.json @@ -0,0 +1,10 @@ +{ + "filesystems": [ + {"target":"/sysroot", "source":"/dev/vda2", "maj:min":"252:2", "fstype":"ext4", "fsroot":"/", "options":"rw,relatime,errors=remount-ro"}, + {"target":"/", "source":"/dev/vda2", "maj:min":"252:2", "fstype":"ext4", "fsroot":"/ostree/deploy/eos/deploy/aa2329cfadd1063eff87d788d10fa55c7efe2bbfa3f4b415919cce7f69dc0e97.0", "options":"rw,relatime,errors=remount-ro"}, + {"target":"/boot", "source":"/dev/vda2", "maj:min":"252:2", "fstype":"ext4", "fsroot":"/boot", "options":"rw,relatime,errors=remount-ro"}, + {"target":"/usr", "source":"/dev/vda2", "maj:min":"252:2", "fstype":"ext4", "fsroot":"/ostree/deploy/eos/deploy/aa2329cfadd1063eff87d788d10fa55c7efe2bbfa3f4b415919cce7f69dc0e97.0/usr", "options":"ro,relatime,errors=remount-ro"}, + {"target":"/var", "source":"/dev/vda2", "maj:min":"252:2", "fstype":"ext4", "fsroot":"/ostree/deploy/eos/var", "options":"rw,relatime,errors=remount-ro"}, + {"target":"/efi", "source":"/dev/vda1", "maj:min":"252:1", "fstype":"vfat", "fsroot":"/", "options":"rw,relatime,fmask=0077,dmask=0077,codepage=437,iocharset=iso8859-1,shortname=mixed,errors=remount-ro"} + ] +} diff --git a/tests/data/espgen-payg-amd64-disk-root.json b/tests/data/espgen-payg-amd64-disk-root.json new file mode 100644 index 0000000..d6d97d6 --- /dev/null +++ b/tests/data/espgen-payg-amd64-disk-root.json @@ -0,0 +1,16 @@ +{ + "partitiontable": { + "label":"gpt", + "id":"5B13BB34-4E2B-DB48-9DD1-E84673108B7A", + "device":"/dev/vda", + "unit":"sectors", + "firstlba":2048, + "lastlba":40084694, + "sectorsize":512, + "partitions": [ + {"node":"/dev/vda1", "start":2048, "size":1024000, "type":"C12A7328-F81F-11D2-BA4B-00A0C93EC93B", "uuid":"FB724EC0-209C-B74B-A6E1-9B51EFE59DBF"}, + {"node":"/dev/vda2", "start":1026048, "size":2048, "type":"21686148-6449-6E6F-744E-656564454649", "uuid":"4C4F3323-E487-F145-9A01-2AFA67956DC6"}, + {"node":"/dev/vda3", "start":1028096, "size":39056599, "type":"4F68BCE3-E8CD-4DB1-96E7-FBCAF984B709", "uuid":"BB8975A2-3417-DB43-98AE-84C9A9806BBE"} + ] + } +} diff --git a/tests/data/espgen-payg-amd64-fstab.json b/tests/data/espgen-payg-amd64-fstab.json new file mode 100644 index 0000000..3e6aac6 --- /dev/null +++ b/tests/data/espgen-payg-amd64-fstab.json @@ -0,0 +1,5 @@ +{ + "filesystems": [ + {"target":"/", "source":"/dev/vda3", "fstype":"ext4", "options":"errors=remount-ro"} + ] +} diff --git a/tests/data/espgen-payg-amd64-mounts-init.json b/tests/data/espgen-payg-amd64-mounts-init.json new file mode 100644 index 0000000..b58c093 --- /dev/null +++ b/tests/data/espgen-payg-amd64-mounts-init.json @@ -0,0 +1,7 @@ +{ + "filesystems": [ + {"target":"/sysroot", "source":"/dev/vda3", "maj:min":"252:3", "fstype":"ext4", "fsroot":"/", "options":"rw,relatime"}, + {"target":"/", "source":"/dev/vda3", "maj:min":"252:3", "fstype":"ext4", "fsroot":"/ostree/deploy/eos/deploy/b3fb4e8448913270508c9f35aee01090ccb430b72a4b12d083dae6009740d90f.0", "options":"rw,relatime"}, + {"target":"/usr", "source":"/dev/vda3", "maj:min":"252:3", "fstype":"ext4", "fsroot":"/ostree/deploy/eos/deploy/b3fb4e8448913270508c9f35aee01090ccb430b72a4b12d083dae6009740d90f.0/usr", "options":"ro,relatime"} + ] +} diff --git a/tests/data/espgen-payg-amd64-mounts-reload.json b/tests/data/espgen-payg-amd64-mounts-reload.json new file mode 100644 index 0000000..f5d97a5 --- /dev/null +++ b/tests/data/espgen-payg-amd64-mounts-reload.json @@ -0,0 +1,9 @@ +{ + "filesystems": [ + {"target":"/sysroot", "source":"/dev/vda3", "maj:min":"252:3", "fstype":"ext4", "fsroot":"/", "options":"rw,relatime,errors=remount-ro"}, + {"target":"/", "source":"/dev/vda3", "maj:min":"252:3", "fstype":"ext4", "fsroot":"/ostree/deploy/eos/deploy/b3fb4e8448913270508c9f35aee01090ccb430b72a4b12d083dae6009740d90f.0", "options":"rw,relatime,errors=remount-ro"}, + {"target":"/usr", "source":"/dev/vda3", "maj:min":"252:3", "fstype":"ext4", "fsroot":"/ostree/deploy/eos/deploy/b3fb4e8448913270508c9f35aee01090ccb430b72a4b12d083dae6009740d90f.0/usr", "options":"ro,relatime,errors=remount-ro"}, + {"target":"/var", "source":"/dev/vda3", "maj:min":"252:3", "fstype":"ext4", "fsroot":"/ostree/deploy/eos/var", "options":"rw,relatime,errors=remount-ro"}, + {"target":"/boot", "source":"/dev/vda1", "maj:min":"252:1", "fstype":"vfat", "fsroot":"/", "options":"rw,relatime,fmask=0022,dmask=0022,codepage=437,iocharset=iso8859-1,shortname=mixed,errors=remount-ro"} + ] +} diff --git a/tests/data/espgen-payg-xbootldr-amd64-disk-root.json b/tests/data/espgen-payg-xbootldr-amd64-disk-root.json new file mode 100644 index 0000000..184b06b --- /dev/null +++ b/tests/data/espgen-payg-xbootldr-amd64-disk-root.json @@ -0,0 +1,17 @@ +{ + "partitiontable": { + "label":"gpt", + "id":"5B13BB34-4E2B-DB48-9DD1-E84673108B7A", + "device":"/dev/vda", + "unit":"sectors", + "firstlba":2048, + "lastlba":40494294, + "sectorsize":512, + "partitions": [ + {"node":"/dev/vda1", "start":2048, "size":1024000, "type":"C12A7328-F81F-11D2-BA4B-00A0C93EC93B", "uuid":"FB724EC0-209C-B74B-A6E1-9B51EFE59DBF"}, + {"node":"/dev/vda2", "start":1026048, "size":2048, "type":"21686148-6449-6E6F-744E-656564454649", "uuid":"4C4F3323-E487-F145-9A01-2AFA67956DC6"}, + {"node":"/dev/vda3", "start":1028096, "size":409600, "type":"BC13C2FF-59E6-4262-A352-B275FD6F7172", "uuid":"C3C8D283-EC36-534C-BE18-1B41377F3D40"}, + {"node":"/dev/vda4", "start":1437696, "size":39056599, "type":"4F68BCE3-E8CD-4DB1-96E7-FBCAF984B709", "uuid":"BB8975A2-3417-DB43-98AE-84C9A9806BBE"} + ] + } +} diff --git a/tests/data/espgen-payg-xbootldr-amd64-fstab.json b/tests/data/espgen-payg-xbootldr-amd64-fstab.json new file mode 100644 index 0000000..7c983c1 --- /dev/null +++ b/tests/data/espgen-payg-xbootldr-amd64-fstab.json @@ -0,0 +1,5 @@ +{ + "filesystems": [ + {"target":"/", "source":"/dev/vda4", "fstype":"ext4", "options":"errors=remount-ro"} + ] +} diff --git a/tests/data/espgen-payg-xbootldr-amd64-mounts-init.json b/tests/data/espgen-payg-xbootldr-amd64-mounts-init.json new file mode 100644 index 0000000..13f9bfb --- /dev/null +++ b/tests/data/espgen-payg-xbootldr-amd64-mounts-init.json @@ -0,0 +1,7 @@ +{ + "filesystems": [ + {"target":"/sysroot", "source":"/dev/vda4", "maj:min":"252:4", "fstype":"ext4", "fsroot":"/", "options":"rw,relatime"}, + {"target":"/", "source":"/dev/vda4", "maj:min":"252:4", "fstype":"ext4", "fsroot":"/ostree/deploy/eos/deploy/b3fb4e8448913270508c9f35aee01090ccb430b72a4b12d083dae6009740d90f.0", "options":"rw,relatime"}, + {"target":"/usr", "source":"/dev/vda4", "maj:min":"252:4", "fstype":"ext4", "fsroot":"/ostree/deploy/eos/deploy/b3fb4e8448913270508c9f35aee01090ccb430b72a4b12d083dae6009740d90f.0/usr", "options":"ro,relatime"} + ] +} diff --git a/tests/data/espgen-payg-xbootldr-amd64-mounts-reload.json b/tests/data/espgen-payg-xbootldr-amd64-mounts-reload.json new file mode 100644 index 0000000..ec611f1 --- /dev/null +++ b/tests/data/espgen-payg-xbootldr-amd64-mounts-reload.json @@ -0,0 +1,10 @@ +{ + "filesystems": [ + {"target":"/sysroot", "source":"/dev/vda4", "maj:min":"252:4", "fstype":"ext4", "fsroot":"/", "options":"rw,relatime,errors=remount-ro"}, + {"target":"/", "source":"/dev/vda4", "maj:min":"252:4", "fstype":"ext4", "fsroot":"/ostree/deploy/eos/deploy/b3fb4e8448913270508c9f35aee01090ccb430b72a4b12d083dae6009740d90f.0", "options":"rw,relatime,errors=remount-ro"}, + {"target":"/usr", "source":"/dev/vda4", "maj:min":"252:4", "fstype":"ext4", "fsroot":"/ostree/deploy/eos/deploy/b3fb4e8448913270508c9f35aee01090ccb430b72a4b12d083dae6009740d90f.0/usr", "options":"ro,relatime,errors=remount-ro"}, + {"target":"/var", "source":"/dev/vda4", "maj:min":"252:4", "fstype":"ext4", "fsroot":"/ostree/deploy/eos/var", "options":"rw,relatime,errors=remount-ro"}, + {"target":"/efi", "source":"/dev/vda1", "maj:min":"252:1", "fstype":"vfat", "fsroot":"/", "options":"rw,relatime,fmask=0077,dmask=0077,codepage=437,iocharset=iso8859-1,shortname=mixed,errors=remount-ro"}, + {"target":"/boot", "source":"/dev/vda3", "maj:min":"252:3", "fstype":"ext4", "fsroot":"/", "options":"rw,relatime,errors=remount-ro"} + ] +} diff --git a/tests/espgen-test-data.sh b/tests/espgen-test-data.sh new file mode 100755 index 0000000..0ef7145 --- /dev/null +++ b/tests/espgen-test-data.sh @@ -0,0 +1,113 @@ +#!/bin/bash + +# Copyright 2023 Endless OS Foundation, LLC +# SPDX-License-Identifier: GPL-2.0-or-later + +# Gather test data in the format eos-esp-generator uses. Install this in +# /etc/systemd/system-generators/ to gather data during boot and +# systemctl daemon-reload. Test data will be written to +# /run/espgen-data-*.tar.gz. Keep the command options below in sync with +# it. + +set -e +shopt -s nullglob + +usage() { + cat <&2 + exit 1 + ;; + esac +done + +if [ -n "$SYSTEMD_SCOPE" ]; then + export TMPDIR=/run + exec >/dev/kmsg +fi + +[ "$UID" -eq 0 ] && SUDO= || SUDO=sudo + +datatmpdir=$(mktemp -d --tmpdir espgen-data-XXXXXXXX) +trap 'rm -rf "$datatmpdir"' EXIT +datadir_name=espgen-data +datadir="$datatmpdir/$datadir_name" +mkdir "$datadir" + +# Make sure if /boot or /efi are automounts, the real mounts are +# triggered. +test -e /boot || : +test -e /efi || : + +findmnt \ + --json \ + --list \ + --canonicalize \ + --evaluate \ + --nofsroot \ + --output \ + 'TARGET,SOURCE,MAJ:MIN,FSTYPE,FSROOT,OPTIONS' \ + --real \ + > "$datadir/mounts.json" +if "$PRINT"; then + echo "mounts.json:" + cat "$datadir/mounts.json" +fi + +findmnt \ + --json \ + --list \ + --canonicalize \ + --evaluate \ + --nofsroot \ + --output \ + 'TARGET,SOURCE,FSTYPE,OPTIONS' \ + --fstab \ + > "$datadir/fstab.json" +if "$PRINT"; then + echo "fstab.json:" + cat "$datadir/fstab.json" +fi + +for sys_block in /sys/block/*; do + partitions=("$sys_block"/*/partition) + [ ${#partitions[@]} -eq 0 ] && continue + name=${sys_block##*/} + $SUDO sfdisk --json "/dev/$name" > "$datadir/disk-${name}.json" + if "$PRINT"; then + echo "disk-${name}.json:" + cat "$datadir/disk-${name}.json" + fi +done + +if ! "$PRINT"; then + datafile=$(mktemp --tmpdir "${datadir_name}-XXXXXXXX.tar.gz") + chmod 644 "$datafile" + echo "Storing espgen test data in $datafile" + tar -C "$datatmpdir" -czf "$datafile" "$datadir_name" +fi diff --git a/tests/test_esp_generator.py b/tests/test_esp_generator.py new file mode 100644 index 0000000..fa108d5 --- /dev/null +++ b/tests/test_esp_generator.py @@ -0,0 +1,423 @@ +# Copyright © 2023 Endless OS Foundation, LLC +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or (at +# your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +# 02110-1301, USA. + +""" +Tests for eos-esp-generator +""" + +from contextlib import contextmanager +import json +import logging +import os +from pathlib import Path +import pytest +import sys +from textwrap import dedent + +from .util import import_script_as_module + +espgen = import_script_as_module('espgen', 'eos-esp-generator') + +logger = logging.getLogger(__name__) +TESTSDIR = Path(os.path.dirname(__file__)) +TESTSDATADIR = TESTSDIR / 'data' + + +@pytest.fixture(autouse=True) +def generator_environment(monkeypatch): + """Generator environment fixture""" + monkeypatch.setenv('SYSTEMD_IN_INITRD', '0') + + +@pytest.fixture +def root_dir(tmp_path): + """Temporary root directory""" + path = tmp_path / 'root' + path.mkdir() + path.joinpath('boot').mkdir() + path.joinpath('efi').mkdir() + return path + + +@pytest.fixture +def unit_dir(root_dir): + """Temporary systemd generator unit directory""" + path = root_dir / 'run/systemd/generator' + path.mkdir(parents=True) + return path + + +@pytest.fixture +def mock_system_data(monkeypatch): + """System data mocking factory fixture + + Provides a function that can be called with the desired mocked data. + This will override the generator calls that use findmnt and sfdisk. + """ + def _mock_system_data(mounts, fstab, disks): + with mounts.open() as f: + mounts_data = json.load(f)['filesystems'] + logger.debug(f'mounts: {mounts_data}') + + def _fake_mount_data(root=Path('/')): + return mounts_data + + monkeypatch.setattr(espgen, 'get_mount_data', _fake_mount_data) + + with fstab.open() as f: + fstab_data = json.load(f)['filesystems'] + logger.debug(f'fstab: {fstab_data}') + + def _fake_fstab_data(): + return fstab_data + + monkeypatch.setattr(espgen, 'get_fstab_data', _fake_fstab_data) + + partitions = [] + for disk in disks: + with disk.open() as f: + disk_data = json.load(f) + partitions += espgen._disk_to_partitions(disk_data) + logger.debug(f'partitions: {partitions}') + + def _fake_partition_data(): + return partitions + + monkeypatch.setattr(espgen, 'get_partition_data', _fake_partition_data) + + return _mock_system_data + + +@contextmanager +def loader_device_part_uuid(value, root): + """LoaderDevicePartUUID EFI variable context manager""" + varpath = root / 'sys/firmware/efi/efivars' / espgen.LOADER_DEVICE_PART_UUID_EFIVAR + varpath.parent.mkdir(parents=True, exist_ok=True) + + # Using the utf-16 codec for writing will add the byte order mark + # (BOM). Select the native endian version. + utf_16 = 'utf-16-le' if sys.byteorder == 'little' else 'utf-16-be' + + with open(varpath, 'wb') as f: + f.write(b'\0\0\0\0') + f.write(value.encode(utf_16)) + + # Add 3 nul byte terminators like systemd-boot does. + f.write(b'\0\0\0') + + try: + yield + finally: + varpath.unlink() + + +# Dictionary of mocked data to use with the test_get_esp_mount() test. +ESP_MOUNT_TEST_DATA = { + # Standard EOS with GPT partitioning. + 'gpt': { + 'mounts-init': TESTSDATADIR / 'espgen-gpt-amd64-mounts-init.json', + 'mounts-reload': TESTSDATADIR / 'espgen-gpt-amd64-mounts-reload.json', + 'fstab': TESTSDATADIR / 'espgen-gpt-amd64-fstab.json', + 'disks': [ + TESTSDATADIR / 'espgen-gpt-amd64-disk-root.json', + ], + 'esp-mount': espgen.EspMount( + source='/dev/vda1', + target='/efi', + type='vfat', + umask='0077', + ), + }, + + # Standard EOS with MBR partitioning. + 'mbr': { + 'mounts-init': TESTSDATADIR / 'espgen-mbr-amd64-mounts-init.json', + 'mounts-reload': TESTSDATADIR / 'espgen-mbr-amd64-mounts-reload.json', + 'fstab': TESTSDATADIR / 'espgen-mbr-amd64-fstab.json', + 'disks': [ + TESTSDATADIR / 'espgen-mbr-amd64-disk-root.json', + ], + 'esp-mount': espgen.EspMount( + source='/dev/vda1', + target='/efi', + type='vfat', + umask='0077', + ), + }, + + # Standard EOS with GPT partitioning and /efi in fstab. + 'efi-fstab': { + 'mounts-init': TESTSDATADIR / 'espgen-gpt-amd64-mounts-init.json', + 'mounts-reload': TESTSDATADIR / 'espgen-efi-fstab-amd64-mounts-reload.json', + 'fstab': TESTSDATADIR / 'espgen-efi-fstab-amd64-fstab.json', + 'disks': [ + TESTSDATADIR / 'espgen-gpt-amd64-disk-root.json', + ], + 'esp-mount': None, + }, + + # PAYG with GPT partitioning. + 'payg': { + 'mounts-init': TESTSDATADIR / 'espgen-payg-amd64-mounts-init.json', + 'mounts-reload': TESTSDATADIR / 'espgen-payg-amd64-mounts-reload.json', + 'fstab': TESTSDATADIR / 'espgen-payg-amd64-fstab.json', + 'disks': [ + TESTSDATADIR / 'espgen-payg-amd64-disk-root.json', + ], + 'esp-mount': espgen.EspMount( + source='/dev/vda1', + target='/boot', + type='vfat', + umask='0022', + ), + }, + + # PAYG with GPT partitioning and XBOOTLDR partition. This is not + # something we currently do, but we could to allow /boot to be on a + # normal filesystem like ext4. + 'payg-xbootldr': { + 'mounts-init': TESTSDATADIR / 'espgen-payg-xbootldr-amd64-mounts-init.json', + 'mounts-reload': TESTSDATADIR / 'espgen-payg-xbootldr-amd64-mounts-reload.json', + 'fstab': TESTSDATADIR / 'espgen-payg-xbootldr-amd64-fstab.json', + 'disks': [ + TESTSDATADIR / 'espgen-payg-xbootldr-amd64-disk-root.json', + ], + 'esp-mount': espgen.EspMount( + source='/dev/vda1', + target='/efi', + type='vfat', + umask='0077', + ), + }, +} + + +@pytest.mark.parametrize('system', sorted(ESP_MOUNT_TEST_DATA.keys())) +def test_get_esp_mount(system, root_dir, mock_system_data, monkeypatch): + """EspGenerator.get_esp_mount tests""" + system_data = ESP_MOUNT_TEST_DATA[system] + mounts_init = system_data['mounts-init'] + mounts_reload = system_data['mounts-reload'] + fstab = system_data['fstab'] + disks = system_data['disks'] + expected_mount = system_data['esp-mount'] + + # Running during init. + mock_system_data(mounts=mounts_init, fstab=fstab, disks=disks) + generator = espgen.EspGenerator(root_dir) + esp_mount = generator.get_esp_mount() + assert esp_mount == expected_mount + + # If the generator is run in the initrd, it should do nothing. + with monkeypatch.context() as mctx: + mctx.setenv('SYSTEMD_IN_INITRD', '1') + generator = espgen.EspGenerator(root_dir) + esp_mount = generator.get_esp_mount() + assert esp_mount is None + + # If the expected mount directory is /efi but there's no /efi + # directory, nothing should be mounted since there's already a mount + # on /boot. + root_dir.joinpath('efi').rmdir() + generator = espgen.EspGenerator(root_dir) + esp_mount = generator.get_esp_mount() + if expected_mount is None or expected_mount.target == '/efi': + assert esp_mount is None + else: + assert esp_mount == expected_mount + root_dir.joinpath('efi').mkdir() + + expected_esp_part = {} + if expected_mount: + partitions = espgen.get_partition_data() + expected_esp_part = next( + filter(lambda p: p['node'] == expected_mount.source, partitions), + ) + + # If the ESP disk is GPT partitioned, test the LoaderDevicePartUUID + # EFI variable handling. + if expected_esp_part.get('disk_label') == 'gpt': + # Set it to the expected value. + with loader_device_part_uuid(expected_esp_part['uuid'].upper(), root_dir): + generator = espgen.EspGenerator(root_dir) + esp_mount = generator.get_esp_mount() + assert esp_mount == expected_mount + + # Set it to a different value. This should cause the mount to be + # skipped. + with loader_device_part_uuid('4859CD39-28DC-4C60-B509-C2A63A80483B', root_dir): + generator = espgen.EspGenerator(root_dir) + esp_mount = generator.get_esp_mount() + assert esp_mount is None + + # Load the post-init mounts data to check that running during a + # reload still creates the mount unit. + mock_system_data(mounts=mounts_reload, fstab=fstab, disks=disks) + generator = espgen.EspGenerator(root_dir) + esp_mount = generator.get_esp_mount() + assert esp_mount == expected_mount + + +def test_write_units(unit_dir): + """EspMount.write_units tests""" + esp_mount = espgen.EspMount( + source='/dev/vda1', + target='/efi', + type='vfat', + umask='0077', + ) + esp_mount.write_units(unit_dir) + automount_unit = unit_dir / 'efi.automount' + mount_unit = unit_dir / 'efi.mount' + local_fs_wants = unit_dir / 'local-fs.target.wants/efi.automount' + units = set() + for dirpath, dirnames, filenames in os.walk(unit_dir): + units.update([unit_dir / dirpath / f for f in filenames]) + assert units == {automount_unit, mount_unit, local_fs_wants} + assert not os.path.isabs(os.readlink(local_fs_wants)) + assert local_fs_wants.resolve() == automount_unit + + automount_contents = automount_unit.read_text() + assert automount_contents == dedent("""\ + # Automatically generated by eos-esp-generator + + [Unit] + Description=EFI System Partition Automount + + [Automount] + Where=/efi + TimeoutIdleSec=2min + """) + + mount_contents = mount_unit.read_text() + assert mount_contents == dedent("""\ + # Automatically generated by eos-esp-generator + + [Unit] + Description=EFI System Partition Automount + Requires=systemd-fsck@dev-vda1.service + After=systemd-fsck@dev-vda1.service + After=blockdev@dev-vda1.target + + [Mount] + What=/dev/vda1 + Where=/efi + Type=vfat + Options=umask=0077,noauto,rw + """) + + +def test_read_efivar(root_dir, caplog): + """read_efivar tests""" + var = 'Foo-7553c1d3-754b-47f3-a613-b9e2b860e8c1' + varpath = root_dir / 'sys/firmware/efi/efivars' / var + varpath.parent.mkdir(parents=True) + + # The first 32 bits are an attribute mask. Use all 1s so we can + # better detect it leaking into the value. + attr = b'\xff\xff\xff\xff' + + # Missing variable should return None. + varpath.unlink(missing_ok=True) + value = espgen.read_efivar(var, root_dir) + assert value is None + + # Empty value. + with open(varpath, 'wb') as f: + f.write(attr) + value = espgen.read_efivar(var, root_dir) + assert value == b'' + + # Actual contents. + with open(varpath, 'wb') as f: + f.write(attr) + f.write(b'hello') + value = espgen.read_efivar(var, root_dir) + assert value == b'hello' + + # Invalid contents should log a warning and return None. + with open(varpath, 'wb') as f: + pass + caplog.clear() + with caplog.at_level(logging.WARNING): + value = espgen.read_efivar(var, root_dir) + assert value is None + assert caplog.record_tuples == [( + espgen.logger.name, + logging.WARNING, + f'Invalid EFI variable {var} is less than 4 bytes', + )] + + +def test_efivar_utf16_string(root_dir): + """read_efivar_utf16_string tests""" + var = 'Bar-7553c1d3-754b-47f3-a613-b9e2b860e8c1' + varpath = root_dir / 'sys/firmware/efi/efivars' / var + varpath.parent.mkdir(parents=True) + + # The first 32 bits are an attribute mask. Use all 1s so we can + # better detect it leaking into the value. + attr = b'\xff\xff\xff\xff' + + # Using the utf-16 codec for writing will add the byte order mark + # (BOM). Select the native endian version. + utf_16 = 'utf-16-le' if sys.byteorder == 'little' else 'utf-16-be' + + # Missing variable should return None. + varpath.unlink(missing_ok=True) + value = espgen.read_efivar_utf16_string(var, root_dir) + assert value is None + + # Empty value. + with open(varpath, 'wb') as f: + f.write(attr) + value = espgen.read_efivar_utf16_string(var, root_dir) + assert value == '' + + # Actual contents. + with open(varpath, 'wb') as f: + f.write(attr) + f.write('hello'.encode(utf_16)) + value = espgen.read_efivar_utf16_string(var, root_dir) + assert value == 'hello' + + # Only nul terminator. + with open(varpath, 'wb') as f: + f.write(attr) + f.write(b'\0\0') + value = espgen.read_efivar_utf16_string(var, root_dir) + assert value == '' + + # Various nul byte terminators. + for n in range(6): + term = b'\0' * n + with open(varpath, 'wb') as f: + f.write(attr) + f.write('hello'.encode(utf_16)) + f.write(term) + value = espgen.read_efivar_utf16_string(var, root_dir) + assert value == 'hello' + + # Unicode. + uface = '\N{UPSIDE-DOWN FACE}' # 🙃 + for term in (b'', b'\0\0'): + with open(varpath, 'wb') as f: + f.write(attr) + f.write(uface.encode(utf_16)) + f.write(term) + value = espgen.read_efivar_utf16_string(var, root_dir) + assert value == uface