Files
plezy/scripts/test_check_bundle_host_deps.py
T
edde746 e9a213807f ci(linux): check the runner's libraries reach the package metadata
The plane added three runtime libraries that bundle-libs.sh deliberately does not
bundle, so they have to be declared per distro by hand - and two hand-maintained
lists drifting apart is the failure this guard exists to prevent.

check_linux_package_deps.py parses the runner's CMake for every pkg-config module
it links, follows target_link_libraries to prove each one actually reaches the
binary, and requires a package name for it in every distro's depends list. It
fails closed on the shapes a naive parser gets wrong: a pkg_check_modules call
naming several modules, options preceding the module name, and version
constraints like mpv>=0.40 that would otherwise be read as a package nobody
ships.

The smoke job builds the three packages and reads the dependencies back out of
the artifacts, deriving what to expect from build-packages.py rather than
restating it - so a library is declared once and verified everywhere. That job is
off by default, which is exactly why it must not carry its own copy of the list.

The Linux native job names libwayland-dev and libegl-dev instead of riding
GTK's and epoxy's transitive dev dependencies, matching the CMake comment's own
rationale. In CI the host-dependency guard runs once: the named step covers the
staged bundle, and build-packages.py's internal run - which exists for by-hand
packaging - is skipped. The smoke job also drops patchelf, which nothing
invokes.
2026-08-10 08:48:14 +02:00

431 lines
19 KiB
Python

#!/usr/bin/env python3
"""Behavior tests for the bundle host dependency guard.
The nested-module case reproduces the bug that motivated them: bundle-libs.sh
installs the dlopen'd gdk-pixbuf loaders and GIO modules into lib/
subdirectories, and the guard scanned lib/*.so* only - so those modules counted
as bundled while their own host dependencies never reached ldd, and the check
reported success over exactly the undeclared library it exists to catch.
ldd and dpkg-query only answer on a Debian-family machine, so the checker
resolves both through PLEZY_HOST_TOOLS and the fixtures here install stubs
there. That keeps "a bundle that needs libpng16" a fixture rather than a
machine, and keeps the tests exercising the real script end to end.
"""
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
CHECKER = ROOT / "linux/packaging/check-bundle-host-deps.py"
BUILD_PACKAGES = ROOT / "linux/packaging/build-packages.py"
# A host library whose deb owner and rpm/pacman names build-packages.py all
# declare, so its presence alone never fails a fixture.
DECLARED_SONAME = "libepoxy.so.0"
DECLARED_PATH = "/usr/lib/x86_64-linux-gnu/libepoxy.so.0"
DECLARED_OWNER = "libepoxy0"
# One nothing declares. Chosen from the gdk-pixbuf loaders' real dependencies,
# which is how this class of miss actually reaches a user.
UNDECLARED_SONAME = "libpng16.so.16"
UNDECLARED_PATH = "/usr/lib/x86_64-linux-gnu/libpng16.so.16"
UNDECLARED_OWNER = "libpng16-16"
PIXBUF_LOADER = "lib/gdk-pixbuf-2.0/2.10.0/loaders/libpixbufloader-png.so"
# Both stubs answer from the JSON the fixture writes. Keeping them as scripts
# rather than mocks means the checker really forks, parses real ldd-shaped
# output and really fails on the exit status it would see in CI.
LDD_STUB = r"""import json, os, sys
answers = json.loads(open(os.environ["PLEZY_TEST_ANSWERS"], encoding="utf-8").read())
for line in answers["ldd"].get(os.path.basename(sys.argv[1]), []):
print("\t" + line)
"""
DPKG_QUERY_STUB = r"""import json, os, sys
answers = json.loads(open(os.environ["PLEZY_TEST_ANSWERS"], encoding="utf-8").read())
if sys.argv[1] == "-S":
owner = answers["owners"].get(sys.argv[2], "")
if not owner:
sys.stderr.write("dpkg-query: no path found matching pattern %s\n" % sys.argv[2])
sys.exit(1)
print("%s: %s" % (owner, sys.argv[2]))
else:
# dpkg-query -W -f ${Provides} <package>
sys.stdout.write(answers["provides"].get(sys.argv[-1], ""))
"""
def install_stub(scripts: Path, tools: Path, name: str, source: str) -> None:
"""Put a fake `name` where the checker's PLEZY_HOST_TOOLS lookup will find it.
The stub itself is Python; the file the checker spawns has to be something
the platform can execute directly, so it gets a wrapper. The two live in
different directories because a Windows PATHEXT search would otherwise be
free to pick the .py over the .bat.
"""
stub = scripts / f"{name}.py"
stub.write_text(source, encoding="utf-8")
if os.name == "nt":
(tools / f"{name}.bat").write_text(f'@echo off\r\n"{sys.executable}" "{stub}" %*\r\n', encoding="utf-8")
return
wrapper = tools / name
wrapper.write_text(f'#!/bin/sh\nexec "{sys.executable}" "{stub}" "$@"\n', encoding="utf-8")
wrapper.chmod(0o755)
def stage_bundle(
staging: Path,
ldd: dict[str, list[str]],
owners: dict[str, str],
shipped: tuple[str, ...] = (),
install: tuple[str, ...] = ("ldd", "dpkg-query"),
) -> tuple[Path, dict[str, str]]:
"""A synthetic bundle plus host-tool stubs, and the environment that finds them."""
bundle = staging / "bundle"
bundle.mkdir()
(bundle / "plezy").write_bytes(b"")
for name in shipped:
library = bundle / name
library.parent.mkdir(parents=True, exist_ok=True)
library.write_bytes(b"")
answers = staging / "answers.json"
answers.write_text(json.dumps({"ldd": ldd, "owners": owners, "provides": {}}), encoding="utf-8")
scripts, tools = staging / "stubs", staging / "tools"
scripts.mkdir()
tools.mkdir()
for name, source in (("ldd", LDD_STUB), ("dpkg-query", DPKG_QUERY_STUB)):
if name in install:
install_stub(scripts, tools, name, source)
return bundle, {
**os.environ,
"PLEZY_HOST_TOOLS": str(tools),
"PLEZY_TEST_ANSWERS": str(answers),
}
class BundleHostDepsGuardTest(unittest.TestCase):
def _check(
self,
ldd: dict[str, list[str]],
owners: dict[str, str],
shipped: tuple[str, ...] = (),
cwd: Path | None = None,
install: tuple[str, ...] = ("ldd", "dpkg-query"),
path: str | None = None,
) -> subprocess.CompletedProcess[str]:
"""Stage a synthetic bundle plus host-tool stubs and run the real checker."""
with tempfile.TemporaryDirectory(prefix="plezy-bundle-deps-test-") as directory:
bundle, env = stage_bundle(Path(directory), ldd, owners, shipped, install)
if path is not None:
env["PATH"] = path
return subprocess.run(
[sys.executable, str(CHECKER), str(bundle)],
cwd=cwd or ROOT,
check=False,
capture_output=True,
text=True,
env=env,
)
def test_a_bundle_whose_host_libraries_are_all_declared_passes(self) -> None:
result = self._check(
ldd={
"plezy": [
# No "=>" on this one: it must not be read as a soname.
"linux-vdso.so.1 (0x00007ffd1b3fe000)",
f"{DECLARED_SONAME} => {DECLARED_PATH} (0x00007f9c2c000000)",
"libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f9c2b800000)",
# Resolved from the host copy, but the bundle ships its own.
"libmpv.so.2 => /usr/lib/x86_64-linux-gnu/libmpv.so.2 (0x00007f9c2b400000)",
],
"libgiognutls.so": [f"{DECLARED_SONAME} => {DECLARED_PATH} (0x00007f9c2c000000)"],
},
owners={DECLARED_PATH: DECLARED_OWNER},
shipped=("lib/libmpv.so.2", "lib/gio/modules/libgiognutls.so"),
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("every one of the 1 host libraries", result.stdout)
def test_an_undeclared_host_library_is_named(self) -> None:
result = self._check(
ldd={"plezy": [f"{UNDECLARED_SONAME} => {UNDECLARED_PATH} (0x00007f9c2c000000)"]},
owners={UNDECLARED_PATH: UNDECLARED_OWNER},
)
self.assertEqual(result.returncode, 1, result.stdout)
self.assertIn(UNDECLARED_SONAME, result.stderr)
self.assertIn(f"deb package '{UNDECLARED_OWNER}'", result.stderr)
def test_a_library_declared_for_deb_but_not_for_rpm_is_still_rejected(self) -> None:
"""Every other negative fixture fails on the Debian arm first.
Fedora and Arch names cannot be resolved on this runner, so they are the
half most likely to be forgotten - and a package that installs on Fedora
and then cannot start is exactly as broken as one that fails on Debian.
libGLESv2 is the live example: it is mapped to libglvnd-gles, which is
deliberately not declared because nothing links GLES today, so the moment
something does the guard has to say so rather than wave it through on the
strength of a satisfied deb dependency.
"""
soname = "libGLESv2.so.2"
path = f"/usr/lib/x86_64-linux-gnu/{soname}"
result = self._check(
ldd={"plezy": [f"{soname} => {path} (0x00007f9c2c000000)"]},
# Owned by a package the deb list does declare, so only the non-deb
# half can be what rejects this.
owners={path: "libegl1"},
)
self.assertEqual(result.returncode, 1, result.stdout)
self.assertIn("libglvnd-gles", result.stderr)
self.assertIn("rpm", result.stderr)
# Proving the deb arm was satisfied, so the rejection came from the other.
self.assertNotIn("comes from deb package", result.stderr)
def test_a_walk_that_finds_nothing_is_not_a_pass(self) -> None:
"""An empty result means the walk failed, not that nothing is needed.
bundle-libs.sh always leaves the graphics stack to the host, so a real
bundle cannot need zero host libraries. Reporting success here would make
every later breakage invisible, which is the worst thing a guard can do.
"""
result = self._check(ldd={"plezy": []}, owners={})
self.assertEqual(result.returncode, 1, result.stdout)
self.assertIn("proved nothing", result.stderr)
def test_a_library_reached_only_through_a_nested_module_is_still_checked(self) -> None:
"""The gdk-pixbuf loaders sit two directories below lib/, and dlopen finds them.
Nothing else in the bundle links libpng, so a scan that stops at
lib/*.so* sees a fully declared bundle and a user sees the loader fail.
"""
result = self._check(
ldd={
"plezy": [f"{DECLARED_SONAME} => {DECLARED_PATH} (0x00007f9c2c000000)"],
"libpixbufloader-png.so": [f"{UNDECLARED_SONAME} => {UNDECLARED_PATH} (0x00007f9c2b000000)"],
},
owners={DECLARED_PATH: DECLARED_OWNER, UNDECLARED_PATH: UNDECLARED_OWNER},
shipped=(PIXBUF_LOADER,),
)
self.assertEqual(result.returncode, 1, result.stdout)
self.assertIn(UNDECLARED_SONAME, result.stderr)
def test_a_nested_module_cannot_satisfy_a_top_level_dependency(self) -> None:
"""Only lib/ is on the loader path, so only lib/ can make a soname bundled.
plezy carries RPATH $ORIGIN/lib and plezy.sh exports $INSTALL_DIR/lib.
The gdk-pixbuf loaders two directories below are opened by explicit
path and resolve nothing for the executable, so a module whose basename
happens to equal a host soname must not suppress it - otherwise the
package under-declares and the check still passes.
"""
result = self._check(
ldd={
"plezy": [
f"{DECLARED_SONAME} => {DECLARED_PATH} (0x00007f9c2c000000)",
f"{UNDECLARED_SONAME} => {UNDECLARED_PATH} (0x00007f9c2b000000)",
]
},
owners={DECLARED_PATH: DECLARED_OWNER, UNDECLARED_PATH: UNDECLARED_OWNER},
shipped=(f"lib/gdk-pixbuf-2.0/2.10.0/loaders/{UNDECLARED_SONAME}",),
)
self.assertEqual(result.returncode, 1, result.stdout)
self.assertIn(UNDECLARED_SONAME, result.stderr)
self.assertIn(f"deb package '{UNDECLARED_OWNER}'", result.stderr)
def test_a_soname_the_bundle_ships_is_not_a_missing_dependency(self) -> None:
"""ldd on a bundled library in isolation cannot see its siblings.
Bundled objects carry no RUNPATH - bundle-libs.sh copies and strips - so
a library that exists nowhere but the bundle reads as `not found` when
ldd is pointed at one of them directly. At runtime the executable's own
$ORIGIN/lib resolves it. libshaderc_shared is the real instance: no
distro package ships it, which is why both workflows copy it by hand, so
faulting it here would fail the release for a library that is present.
"""
result = self._check(
ldd={
"plezy": [f"{DECLARED_SONAME} => {DECLARED_PATH} (0x00007f9c2c000000)"],
# The bundled libmpv needs a bundled shaderc, and sees nothing.
"libmpv.so.2": ["libshaderc_shared.so.1 => not found"],
},
owners={DECLARED_PATH: DECLARED_OWNER},
shipped=("lib/libmpv.so.2", "lib/libshaderc_shared.so.1"),
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertNotIn("resolves to nothing", result.stderr)
def test_a_library_that_resolves_to_nothing_fails(self) -> None:
"""`=> not found` is the failure this check exists to prevent, already happened."""
result = self._check(
ldd={"libplezyextra.so.1": ["libfoo.so.1 => not found"]},
owners={},
shipped=("lib/libplezyextra.so.1",),
)
self.assertEqual(result.returncode, 1, result.stdout)
self.assertIn("libfoo.so.1", result.stderr)
self.assertIn("resolves to nothing", result.stderr)
def test_the_checker_reads_build_packages_from_any_working_directory(self) -> None:
"""--root defaulted to cwd, so running the check from the build tree crashed."""
result = self._check(
ldd={"plezy": [f"{DECLARED_SONAME} => {DECLARED_PATH} (0x00007f9c2c000000)"]},
owners={DECLARED_PATH: DECLARED_OWNER},
cwd=Path(tempfile.gettempdir()),
)
self.assertEqual(result.returncode, 0, result.stderr)
def test_a_host_without_dpkg_query_fails_instead_of_passing(self) -> None:
"""Fedora and Arch have no dpkg-query, so the check cannot run there.
Every answer it gives comes from ldd and dpkg-query. Missing one, the
walk would find nothing and report a bundle needing nothing from the
host - so the absence has to be an error the reader can act on, never a
skip and never a traceback.
"""
result = self._check(
ldd={"plezy": [f"{DECLARED_SONAME} => {DECLARED_PATH} (0x00007f9c2c000000)"]},
owners={DECLARED_PATH: DECLARED_OWNER},
install=("ldd",),
path="",
)
self.assertEqual(result.returncode, 1, result.stdout)
self.assertIn("dpkg-query not found", result.stderr)
self.assertIn("Debian or Ubuntu host", result.stderr)
self.assertNotIn("Traceback", result.stderr)
class BuildPackagesGuardWiringTest(unittest.TestCase):
"""build-packages.py is the only path a maintainer packaging by hand takes.
CI ran the guard as its own step, so the script itself never did, and its own
error message invited the standalone path that skipped it.
"""
def test_an_undeclared_host_library_stops_packaging(self) -> None:
with tempfile.TemporaryDirectory(prefix="plezy-packaging-test-") as directory:
staging = Path(directory)
bundle, env = stage_bundle(
staging,
ldd={"plezy": [f"{UNDECLARED_SONAME} => {UNDECLARED_PATH} (0x00007f9c2c000000)"]},
owners={UNDECLARED_PATH: UNDECLARED_OWNER},
shipped=("lib/libmpv.so.2",),
)
output = staging / "packages"
output.mkdir()
env |= {"BUILD_DIR": str(bundle), "OUTPUT_DIR": str(output)}
result = subprocess.run(
[sys.executable, str(BUILD_PACKAGES)],
cwd=ROOT,
check=False,
capture_output=True,
text=True,
env=env,
)
produced = sorted(path.name for path in output.iterdir())
self.assertEqual(result.returncode, 1, result.stdout)
self.assertIn(UNDECLARED_SONAME, result.stderr)
self.assertIn("no packages were built", result.stdout)
self.assertEqual(produced, [])
def test_the_opt_out_says_out_loud_what_it_costs(self) -> None:
"""The escape hatch for dpkg-query-less hosts must announce itself.
A skip nobody can see in the log is indistinguishable from the guard
having passed, which is the whole failure this wiring removes.
"""
with tempfile.TemporaryDirectory(prefix="plezy-packaging-optout-test-") as directory:
staging = Path(directory)
# A copy, not the checkout's script: past this point main() writes
# generated icons next to itself, and a test has no business dirtying
# the working tree. Everything under test happens before that, and the
# copy's missing sibling guard would fail loudly if it did run.
packaging = staging / "linux/packaging"
packaging.mkdir(parents=True)
script = packaging / BUILD_PACKAGES.name
script.write_text(BUILD_PACKAGES.read_text(encoding="utf-8"), encoding="utf-8")
bundle = staging / "bundle"
(bundle / "lib").mkdir(parents=True)
(bundle / "lib/libmpv.so.2").write_bytes(b"")
result = subprocess.run(
[sys.executable, str(script)],
cwd=ROOT,
check=False,
capture_output=True,
text=True,
env={
**os.environ,
"BUILD_DIR": str(bundle),
"OUTPUT_DIR": str(staging / "packages"),
"PLEZY_SKIP_HOST_DEP_CHECK": "1",
},
)
self.assertIn("PLEZY_SKIP_HOST_DEP_CHECK is set", result.stdout)
self.assertIn("unverified", result.stdout)
def test_a_falsey_opt_out_does_not_skip(self) -> None:
"""`=0` means "do not skip" to almost everyone, and must behave that way.
A bare non-empty test would read `0`, `false` and `no` as consent, which
is the exact opposite of what the person typing them meant - and hands
back the unverified package this wiring exists to withhold.
"""
for value in ("0", "false", "no", "off", ""):
with self.subTest(value=value), tempfile.TemporaryDirectory(prefix="plezy-packaging-falsey-") as directory:
staging = Path(directory)
packaging = staging / "linux/packaging"
packaging.mkdir(parents=True)
script = packaging / BUILD_PACKAGES.name
script.write_text(BUILD_PACKAGES.read_text(encoding="utf-8"), encoding="utf-8")
bundle = staging / "bundle"
(bundle / "lib").mkdir(parents=True)
(bundle / "lib/libmpv.so.2").write_bytes(b"")
result = subprocess.run(
[sys.executable, str(script)],
cwd=ROOT,
check=False,
capture_output=True,
text=True,
env={
**os.environ,
"BUILD_DIR": str(bundle),
"OUTPUT_DIR": str(staging / "packages"),
"PLEZY_SKIP_HOST_DEP_CHECK": value,
},
)
self.assertNotIn("PLEZY_SKIP_HOST_DEP_CHECK is set", result.stdout)
# The guard ran, so packaging stopped on its verdict rather than
# continuing past an unverified bundle.
self.assertNotEqual(result.returncode, 0)
if __name__ == "__main__":
unittest.main()