Source code for cmkinitramfs.bin

"""Module providing functions to manage binaries and executables

This library also provides utilities like :func:`find_exec`.
Multiple helper functions are also available (e.g. :func:`find_elf_deps_iter`
and :func:`find_elf_deps_set` to list libraries needed by an ELF executable).
"""

from __future__ import annotations

import functools
import glob
import itertools
import logging
import os
import os.path
import platform
import stat
import subprocess
from typing import FrozenSet, Iterator, List, Optional, Tuple, Union

from elftools.common.exceptions import ELFError
from elftools.elf.elffile import ELFFile
from elftools.elf.enums import ENUM_DT_FLAGS_1

from .utils import normpath, removeprefix


logger = logging.getLogger(__name__)
#: Kernel modules will be searched in ``{KMOD_DIR}/{KERNEL}/**/*.ko``
KMOD_DIR = '/lib/modules'


[docs]class ELFIncompatibleError(ELFError): """The ELF files are incompatible"""
[docs]def parse_ld_path(ld_path: Optional[str] = None, origin: str = '', root: str = '/') -> Iterator[str]: """Parse a colon-delimited list of paths and apply ldso rules Note the special handling as dictated by the ldso: - Empty paths are equivalent to $PWD - $ORIGIN is expanded to the path of the given file, ``origin`` :param ld_path: Colon-delimited string of paths, defaults to the ``LD_LIBRARY_PATH`` environment variable :param origin: Directory containing the ELF file being parsed (used for $ORIGIN), defaults to an empty string :param root: Path to prepend to all paths found :return: Iterator over the processed paths """ if ld_path is None: ld_path = os.environ.get('LD_LIBRARY_PATH') if ld_path is None: return logger.debug("Parsing ld_path %s", ld_path) lib = 'lib64' if platform.architecture()[0] == '64bit' else 'lib' platform_ = platform.machine() for path in ld_path.split(':'): if not path: yield normpath(os.getcwd()) else: for k in (('$ORIGIN', origin), ('${ORIGIN}', origin), ('$LIB', lib), ('${LIB}', lib), ('$PLATFORM', platform_), ('${PLATFORM}', platform_)): path = path.replace(*k) if os.path.isabs(path): path = root + '/' + path yield normpath(path)
[docs]def parse_ld_so_conf_iter(conf_path: Optional[str] = None, root: str = '/') \ -> Iterator[str]: """Parse a ldso config file This should handle comments, whitespace, and "include" statements. :param conf_path: Path of the ldso config file to parse, defaults to ``{root}/etc/ld.so.conf`` :param root: Path to prepend to all paths found :return: Iterator over the processed paths """ if conf_path is None: conf_path = normpath(root + '/etc/ld.so.conf') if not os.path.isfile(conf_path): return logger.debug("Parsing ld.so.conf %s", conf_path) with open(conf_path, 'r') as conf_file: for line in conf_file: line = line.split('#', 1)[0].strip() if not line: continue if line.startswith('include '): line = line[8:] if line[0] != '/': line = os.path.dirname(conf_path) + '/' + line for path in sorted(glob.glob(line)): yield from parse_ld_so_conf_iter(normpath(path), root) else: yield normpath(root + line)
[docs]@functools.lru_cache() def parse_ld_so_conf_tuple(conf_path: Optional[str] = None, root: str = '/') \ -> Tuple[str, ...]: """Parse a ldso config file Cached version of :func:`parse_ld_so_conf_iter`, returning a tuple. :param conf_path: Path of the ldso config file to parse, defaults to ``{root}/etc/ld.so.conf`` :param root: Path to prepend to all paths found :return: Tuple with the processed paths """ return tuple(parse_ld_so_conf_iter(conf_path, root))
[docs]@functools.lru_cache() def _get_default_libdirs(root: str = '/') -> Tuple[str, ...]: """Get the default library directories :param root: Root directory to check for library directories :return: Libdirs in the initramfs """ libdirs = [] for lib in ('lib64', 'lib', 'lib32'): for prefix in ('/', '/usr/'): path = normpath(root + prefix + lib) if os.path.exists(path): libdirs.append(path) return tuple(libdirs)
[docs]@functools.lru_cache() def _get_libdir(arch: int, root: str = '/') -> str: """Get the libdir corresponding to a binary class :param arch: Binary class (e.g. :data:`32` or :data:`64`) :param root: Directory where libdirs are searched :return: Libdir in the initramfs """ if arch == 64 and os.path.exists(root + '/lib64'): return '/lib64' if arch == 32 and os.path.exists(root + '/lib32'): return '/lib32' return '/lib'
[docs]def _is_elf_compatible(elf1: ELFFile, elf2: ELFFile) -> bool: """See if two ELFs are compatible This compares the aspects of the ELF to see if they're compatible: bit size, endianness, machine type, and operating system. :param elf1: First ELF object :param elf2: Second ELF object :return: :data:`True` if compatible, :data:`False` otherwise """ osabis = frozenset([e.header['e_ident']['EI_OSABI'] for e in (elf1, elf2)]) compat_sets = (frozenset( f'ELFOSABI_{x}' for x in ('NONE', 'SYSV', 'GNU', 'LINUX',) ),) return ( (len(osabis) == 1 or any(osabis.issubset(x) for x in compat_sets)) and elf1.elfclass == elf2.elfclass and elf1.little_endian == elf2.little_endian and elf1.header['e_machine'] == elf2.header['e_machine'] )
[docs]def _get_elf_arch(elf1: Union[ELFFile, str], elf2: Union[ELFFile, str]) -> int: """Open elf2, check compatibility, and return ELF architecture :param elf1: First ELF :param elf2: Second ELF :return: Architecture of the ELF files (32 or 64) :raises OSError: Could not open an ELF file :raises ELFIncompatibleError: ELFs are incompatible :raises ELFError: File is not an ELF file """ # Convert string to ELFFile if isinstance(elf1, str): with open(elf1, 'rb') as elf_file: return _get_elf_arch(ELFFile(elf_file), elf2) if isinstance(elf2, str): with open(elf2, 'rb') as elf_file: return _get_elf_arch(elf1, ELFFile(elf_file)) if not _is_elf_compatible(elf1, elf2): raise ELFIncompatibleError("Incompatible ELF binaries") return elf1.elfclass
[docs]def _find_elf_deps_iter(elf: ELFFile, origin: str, root: str = '/') \ -> Iterator[Tuple[str, str]]: """Iterates over the dependencies of an ELF file Backend of :func:`find_elf_deps_iter`. :param elf: Elf file to parse :param origin: Directory containing the ELF binary (real path as provided by :func:`os.path.realpath`, used for $ORIGIN) :param root: Path to prepend to all paths found :return: Same as :func:`find_elf_deps_iter` :raises FileNotFoundError: Dependency not found """ deps: List[str] = [] rpaths: List[str] = [] runpaths: List[str] = [] nodeflib = False # Read ELF segments for segment in elf.iter_segments(): if segment.header.p_type == 'PT_INTERP': interp = segment.get_interp_name() logger.debug("INTERP: %s", interp) deps.append(interp) elif segment.header.p_type == 'PT_DYNAMIC': for tag in segment.iter_tags(): if tag.entry.d_tag == 'DT_RPATH': rpaths.extend(parse_ld_path(tag.rpath, origin, root)) elif tag.entry.d_tag == 'DT_RUNPATH': runpaths.extend(parse_ld_path(tag.runpath, origin, root)) elif tag.entry.d_tag == 'DT_NEEDED': deps.extend(parse_ld_path(tag.needed, origin, root)) elif tag.entry.d_tag == 'DT_FLAGS_1': if tag.entry.d_val & ENUM_DT_FLAGS_1['DF_1_NODEFLIB']: nodeflib = True logger.debug("ELF: deps: %s, rpaths: %s, runpaths: %s, nodeflib: %s", deps, rpaths, runpaths, nodeflib) # Directories in which dependencies will be searched search_paths_base = tuple(itertools.chain( rpaths, parse_ld_path(None, origin, root), runpaths, parse_ld_so_conf_tuple(root=root), _get_default_libdirs(root) )) for dep in deps: search_paths = search_paths_base \ if not os.path.isabs(dep) else (root,) # Search the dependency for found_dir in search_paths: found_path = normpath(found_dir + '/' + dep) # Check found_path is valid and compatible if nodeflib and found_dir in _get_default_libdirs(root): continue try: found_arch = _get_elf_arch(elf, found_path) except (ELFError, OSError): continue # Lib found if os.path.isabs(dep) \ or found_dir in itertools.chain(rpaths, runpaths): # Libdir in R*PATH: use the same path dest = normpath('/' + removeprefix(found_path, root)) else: # Libdir in ld_path or ld.so.conf: use default libdir dest = normpath(_get_libdir(found_arch, root) + '/' + dep) logger.debug("Found %s in %s (dest: %s)", dep, found_dir, dest) yield found_path, dest break else: raise FileNotFoundError(f"ELF dependency not found: {dep}")
[docs]def find_elf_deps_iter(src: str, root: str = '/') -> Iterator[Tuple[str, str]]: """Iterates over the dependencies of an ELF file Read an ELF file to search dynamic library dependencies. For each dependency, find it on the system (using RPATH, LD_LIBRARY_PATH, RUNPATH, ld.so.conf, and default library directories). If the library is in a path encoded in the ELF binary (RPATH or RUNPATH), ``dep_dest = dep_src``, otherwise use a default library directory according to the type of binary (``/lib``, ``/lib64``, ``/lib32``). If the file is not an ELF file, returns an empty iterator. :param src: File to find dependencies for :param root: Path to prepend to all paths found :return: Iterator of ``(dep_src, dep_dest)``, with ``dep_src`` the path of the dependency on the current system, and ``dep_dest`` the path of the dependency on the initramfs :raises FileNotFoundError: Dependency not found """ logger.debug("Searching ELF dependencies for %s", src) if src != os.path.realpath(src): yield from find_elf_deps_iter(os.path.realpath(src), root) return with open(src, 'rb') as src_file: try: elf = ELFFile(src_file) except ELFError: return yield from _find_elf_deps_iter(elf, os.path.dirname(src), root) logger.debug("Found all ELF dependencies for %s", src)
[docs]@functools.lru_cache() def find_elf_deps_set(src: str, root: str = '/') -> FrozenSet[Tuple[str, str]]: """Find dependencies of an ELF file Cached version of :func:`find_elf_deps_iter`. :param src: File to find dependencies for :param root: Path to prepend to all paths found :return: Set of ``(dep_src, dep_dest)``, see :func:`find_elf_deps_iter`. :raises FileNotFoundError: Dependency not found """ if src != os.path.realpath(src): return find_elf_deps_set(os.path.realpath(src), root) return frozenset(find_elf_deps_iter(src, root))
[docs]def find_lib_iter(lib: str, compat: Optional[str] = None, root: str = '/') \ -> Iterator[Tuple[str, str]]: """Search a library in the system, with globbing Same as :func:`find_lib` but uses :func:`glob.glob` to find matching libraries. :param lib: Glob pattern for the library to search (e.g. ``libgcc_s.*``) :param compat: Path to a binary that the library must be compatible with (checked with :func:`_is_elf_compatible`), defaults to ``{root}/bin/sh`` :param root: Path to prepend to all paths found :return: Iterator over ``(lib_src, lib_dest)``, see :func:`find_lib` :raises FileNotFoundError: Library not found """ if compat is None: compat = normpath(root + '/bin/sh') logger.debug("Searching library %s (compat: %s)", lib, compat) libname = os.path.basename(lib) with open(compat, 'rb') as pyexec: pyelf = ELFFile(pyexec) # If path is absolute: only search in root search_paths = itertools.chain( (os.getcwd(),), parse_ld_path(root=root), parse_ld_so_conf_tuple(root=root), _get_default_libdirs(root) ) if not os.path.isabs(lib) else (root,) found = False for found_dir in search_paths: found_path = found_dir + '/' + libname for found_path in glob.iglob(found_path): try: found_arch = _get_elf_arch(pyelf, found_path) except (ELFError, OSError): continue found = True dest = normpath(_get_libdir(found_arch, root) + '/' + libname) logger.debug("Found %s in %s (dest: %s)", lib, found_dir, dest) yield found_path, dest if not found: raise FileNotFoundError(lib)
[docs]def find_lib(lib: str, compat: Optional[str] = None, root: str = '/') \ -> Tuple[str, str]: """Search a library in the system, without globbing Uses ``ld.so.conf`` and ``LD_LIBRARY_PATH``. Libraries will be installed in the default library directory in the initramfs. :param lib: Library to search (e.g. ``libgcc_s.so.1``) :param compat: Path to a binary that the library must be compatible with (checked with :func:`_is_elf_compatible`), defaults to ``{root}/bin/sh`` :param root: Path to prepend to all paths found :return: ``(lib_src, lib_dest)``, with ``lib_src`` the absolute path of the library on the current system, and ``lib_dest`` the absolute path of the library on the initramfs :raises FileNotFoundError: Library not found """ return next(find_lib_iter(glob.escape(lib), compat, root))
[docs]def parse_path(path: Optional[str] = None, root: str = '/') \ -> Iterator[str]: """Parse PATH variable :param path: PATH string to parse, default to the ``PATH`` environment variable :param root: Path to prepend to all paths found :return: Iterator over the processed paths """ if path is None: path = os.environ.get('PATH') if path is None: return logger.debug("Parsing path %s", path) for k in path.split(':'): if not k: yield normpath(os.getcwd()) else: yield normpath(root + '/' + k)
[docs]def find_exec(executable: str, compat: Optional[str] = None, root: str = '/') \ -> Tuple[str, str]: """Search an executable in the system Uses the ``PATH`` environment variable. :param executable: Executable to search :param compat: Path to a binary that the executable must be compatible with (checked with :func:`_is_elf_compatible`), defaults to ``{root}/bin/sh`` :param root: Path to prepend to all paths found :return: ``(src_path, dest_path)`` with ``src_path`` the path of the executable on ``root``, and ``dest_path`` the default path of the executable on the initramfs. :return: Absolute path of the executable :raises FileNotFoundError: Executable not found """ if compat is None: compat = normpath(root + '/bin/sh') logger.debug("Searching executable %s (compat: %s)", executable, compat) # Get list of directories to search execdirs = itertools.chain((os.getcwd(),), parse_path(root=root)) \ if not os.path.isabs(executable) else (root,) # Parse directories execname = os.path.basename(executable) with open(compat, 'rb') as compat_file: compat_elf = ELFFile(compat_file) for found_dir in execdirs: found_path = normpath(found_dir + '/' + execname) # Check for compatibility if not os.path.isfile(found_path) \ or os.stat(found_path).st_mode & stat.S_IXOTH == 0: continue try: _get_elf_arch(compat_elf, found_path) except (ELFIncompatibleError, OSError): continue except ELFError: pass dest = normpath('/' + removeprefix(found_path, root)) logger.debug("Found %s in %s (dest: %s)", executable, found_dir, dest) return found_path, dest raise FileNotFoundError(executable)
[docs]@functools.lru_cache() def _get_all_kmods(kernel: str) -> FrozenSet[str]: """Get all kernel modules on the system :param kernel: Target kernel version :return: Set with the absolute path of the modules """ with open(f'{KMOD_DIR}/{kernel}/modules.builtin', 'r') as builtin: return frozenset(itertools.chain( ( normpath(f'{KMOD_DIR}/{kernel}/{module.strip()}') for module in builtin ), glob.glob(normpath(f'{KMOD_DIR}/{kernel}/**/*.ko'), recursive=True) ))
[docs]@functools.lru_cache() def find_kmod_deps(path: str) -> FrozenSet[str]: """Get kernel module dependencies :param path: Path of the kernel module to parse :return: Set with the dependencies' names :raises subprocess.CalledProcessError: Error during ``modinfo`` """ cmd = ('modinfo', '-0', '-F', 'depends', path) logger.debug("Subprocess: %s", cmd) proc = subprocess.run(cmd, stdout=subprocess.PIPE, check=True) return frozenset( k.strip('\0') for k in proc.stdout.decode('UTF-8').split(',') if k.strip('\0') )
[docs]def find_kmod(module: str, kernel: str) -> Optional[str]: """Search a kernel module on the system :param module: Name of the kernel module :param kernel: Target kernel version :return: Absolute path of the kernel module on the system :raises FileNotFoundError: Kernel module not found """ def none_if_builtin(module: str) -> Optional[str]: module = normpath(module) if module.startswith(f'{KMOD_DIR}/{kernel}/kernel/'): logger.debug("Module %s: builtin", module) return None return module logger.debug("Searching module %s for kernel %s", module, kernel) if os.path.isabs(module): logger.debug("Module path is absolute: %s", module) return none_if_builtin(module) module_compat = module.replace('_', '-') + '.ko' for kmod in _get_all_kmods(kernel): if module_compat == os.path.basename(kmod).replace('_', '-'): kmod = normpath(kmod) logger.debug("Found module %s: %s", module, kmod) return none_if_builtin(kmod) raise FileNotFoundError(f"Kernel module not found: {module}")