From 253fc0b31a2161fec29ff937f9a5e7c35cc7438e Mon Sep 17 00:00:00 2001 From: Christian Heimes Date: Wed, 26 Jun 2024 16:48:48 +0200 Subject: [PATCH] Fix tests, update README, unique (#1) Signed-off-by: Christian Heimes --- README.md | 67 ++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 15 ++++++++- src/elfdeps/__main__.py | 11 ++++++- src/elfdeps/_elfdeps.py | 43 +++++++++++++++++--------- tox.ini | 13 ++++++-- 5 files changed, 129 insertions(+), 20 deletions(-) mode change 100755 => 100644 src/elfdeps/_elfdeps.py diff --git a/README.md b/README.md index 34e42a7..7555b65 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,68 @@ # ELF deps -Python implementation of RPM [elfdeps](https://github.com/rpm-software-management/rpm/blob/master/tools/elfdeps.c). +Python implementation of RPM [`elfdeps`](https://github.com/rpm-software-management/rpm/blob/master/tools/elfdeps.c). The `elfdeps` tool can extract dependencies and provides from an ELF binary. + +## Example + +```shell-session +$ elfdeps --requires /usr/bin/python3.12 +libc.so.6(GLIBC_2.34)(64bit) +libc.so.6(GLIBC_2.2.5)(64bit) +libpython3.12.so.1.0()(64bit) +libc.so.6()(64bit) +rtld(GNU_HASH) + +$ elfdeps --provides /usr/lib64/libpython3.12.so +libpython3.12.so.1.0()(64bit) +``` + +```shell-session +$ elfdeps --provides /lib64/libc.so.6 +libc.so.6(GLIBC_2.2.5)(64bit) +libc.so.6(GLIBC_2.2.6)(64bit) +libc.so.6(GLIBC_2.3)(64bit) +... +libc.so.6(GLIBC_2.36)(64bit) +libc.so.6(GLIBC_2.38)(64bit) +libc.so.6(GLIBC_ABI_DT_RELR)(64bit) +libc.so.6(GLIBC_PRIVATE)(64bit) +libc.so.6()(64bit) +``` + +## RPM + +In Fedora-based distributions, RPM packages provide and require virtual packages with ELF sonames and versions. The package manager can install virtual provides. + +The `python3` base package depends on `libpython3.12.so.1.0()(64bit)` and `libc.so.6(GLIBC_2.34)(64bit)`: + +```shell-session +$ rpm -qR python3 +libc.so.6()(64bit) +libc.so.6(GLIBC_2.2.5)(64bit) +libc.so.6(GLIBC_2.34)(64bit) +libpython3.12.so.1.0()(64bit) +... +rtld(GNU_HASH) +``` + +The `python3-libs` package virtually provides `libpython3.12.so.1.0()(64bit)`: + +```shell-session +$ rpm -qP python3-libs +bundled(libb2) = 0.98.1 +libpython3.12.so.1.0()(64bit) +libpython3.so()(64bit) +python-libs = 3.12.3-2.fc39 +python3-libs = 3.12.3-2.fc39 +python3-libs(x86-64) = 3.12.3-2.fc39 +python3.12-libs = 3.12.3-2.fc39 +``` + +```shell-session +$ sudo dnf install 'libc.so.6(GLIBC_2.34)(64bit)' 'libpython3.12.so.1.0()(64bit)' +Package glibc-2.38-18.fc39.x86_64 is already installed. +Package python3-libs-3.12.3-2.fc39.x86_64 is already installed. +Dependencies resolved. +Nothing to do. +Complete! +``` diff --git a/pyproject.toml b/pyproject.toml index 7088703..ad97f5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -70,7 +69,21 @@ select = [ "TID", # flake8-tidy-imports ] ignore = [ + "UP006", # not compatible with 3.9 type annotations ] [tool.ruff.lint.isort] known-first-party = ["elfdeps"] + +[tool.mypy] +python_version = "3.9" +warn_return_any = true +warn_unused_configs = true + +[[tool.mypy.overrides]] +module = ["elfdeps.*"] +disallow_untyped_defs = true + +[[tool.mypy.overrides]] +module = ["elftools.*"] +ignore_missing_imports = true diff --git a/src/elfdeps/__main__.py b/src/elfdeps/__main__.py index 2477ea5..913177a 100644 --- a/src/elfdeps/__main__.py +++ b/src/elfdeps/__main__.py @@ -3,6 +3,7 @@ import argparse import pathlib import pprint +import typing from ._elfdeps import ELFDeps @@ -46,9 +47,16 @@ dest="require_interp", help="Include ELF interpreter name", ) +parser.add_argument( + "--unique", + "-u", + action="store_true", + dest="unique", + help="Remove duplicate entries", +) -def main(argv=None): +def main(argv: typing.Optional[typing.List[str]] = None) -> None: args = parser.parse_args(argv) e = ELFDeps( args.filename, @@ -56,6 +64,7 @@ def main(argv=None): fake_soname=args.fake_soname, filter_soname=args.filter_soname, require_interp=args.require_interp, + unique=args.unique, ) if args.provides: for p in e.info.provides: diff --git a/src/elfdeps/_elfdeps.py b/src/elfdeps/_elfdeps.py old mode 100755 new mode 100644 index 0a2b1ff..4d62e81 --- a/src/elfdeps/_elfdeps.py +++ b/src/elfdeps/_elfdeps.py @@ -11,6 +11,7 @@ import os import pathlib import stat +import typing from elftools.elf.constants import VER_FLAGS from elftools.elf.dynamic import DynamicSection @@ -18,7 +19,7 @@ from elftools.elf.gnuversions import GNUVerDefSection, GNUVerNeedSection -@dataclasses.dataclass(slots=True, frozen=True) +@dataclasses.dataclass(frozen=True, order=True) class SOInfo: """Shared object information""" @@ -38,16 +39,19 @@ def __repr__(self) -> str: @dataclasses.dataclass class ELFInfo: + # requires and provides are ordered by occurence in ELF metadata and + # can contain duplicate entries. The order can be different than output + # of elfdeps.c, but that usually does not matter. requires: list[SOInfo] provides: list[SOInfo] - machine: str | None = None + machine: typing.Optional[str] = None is_dso: bool = False is_exec: bool = False got_debug: bool = False got_hash: bool = False got_gnuhash: bool = False - soname: str | None = None - interp: str | None = None + soname: typing.Optional[str] = None + interp: typing.Optional[str] = None marker: str = "" @@ -79,6 +83,7 @@ def __init__( fake_soname: bool = True, filter_soname: bool = False, require_interp: bool = False, + unique: bool = True, ) -> None: if not isinstance(filename, pathlib.Path): raise TypeError(f"filename is not a pathlib.Path: {type(filename)}") @@ -87,11 +92,13 @@ def __init__( self.fake_soname = fake_soname self.filter_soname = filter_soname self.require_interp = require_interp + self.unique = unique self.info = ELFInfo( requires=[], provides=[], ) + self._seen: typing.Set[typing.Tuple[bool, SOInfo]] = set() with self.filename.open("rb") as f: self._process_file(f) @@ -137,23 +144,30 @@ def _process_file(self, f: io.BufferedReader) -> None: # direct add self.info.requires.append(SOInfo(self.info.interp, version="", marker="")) - def _add_soinfo(self, provides: bool, soname: str, version: str | None) -> bool: + def _add_soinfo( + self, provides: bool, soname: str, version: typing.Optional[str] + ) -> None: if skip_soname(soname, filter_soname=self.filter_soname): - return False + return version = version if version else "" marker = self.info.marker or "" soinfo = SOInfo(soname, version, marker) + + key = (provides, soinfo) + if self.unique and key in self._seen: + return + self._seen.add(key) + if provides: self.info.provides.append(soinfo) else: self.info.requires.append(soinfo) - return True - def add_provides(self, soname: str, version: str | None = None) -> bool: - return self._add_soinfo(True, soname, version) + def add_provides(self, soname: str, version: typing.Optional[str] = None) -> None: + self._add_soinfo(True, soname, version) - def add_requires(self, soname: str, version: str | None = None) -> bool: - return self._add_soinfo(False, soname, version) + def add_requires(self, soname: str, version: typing.Optional[str] = None) -> None: + self._add_soinfo(False, soname, version) @property def gen_requires(self) -> bool: @@ -200,7 +214,7 @@ def process_verdef(self, sec: GNUVerDefSection) -> None: processVerDef(Elf_Scn *scn, GElf_Shdr *shdr, elfInfo *ei) """ - soname: str | None = None + soname: typing.Optional[str] = None for verdef, vernaux in sec.iter_versions(): for aux in vernaux: if not aux.name: @@ -242,12 +256,13 @@ def process_dynamic(self, sec: DynamicSection) -> None: elif d_tag == "DT_NEEDED": self.add_requires(tag.needed) - def process_prog_headers(self, elffile: ELFFile) -> str | None: + def process_prog_headers(self, elffile: ELFFile) -> typing.Optional[str]: """Get interpreter from PT_INTERP segment void processProgHeaders(elfInfo *ei, GElf_Ehdr *ehdr) """ for seg in elffile.iter_segments("PT_INTERP"): - return seg.get_interp_name() + interp: str = seg.get_interp_name() + return interp else: return None diff --git a/tox.ini b/tox.ini index d2336a2..980ee1f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,24 +1,31 @@ # SPDX-License-Identifier: Apache-2.0 [tox] -envlist=py{9,10,11,12},lint +envlist=py3{9,10,11,12},lint [testenv] +package = wheel +wheel_build_env = pkg extras = test commands = python -m pytest tests [testenv:lint] -deps= +basepython = python3 +deps = ruff + mypy + pytest-stub commands = ruff check src tests ruff format --check src tests + mypy src tests skip_install = true skip_sdist = true [testenv:fix] -deps= +basepython = python3 +deps = ruff commands = ruff format src tests