From d9600a2ac0433095189688ebd86a24fcc8ed9f6c Mon Sep 17 00:00:00 2001 From: Daan De Meyer Date: Thu, 14 May 2026 19:20:02 +0000 Subject: [PATCH] test: add test-link-abi to enforce link-time ABI invariants For every built executable, internal shared library, and plugin module, verify two link-time properties via readelf: 1. No imported GLIBC symbol's version is newer than 2.34. 2. The dynamic section's NEEDED entries reference only glibc, the runtime linker, our own libraries. --- meson.build | 3 + test/meson.build | 12 +++ test/test-link-abi.py | 187 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 202 insertions(+) create mode 100755 test/test-link-abi.py diff --git a/meson.build b/meson.build index d9c8b84e470..5b880cd95a7 100644 --- a/meson.build +++ b/meson.build @@ -2267,6 +2267,7 @@ test_dlopen = executables_by_name.get('test-dlopen') nss_targets = [] pam_targets = [] +module_targets = [] foreach dict : modules name = dict.get('name') is_nss = name.startswith('nss_') @@ -2311,6 +2312,8 @@ foreach dict : modules implicit_include_directories : false, ) + module_targets += lib + if is_nss # We cannot use shared_module because it does not support version suffix. # Unfortunately shared_library insists on creating the symlink… diff --git a/test/meson.build b/test/meson.build index b64f971126d..fbc88db99dd 100644 --- a/test/meson.build +++ b/test/meson.build @@ -124,6 +124,18 @@ if want_tests != 'false' exe, suite : 'udev', args : ['verify', '--resolve-names=late', all_rules]) + + link_abi_targets = [libsystemd, libudev, libshared, libcore] + link_abi_targets += module_targets + foreach _, exe : executables_by_name + link_abi_targets += exe + endforeach + test('test-link-abi', + files('test-link-abi.py'), + args : link_abi_targets, + depends : link_abi_targets, + suite : 'dist', + timeout : 120) endif ############################################################ diff --git a/test/test-link-abi.py b/test/test-link-abi.py new file mode 100755 index 00000000000..a36e6c32d7e --- /dev/null +++ b/test/test-link-abi.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: LGPL-2.1-or-later +"""Verify two ABI properties of the supplied ELF objects: + +1. No imported GLIBC symbol is newer than the baseline (default 2.34). +2. Each binary's NEEDED entries only reference glibc itself or our own + internal shared libraries — anything else means we've grown a hard + link-time dependency on a third-party library that should be dlopen()'d + instead. +""" + +import argparse +import re +import subprocess +import sys + +GLIBC_RE: re.Pattern[str] = re.compile(r'\bGLIBC_(\d+)\.(\d+)(?:\.\d+)?\b') +NEEDED_RE: re.Pattern[str] = re.compile(r'\(NEEDED\)\s+Shared library:\s+\[([^\]]+)\]') + +Version = tuple[int, int] + +# Shared libraries that ship as part of the C library itself; always allowed. +# Matched by prefix so the soversion is not hardcoded. glibc splits libc/libm/etc.; +# musl bundles everything into a single libc whose soname encodes the architecture +# (e.g. libc.musl-x86_64.so.1, libc.musl-aarch64.so.1). +LIBC_LIB_PREFIXES: tuple[str, ...] = ( + 'libc.so.', + 'libm.so.', + 'libresolv.so.', + 'libc.musl-', +) + +# GCC runtime support libraries (stack unwinding, soft-float helpers, C++ standard library). The +# toolchain pulls these in automatically and they're part of every glibc toolchain. Matched by +# prefix so the soversion is not hardcoded. +GCC_LIB_PREFIXES: tuple[str, ...] = ( + 'libasan.so.', + 'libclang_rt.asan.so', + 'libclang_rt.ubsan.so', + 'libubsan.so.', + 'libgcc_s.so.', + 'libstdc++.so.', +) + +# Our own shared libraries; matched by prefix because the soname encodes the +# project version (libsystemd-shared-NNN.so, libsystemd-core-NNN.so). +INTERNAL_LIB_PREFIXES: tuple[str, ...] = ( + 'libsystemd-shared-', + 'libsystemd-core-', + 'libsystemd.so.', + 'libudev.so.', +) + + +def parse_baseline(s: str) -> Version: + m = re.fullmatch(r'(\d+)\.(\d+)', s) + if not m: + raise argparse.ArgumentTypeError(f'baseline must be MAJOR.MINOR, got {s!r}') + return (int(m.group(1)), int(m.group(2))) + + +def is_elf(path: str) -> bool: + """Cheap ELF magic check so readelf isn't run on non-ELF inputs.""" + try: + with open(path, 'rb') as f: + return f.read(4) == b'\x7fELF' + except OSError: + return False + + +def is_internal(name: str) -> bool: + return any(name.startswith(p) for p in INTERNAL_LIB_PREFIXES) + + +def is_dynamic_linker(name: str) -> bool: + # The runtime linker's filename depends on the architecture: ld-linux-x86-64.so.2, + # ld-linux-aarch64.so.1, ld-linux-armhf.so.3, ld-linux-riscv64-lp64d.so.1 on the + # "ld-" naming; ld.so.1 (mips, ppc, s390 32-bit), ld64.so.1 (s390x, ppc64 BE), + # and ld64.so.2 (ppc64le) on the older naming. musl uses ld-musl-.so.1 + # (already covered by the "ld-" prefix). + return name.startswith(('ld-', 'ld.so.', 'ld64.so.')) + + +def is_libc(name: str) -> bool: + return name.startswith(LIBC_LIB_PREFIXES) + + +def glibc_violations(path: str, baseline: Version) -> list[tuple[str, str]]: + """Return a sorted list of (symbol, "GLIBC_X.Y") pairs above the baseline.""" + out = subprocess.check_output( + ['readelf', '-W', '--dyn-syms', path], + text=True, + stderr=subprocess.DEVNULL, + ) + + found: set[tuple[str, str]] = set() + for line in out.splitlines(): + m = GLIBC_RE.search(line) + if not m: + continue + # readelf --dyn-syms lists both imported (Ndx=UND) and exported symbols; + # only the former carry a real link-time dependency on the listed glibc + # version. Skip anything that isn't UND. + parts = line.split() + if len(parts) < 8 or parts[6] != 'UND': + continue + ver: Version = (int(m.group(1)), int(m.group(2))) + if ver <= baseline: + continue + sym = next((t for t in parts if '@' in t), line.strip()) + found.add((sym, m.group(0))) + return sorted(found) + + +def needed_violations(path: str) -> list[str]: + """Return NEEDED entries outside the always-allowed set.""" + out = subprocess.check_output( + ['readelf', '-W', '-d', path], + text=True, + stderr=subprocess.DEVNULL, + ) + + bad: list[str] = [] + for line in out.splitlines(): + m = NEEDED_RE.search(line) + if not m: + continue + name = m.group(1) + if ( + is_libc(name) + or name.startswith(GCC_LIB_PREFIXES) + or is_dynamic_linker(name) + or is_internal(name) + ): + continue + bad.append(name) + return bad + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument( + '--baseline', + type=parse_baseline, + default=(2, 34), + help='Maximum allowed GLIBC version (default: 2.34)', + ) + ap.add_argument('paths', nargs='*', metavar='PATH', help='ELF files to check') + args = ap.parse_args() + + baseline: Version = args.baseline + checked = 0 + failed = 0 + for path in args.paths: + if not is_elf(path): + # Some inputs passed by meson may be scripts or non-ELF + # generators; silently skip those rather than fail. + continue + checked += 1 + + glibc_bad = glibc_violations(path, baseline) + needed_bad = needed_violations(path) + + if glibc_bad or needed_bad: + failed += 1 + print(f'{path}:', file=sys.stderr) + if glibc_bad: + print(f' imports symbols newer than GLIBC_{baseline[0]}.{baseline[1]}:', file=sys.stderr) + for sym, ver_str in glibc_bad: + print(f' {sym} ({ver_str})', file=sys.stderr) + if needed_bad: + print(' links against unexpected libraries (dlopen() them instead):', file=sys.stderr) + for name in needed_bad: + print(f' {name}', file=sys.stderr) + + baseline_str = f'GLIBC_{baseline[0]}.{baseline[1]}' + if failed: + print(f'\nFAIL: {failed} of {checked} ELF objects failed the ABI checks.', file=sys.stderr) + return 1 + print( + f'OK: {checked} ELF objects checked; all within {baseline_str} and with only allowed NEEDED entries.' + ) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) -- 2.47.3