Files
plezy/scripts/check_linux_package_deps.py
T
2026-08-10 20:28:41 +02:00

246 lines
9.4 KiB
Python

#!/usr/bin/env python3
"""Guard distro dependencies for host libraries linked by the Linux runner.
The runner's CMake link graph maps through pkg-config modules to the hand-maintained
package lists. This catches undeclared runtime libraries before they fail on users'
machines; the built-bundle check covers plugin links unavailable before `pub get`.
"""
from pathlib import Path
import ast
import re
import sys
ROOT = Path(__file__).resolve().parents[1]
if len(sys.argv) > 2:
raise SystemExit(f"Usage: {Path(sys.argv[0]).name} [linux-dir]")
LINUX = Path(sys.argv[1]).resolve() if len(sys.argv) == 2 else ROOT / "linux"
RUNNER_CMAKE = LINUX / "runner/CMakeLists.txt"
PACKAGES_PY = LINUX / "packaging/build-packages.py"
BUNDLE_SH = LINUX / "packaging/bundle-libs.sh"
# Runner-linked pkg_check_modules may be declared in any of these files.
CMAKE_FILES = (RUNNER_CMAKE, LINUX / "CMakeLists.txt", LINUX / "flutter/CMakeLists.txt")
# Bundled modules have no host dependency; their excluded runtime links are
# checked separately against the built bundle.
BUNDLED_MODULES = {"mpv"}
# pkg-config module -> runtime package name for each distro.
RUNTIME_PACKAGES = {
"gtk+-3.0": {"deb": "libgtk-3-0", "rpm": "gtk3", "pacman": "gtk3"},
"epoxy": {"deb": "libepoxy0", "rpm": "libepoxy", "pacman": "libepoxy"},
# Reached through the `flutter` INTERFACE target.
"glib-2.0": {"deb": "libglib2.0-0", "rpm": "glib2", "pacman": "glib2"},
"gio-2.0": {"deb": "libglib2.0-0", "rpm": "glib2", "pacman": "glib2"},
"wayland-client": {
"deb": "libwayland-client0",
"rpm": "libwayland-client",
# Arch ships all libwayland-* libraries in `wayland`.
"pacman": "wayland",
},
"wayland-egl": {
"deb": "libwayland-egl1",
"rpm": "libwayland-egl",
"pacman": "wayland",
},
# libglvnd provides libEGL.so.1.
"egl": {"deb": "libegl1", "rpm": "libglvnd-egl", "pacman": "libglvnd"},
}
errors: list[str] = []
def require(condition: bool, message: str) -> None:
if not condition:
errors.append(message)
def read(path: Path) -> str:
try:
return path.read_text(encoding="utf-8")
except OSError as error:
errors.append(f"{path}: cannot read: {error}")
return ""
LINK_KEYWORDS = {"PRIVATE", "PUBLIC", "INTERFACE", "optimized", "debug", "general"}
# Link keywords and pkg_check_modules options are not module names.
PKG_OPTIONS = ("REQUIRED", "QUIET", "GLOBAL", "NO_CMAKE_PATH", "NO_CMAKE_ENVIRONMENT_PATH")
def strip_comments(text: str) -> str:
"""A `#` comment containing `)` would otherwise truncate a call body.
That is the fail-open direction: every target after the comment vanishes and
the guard still exits 0, which is the whole bug class it exists to catch.
"""
return re.sub(r"#[^\n]*", "", text)
def link_token(raw: str) -> str:
"""`$<LINK_ONLY:PkgConfig::X>` and `"PkgConfig::X"` both name PkgConfig::X."""
return re.sub(r"^\$<[^:]*:", "", raw.strip('"')).rstrip(">")
def link_graph(text: str) -> dict[str, list[str]]:
"""target -> everything target_link_libraries() gives it, in order."""
graph: dict[str, list[str]] = {}
for match in re.finditer(r"target_link_libraries\(\s*([^\s)]+)\s*([^)]*)\)", strip_comments(text)):
name = match.group(1).replace("${BINARY_NAME}", "BINARY")
tokens = [link_token(t) for t in match.group(2).split()]
graph.setdefault(name, []).extend(t for t in tokens if t not in LINK_KEYWORDS)
return graph
def linked_pkgconfig_targets(text: str) -> set[str]:
"""Every PkgConfig:: target that reaches the runner's link line.
A library hands its dependencies to whatever links it - CMake puts even
PRIVATE ones of a static library on the consumer's link line, and an
INTERFACE target exists only to propagate them - so an internal target has to
be followed rather than treated as a leaf. `wayland_protocols PUBLIC
PkgConfig::WAYLAND_CLIENT` and `flutter INTERFACE PkgConfig::GTK` are both
invisible otherwise, the latter across a file boundary.
"""
graph = link_graph(text)
targets: set[str] = set()
seen: set[str] = set()
queue = ["BINARY"]
while queue:
current = queue.pop()
if current in seen:
continue
seen.add(current)
for token in graph.get(current, []):
if token.startswith("PkgConfig::"):
targets.add(token[len("PkgConfig::") :])
elif token in graph:
queue.append(token)
return targets
def pkgconfig_modules() -> dict[str, list[str]]:
"""CMake variable prefix -> every pkg-config module the call names.
A single call may name several - `pkg_check_modules(X REQUIRED
IMPORTED_TARGET a b c)` makes one PkgConfig::X that links all three - and
taking only the first is the fail-open direction: the extra libraries reach
the binary while the guard reports a clean run. wayland-cursor and
xkbcommon are the natural companions of a subsurface and grouping them into
the existing call is the natural way to add them, so this is the next edit
to this file rather than a hypothetical.
"""
modules: dict[str, list[str]] = {}
options = "|".join(PKG_OPTIONS)
for path in CMAKE_FILES:
for match in re.finditer(
# Skip options before module names.
r"pkg_check_modules\(\s*(\w+)\b[^)]*?IMPORTED_TARGET\s+((?:(?:" + options + r")\s+)*[^)]*)\)",
strip_comments(read(path)),
):
# Strip version constraints before package lookup.
names = [
re.split(r"[<>=!]", t, maxsplit=1)[0] for t in match.group(2).split() if t not in PKG_OPTIONS
]
names = [n for n in names if n]
if names:
modules.setdefault(match.group(1), names)
return modules
def declared_depends() -> dict[str, list[str]]:
"""distro -> depends list, read from the DISTROS literal by AST."""
tree = ast.parse(read(PACKAGES_PY), filename=str(PACKAGES_PY))
for node in tree.body:
if not isinstance(node, ast.Assign):
continue
if not any(isinstance(t, ast.Name) and t.id == "DISTROS" for t in node.targets):
continue
table = ast.literal_eval(node.value)
return {name: list(config.get("depends", [])) for name, config in table.items()}
errors.append(f"{PACKAGES_PY}: no DISTROS assignment to read the depends lists from")
return {}
# Include every owned CMake input; missing files fail closed instead of shrinking
# the graph and silently passing.
absent = [path for path in CMAKE_FILES if not path.is_file()]
if absent:
for path in absent:
print(
f"ERROR: {path}: expected CMake input is missing, so the dependency walk "
"would silently cover less than it claims",
file=sys.stderr,
)
sys.exit(1)
cmake_text = "\n".join(read(path) for path in CMAKE_FILES)
modules = pkgconfig_modules()
depends = declared_depends()
require(bool(depends), "no distro depends lists were found, so nothing was checked")
# These libraries must remain host-provided; bundling them would invalidate this guard.
bundle = read(BUNDLE_SH)
for pattern in (r"libEGL\.so", r"libwayland.*\.so"):
require(
pattern in bundle,
f"bundle-libs.sh no longer excludes {pattern}: if those are bundled now, "
"the depends entries this guard demands may be wrong",
)
linked = linked_pkgconfig_targets(cmake_text)
require(
bool(linked),
"found no PkgConfig:: link reaching ${BINARY_NAME}; the link-line parse is broken, not the build",
)
checked_modules = 0
for target in sorted(linked):
target_modules = modules.get(target)
if not target_modules:
errors.append(
f"PkgConfig::{target} is linked into the runner but no pkg_check_modules "
f"declares it in {', '.join(p.name for p in CMAKE_FILES)}"
)
continue
for module in target_modules:
checked_modules += 1
if module in BUNDLED_MODULES:
# Bundled modules have no host dependency but remain part of the summary.
continue
packages = RUNTIME_PACKAGES.get(module)
if packages is None:
errors.append(
f"pkg-config module '{module}' (PkgConfig::{target}) is linked into the runner "
f"but has no entry in RUNTIME_PACKAGES: name the package that ships its "
f"runtime library on each distro, then declare it in {PACKAGES_PY.name}"
)
continue
for distro, declared in sorted(depends.items()):
package = packages.get(distro)
if package is None:
errors.append(
f"RUNTIME_PACKAGES['{module}'] has no '{distro}' package name, so the "
f"{distro} package cannot declare a library the runner links"
)
continue
require(
package in declared,
f"the runner links {module} but the {distro} package does not depend on "
f"'{package}'; bundle-libs.sh will not bundle it, so an installed package "
f"can fail to start",
)
if errors:
for error in errors:
print(f"ERROR: {error}", file=sys.stderr)
sys.exit(1)
print(
f"linux/runner CMake dependency checks passed ({len(linked)} pkg-config links, {checked_modules} modules); "
"Flutter plugin links are out of reach here - check-bundle-host-deps.py covers those from the built bundle"
)