blob: f51c4c40432a9c827efc7a18fb2f47de404dae3d [file] [log] [blame]
# Copyright 2023 The ChromiumOS Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Check whether a package links libraries not in RDEPEND.
If no argument is provided it will check all installed packages. It takes the
BOARD environment variable into account.
Example:
package_has_missing_deps.py --board=amd64-generic --match \
chromeos-base/cryptohome
"""
from __future__ import annotations
import argparse
import collections
import enum
import logging
import os
from pathlib import Path
import pprint
import re
import sys
from typing import (
Generic,
Iterable,
List,
NamedTuple,
Optional,
Set,
TypeVar,
Union,
)
from chromite.lib import build_target_lib
from chromite.lib import chroot_lib
from chromite.lib import commandline
from chromite.lib import cros_build_lib
from chromite.lib import portage_util
from chromite.lib.parser import package_info
from chromite.utils import pformat
VIRTUALS = {
"virtual/acl": ("sys-apps/acl", "media-libs/img-ddk-bin"),
"virtual/arc-opengles": (
"media-libs/arc-img-ddk",
"media-libs/arc-mesa-img",
"media-libs/arc-mali-drivers",
"media-libs/arc-mali-drivers-bifrost",
"media-libs/arc-mali-drivers-bifrost-bin",
"media-libs/arc-mali-drivers-valhall",
"media-libs/arc-mali-drivers-valhall-bin",
"media-libs/arc-mesa",
"media-libs/arc-mesa-amd",
"media-libs/arc-mesa-freedreno",
"media-libs/arc-mesa-iris",
"media-libs/arc-mesa-virgl",
"x11-drivers/opengles-headers",
),
"virtual/cros-camera-hal": (
"media-libs/cros-camera-hal-intel-ipu3",
"media-libs/cros-camera-hal-intel-ipu6",
"media-libs/cros-camera-hal-mtk",
"media-libs/cros-camera-hal-qti",
"media-libs/cros-camera-hal-rockchip-isp1",
"media-libs/cros-camera-hal-usb",
"media-libs/qti-7c-camera-tuning",
),
"virtual/img-ddk": ("media-libs/img-ddk", "media-libs/img-ddk-bin"),
"virtual/jpeg": ("media-libs/libjpeg-turbo", "media-libs/jpeg"),
"virtual/krb5": ("app-crypt/mit-krb5", "app-crypt/heimdal"),
"virtual/libcrypt": ("sys-libs/libxcrypt",),
"virtual/libelf": ("dev-libs/elfutils", "sys-freebsd/freebsd-lib"),
"virtual/libiconv": ("dev-libs/libiconv",),
"virtual/libintl": ("dev-libs/libintl",),
"virtual/libgudev": (
"dev-libs/libgudev",
"sys-apps/systemd",
"sys-fs/eudev",
"sys-fs/udev",
),
"virtual/libudev": (
"sys-apps/systemd-utils",
"sys-fs/udev",
"sys-fs/eudev",
"sys-apps/systemd",
),
"virtual/libusb": ("dev-libs/libusb", "sys-freebsd/freebsd-lib"),
"virtual/opengles": (
"media-libs/img-ddk",
"media-libs/img-ddk-bin",
"media-libs/libglvnd",
"media-libs/mali-drivers-bin",
"media-libs/mali-drivers-bifrost",
"media-libs/mali-drivers-bifrost-bin",
"media-libs/mali-drivers-valhall",
"media-libs/mali-drivers-valhall-bin",
"media-libs/mesa",
"media-libs/mesa-amd",
"media-libs/mesa-freedreno",
"media-libs/mesa-img",
"media-libs/mesa-iris",
"media-libs/mesa-llvmpipe",
"media-libs/mesa-panfrost",
"media-libs/mesa-reven",
"x11-drivers/opengles-headers",
),
"virtual/vulkan-icd": (
"media-libs/img-ddk",
"media-libs/img-ddk-bin",
"media-libs/mali-drivers-bifrost",
"media-libs/mali-drivers-bifrost-bin",
"media-libs/mali-drivers-valhall",
"media-libs/mali-drivers-valhall-bin",
"media-libs/mesa",
"media-libs/mesa-freedreno",
"media-libs/mesa-iris",
"media-libs/mesa-llvmpipe",
"media-libs/mesa-radv",
"media-libs/vulkan-loader",
),
}
class OutputFormat(enum.Enum):
"""Type for the requested output format."""
# Automatically determine the format based on what the user might want.
# This is PRETTY if attached to a terminal, RAW otherwise.
AUTO = enum.auto()
AUTOMATIC = AUTO
# Output packages one per line, suitable for mild scripting.
RAW = enum.auto()
# Suitable for viewing in a color terminal.
PRETTY = enum.auto()
T = TypeVar("T")
class ResultSet(Generic[T], NamedTuple):
"""Represent separate but related sets for the build target and sdk."""
target: Set[T]
sdk: Set[T]
class MissingDependencyDetails(NamedTuple):
"""Information about a package with missing dependencies."""
package: str
unsatisfied_libs: List[str]
unsatisfied_sdk_libs: List[str]
depend: List[str]
bdepend: List[str]
def is_debug_path(path: str) -> bool:
"""Match debug symbol paths."""
return path.startswith("/usr/lib/debug/")
def is_sdk_path(path: str) -> bool:
"""Match paths for files built for the SDK/builder."""
return bool(
re.match(
"|".join(
(
r"/usr/src/chromeos-kernel-[^/]+/build",
r"/build/bin",
r"/build/libexec",
)
),
path,
)
)
def is_guest_os_path(path: str) -> bool:
"""Match paths belonging to a guest OS."""
return bool(
re.match(
r"/opt/google/(?:vms|containers)",
path,
)
)
class DotSoResolver:
"""Provides shared library related dependency operations."""
def __init__(
self,
board: Optional[str] = None,
root: Union[os.PathLike, str] = "/",
chroot: Optional[chroot_lib.Chroot] = None,
) -> None:
self.board = board
self.chroot = chroot if chroot else chroot_lib.Chroot()
self.sdk_db = portage_util.PortageDB()
self._sdk_db_packges = None
self.db = self.sdk_db if root == "/" else portage_util.PortageDB(root)
self._db_packges = None
self.provided_libs_cache = {}
# Lazy initialize since it might not be needed.
self.lib_to_package_map = None
self.sdk_lib_to_package_map = None
@property
def sdk_db_packages(self):
"""Cache sdk_db.InstalledPackages().
We won't be modifying it, so it's safe for us to reuse the results.
"""
if self._sdk_db_packges is None:
self._sdk_db_packges = self.sdk_db.InstalledPackages()
return self._sdk_db_packges
@property
def db_packages(self):
"""Cache db.InstalledPackages().
We won't be modifying it, so it's safe for us to reuse the results.
"""
if self._db_packges is None:
self._db_packges = self.db.InstalledPackages()
return self._db_packges
def get_packages(
self, query: str, from_sdk=False
) -> Iterable[portage_util.InstalledPackage]:
"""Find matching InstalledPackage(s) for the |query|."""
packages = self.sdk_db_packages if from_sdk else self.db_packages
info = package_info.parse(query)
for package in packages:
if info.package != package.package:
continue
if info.category != package.category:
continue
dep_info = package.package_info
if info.revision and info.revision != dep_info.revision:
continue
if info.pv and info.pv != dep_info.pv:
continue
logging.debug("query: %s: matched %s", query, dep_info.cpvr)
yield package
# TODO Re-enable the lint after we upgrade to Python 3.9 or later.
# pylint: disable-next=unsubscriptable-object
def get_required_libs(self, package) -> ResultSet[str]:
"""Return sets of required .so files for the target and the SDK."""
sdk = set()
target = set()
needed = package.needed
if needed is not None:
for file, libs in needed.items():
if is_debug_path(file):
continue
if is_sdk_path(file):
sdk.update(libs)
elif not is_guest_os_path(file):
target.update(libs)
return ResultSet(target, sdk)
# TODO Re-enable the lint after we upgrade to Python 3.9 or later.
# pylint: disable-next=unsubscriptable-object
def get_deps(
self, package: portage_util.InstalledPackage
) -> ResultSet[portage_util.InstalledPackage]:
"""Returns two lists of dependencies.
This expands the virtuals listed in VIRTUALS.
"""
cpvr = f"{package.category}/{package.pf}"
# Handling ||() nodes is difficult. Be lazy and expand all of them.
# We could compare against the installed db to try and find a match,
# but this seems easiest for now as our PortageDB API doesn't support
# these kind of primitives yet.
def _anyof_reduce(choices: List[str]) -> str:
"""Reduce ||() nodes."""
def _flatten(eles):
for e in eles:
if isinstance(e, tuple):
yield from _flatten(e)
else:
yield e
citer = _flatten(choices)
ret = next(citer)
package_dependencies.extend(citer)
return ret
package_dependencies = []
package_dependencies.extend(
package.depend.reduce(anyof_reduce=_anyof_reduce)
)
package_dependencies.extend(
package.rdepend.reduce(anyof_reduce=_anyof_reduce)
)
package_build_dependencies = []
package_build_dependencies.extend(
package.bdepend.reduce(anyof_reduce=_anyof_reduce)
)
def _clean_deps(raw_deps: List[str], from_sdk=False) -> Set[str]:
deps = set()
expanded = []
for fulldep in raw_deps:
# Preclean the atom. We can only handle basic forms like
# CATEGORY/PF, not the full dependency specification. See the
# ebuild(5) man page for more details.
dep = fulldep
# Ignore blockers.
if dep.startswith("!"):
logging.debug("%s: ignoring blocker: %s", cpvr, dep)
continue
# Rip off the SLOT spec.
dep = dep.split(":", 1)[0]
# Rip off any USE flag constraints.
dep = dep.split("[", 1)[0]
# Trim leading & trailing version ranges.
dep = dep.lstrip("<>=~").rstrip("*")
logging.debug(
"%s: found package dependency: %s -> %s", cpvr, fulldep, dep
)
info = package_info.parse(dep)
if not info:
continue
cp = info.cp
if cp in VIRTUALS:
expanded += VIRTUALS[cp]
continue
pkgs = (
self.sdk_db if from_sdk else self.db
).GetInstalledPackage(info.category, info.pvr)
if not pkgs:
pkgs = list(self.get_packages(info.atom, from_sdk))
else:
pkgs = [pkgs]
if pkgs:
deps.update(pkgs)
else:
logging.warning(
"%s: could not find installed %s", cpvr, dep
)
for dep in expanded:
deps.update(self.get_packages(dep))
return deps
return ResultSet(
target=_clean_deps(package_dependencies),
sdk=_clean_deps(package_build_dependencies, from_sdk=True),
)
def get_implicit_libs(self):
"""Return a set of .so files that are provided by the system."""
# libstdc++ comes from the toolchain so always ignore it.
implicit_libs = {"libstdc++.so", "libstdc++.so.6"}
for dep, from_sdk in (
("cross-aarch64-cros-linux-gnu/glibc", True),
("cross-armv7a-cros-linux-gnueabihf/glibc", True),
("cross-i686-cros-linux-gnu/glibc", True),
("cross-x86_64-cros-linux-gnu/glibc", True),
("sys-libs/glibc", False),
("sys-libs/libcxx", False),
("sys-libs/llvm-libunwind", False),
):
for pkg in self.get_packages(dep, from_sdk):
implicit_libs.update(self.provided_libs(pkg))
return implicit_libs
def provided_libs(self, package: portage_util.InstalledPackage) -> Set[str]:
"""Return a set of .so files provided by |package|."""
cpvr = f"{package.category}/{package.pf}"
if cpvr in self.provided_libs_cache:
return self.provided_libs_cache[cpvr]
libs = set()
contents = package.ListContents()
# Keep only the .so files
for typ, path in contents:
if typ == package.DIR:
continue
filename = os.path.basename(path)
if filename.endswith(".so") or (
".so." in filename and not filename.endswith(".debug")
):
libs.add(filename)
self.provided_libs_cache[cpvr] = libs
return libs
def cache_libs_from_build(
self, package: portage_util.InstalledPackage, image_dir: Path
) -> None:
"""Populate the provided_libs_cache for the package from the image dir.
When using build-info, CONTENTS might not be available yet. so provide
alternative using the destination directory of the ebuild.
"""
cpvr = f"{package.category}/{package.pf}"
libs = set()
for _, _, files in os.walk(image_dir):
for file in files:
if file.endswith(".so") or (
".so." in file and not file.endswith(".debug")
):
libs.add(os.path.basename(file))
self.provided_libs_cache[cpvr] = libs
# TODO Re-enable the lint after we upgrade to Python 3.9 or later.
# pylint: disable-next=unsubscriptable-object
def get_provided_from_all_deps(
self, package: portage_util.InstalledPackage
) -> ResultSet[str]:
"""Return sets of .so files provided by the immediate dependencies."""
def _expand_to_libs(
packages: Set[portage_util.InstalledPackage],
) -> Set[str]:
provided_libs = set()
# |package| may not actually be installed yet so manually add it too
# since a package can depend on its own libs.
provided_libs.update(self.provided_libs(package))
for pkg in packages:
logging.debug(
"%s: loading libs from dependency %s",
package.package_info.cpvr,
pkg.package_info.cpvr,
)
provided_libs.update(self.provided_libs(pkg))
return provided_libs
deps, sdk_deps = self.get_deps(package)
return ResultSet(
target=_expand_to_libs(deps), sdk=_expand_to_libs(sdk_deps)
)
def lib_to_package(
self, lib_filename: str = None, from_sdk=False
) -> Set[str]:
"""Return a set of packages that contain the library."""
lookup = (
self.sdk_lib_to_package_map if from_sdk else self.lib_to_package_map
)
if lookup is None:
lookup = collections.defaultdict(set)
for pkg in (
self.sdk_db.InstalledPackages()
if from_sdk
else self.db.InstalledPackages()
):
cpvr = f"{pkg.category}/{pkg.pf}"
# Packages with bundled libs for internal use and/or standaline
# binary packages.
if f"{pkg.category}/{pkg.package}" in (
"chromeos-base/aosp-frameworks-ml-nn-vts",
"chromeos-base/factory",
"sys-devel/gcc-bin",
):
continue
for lib in set(self.provided_libs(pkg)):
lookup[lib].add(cpvr)
if self.board is None:
self.sdk_lib_to_package_map = lookup
self.lib_to_package_map = lookup
elif from_sdk:
self.sdk_lib_to_package_map = lookup
else:
self.lib_to_package_map = lookup
if not lib_filename:
return set()
try:
return lookup[lib_filename]
except KeyError:
return set()
def get_parser() -> commandline.ArgumentParser:
"""Build the argument parser."""
parser = commandline.ArgumentParser(description=__doc__)
parser.add_argument("package", nargs="*", help="package atom")
parser.add_argument(
"-b",
"--board",
"--build-target",
default=cros_build_lib.GetDefaultBoard(),
help="ChromeOS board (Uses the SDK if not specified)",
)
parser.add_argument(
"--no-default-board",
dest="board",
const=None,
action="store_const",
help="Ignore the default board",
)
parser.add_argument(
"-i",
"--build-info",
default=None,
type=Path,
help="Path to build-info folder post src_install",
)
parser.add_argument(
"-x",
"--image",
default=None,
type=Path,
help="Path to image folder post src_install (${D} if unspecified)",
)
parser.add_argument(
"--match",
default=False,
action="store_true",
help="Try to match missing libraries",
)
parser.add_argument(
"-j",
"--jobs",
default=None,
type=int,
help="Number of parallel processes",
)
parser.set_defaults(format=OutputFormat.AUTO)
parser.add_argument(
"--format",
action="enum",
enum=OutputFormat,
help="Output format to use.",
)
return parser
def parse_arguments(argv: List[str]) -> argparse.Namespace:
"""Parse and validate arguments."""
parser = get_parser()
opts = parser.parse_args(argv)
if opts.build_info and opts.package:
parser.error("Do not specify a package when setting --board-info")
if opts.image and not opts.build_info:
parser.error("--image requires --board-info")
if opts.build_info or len(opts.package) == 1:
opts.jobs = 1
if opts.format is OutputFormat.AUTO:
if sys.stdout.isatty():
opts.format = OutputFormat.PRETTY
else:
opts.format = OutputFormat.RAW
return opts
def check_package(
package: portage_util.InstalledPackage,
implicit: Set[str],
resolver: DotSoResolver,
match: bool,
) -> Optional[MissingDependencyDetails]:
"""Returns false if the package has missing dependencies"""
if not package:
print("Package not installed")
return None
provided, sdk_provided = resolver.get_provided_from_all_deps(package)
logging.debug("provided: %s", pprint.pformat(sorted(provided)))
available = provided.union(implicit)
sdk_available = sdk_provided.union(implicit)
required, sdk_required = resolver.get_required_libs(package)
logging.debug("required: %s", pprint.pformat(sorted(required)))
unsatisfied = sorted(required - available)
sdk_unsatisfied = sorted(sdk_required - sdk_available)
details = {
"package": package.package_info.cpvr,
"unsatisfied_libs": unsatisfied,
"unsatisfied_sdk_libs": sdk_unsatisfied,
"depend": [],
"bdepend": [],
}
if match:
missing = set()
for lib in unsatisfied:
missing.update(resolver.lib_to_package(lib, from_sdk=False))
details["depend"] = sorted(missing)
missing = set()
for lib in sdk_unsatisfied:
missing.update(resolver.lib_to_package(lib, from_sdk=True))
details["bdepend"] = sorted(missing)
return (
MissingDependencyDetails(**details)
if unsatisfied or sdk_unsatisfied
else None
)
def pretty_print(details: MissingDependencyDetails) -> None:
"""Handle --format=pretty"""
if details.unsatisfied_libs:
print(
f"'{details.package}': Package is linked against libraries that "
"are not listed as dependencies in the ebuild:"
)
pprint.pprint(details.unsatisfied_libs)
if details.depend:
print(
f"'{details.package}': needs the following added to DEPEND/RDEPEND:"
)
pprint.pprint(details.depend)
if details.unsatisfied_sdk_libs:
print(
f"'{details.package}': Package is linked against sdk libraries "
"that are not listed as build dependencies in the ebuild:"
)
pprint.pprint(details.unsatisfied_sdk_libs)
if details.bdepend:
print(f"'{details.package}': needs the following added to BDEPEND:")
pprint.pprint(details.bdepend)
def raw_print(details: MissingDependencyDetails) -> None:
"""Handle --format=raw"""
pformat.json(details._asdict(), fp=sys.stdout, compact=True)
print()
def main(argv: Optional[List[str]]) -> None:
"""Main."""
commandline.RunInsideChroot()
opts = parse_arguments(argv)
opts.Freeze()
board = opts.board
root = build_target_lib.get_default_sysroot_path(board)
if board:
os.environ["PORTAGE_CONFIGROOT"] = root
os.environ["SYSROOT"] = root
os.environ["ROOT"] = root
failed = False
resolver = DotSoResolver(board, root)
if not opts.package:
if opts.build_info:
pkg = portage_util.InstalledPackage(resolver.db, opts.build_info)
image_path = opts.image or os.environ.get("D")
if image_path:
resolver.cache_libs_from_build(pkg, Path(image_path))
packages = [pkg]
else:
packages = resolver.db.InstalledPackages()
else:
packages = []
for pkg in opts.package:
packages.extend(resolver.get_packages(pkg))
implicit = resolver.get_implicit_libs()
if opts.match:
# Pre initialize the map before starting jobs.
resolver.lib_to_package()
for package in packages:
details = check_package(
package,
implicit,
resolver,
opts.match,
)
if details:
failed = True
if opts.format == OutputFormat.PRETTY:
pretty_print(details)
else:
raw_print(details)
if failed:
if opts.format == OutputFormat.PRETTY:
print(
"""\
For more information about DEPEND vs. RDEPEND in ebuilds see:
https://www.chromium.org/chromium-os/developer-library/guides/portage/\
ebuild-faq/#what-are-depend-bdepend-and-rdepend"""
)
sys.exit(1)
if __name__ == "__main__":
main(sys.argv[1:])