]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
test: add test-link-abi to enforce link-time ABI invariants
authorDaan De Meyer <daan@amutable.com>
Thu, 14 May 2026 19:20:02 +0000 (19:20 +0000)
committerDaan De Meyer <daan.j.demeyer@gmail.com>
Wed, 20 May 2026 09:03:27 +0000 (11:03 +0200)
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
test/meson.build
test/test-link-abi.py [new file with mode: 0755]

index d9c8b84e4707b5914e0c0e7efbd4544322859616..5b880cd95a7e1cdf1934d034205f2addc1f4b217 100644 (file)
@@ -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…
index b64f971126df6827accba40c7e78a043b0662cd4..fbc88db99dd02223f2ab88a6b845f395056b27f0 100644 (file)
@@ -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 (executable)
index 0000000..a36e6c3
--- /dev/null
@@ -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-<arch>.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())