mirror of
https://github.com/systemd/systemd.git
synced 2026-07-01 04:06:56 +00:00
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.
This commit is contained in:
committed by
Daan De Meyer
parent
d52c8f2068
commit
d9600a2ac0
@@ -2267,6 +2267,7 @@ test_dlopen = executables_by_name.get('test-dlopen')
|
|||||||
|
|
||||||
nss_targets = []
|
nss_targets = []
|
||||||
pam_targets = []
|
pam_targets = []
|
||||||
|
module_targets = []
|
||||||
foreach dict : modules
|
foreach dict : modules
|
||||||
name = dict.get('name')
|
name = dict.get('name')
|
||||||
is_nss = name.startswith('nss_')
|
is_nss = name.startswith('nss_')
|
||||||
@@ -2311,6 +2312,8 @@ foreach dict : modules
|
|||||||
implicit_include_directories : false,
|
implicit_include_directories : false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
module_targets += lib
|
||||||
|
|
||||||
if is_nss
|
if is_nss
|
||||||
# We cannot use shared_module because it does not support version suffix.
|
# We cannot use shared_module because it does not support version suffix.
|
||||||
# Unfortunately shared_library insists on creating the symlink…
|
# Unfortunately shared_library insists on creating the symlink…
|
||||||
|
|||||||
@@ -124,6 +124,18 @@ if want_tests != 'false'
|
|||||||
exe,
|
exe,
|
||||||
suite : 'udev',
|
suite : 'udev',
|
||||||
args : ['verify', '--resolve-names=late', all_rules])
|
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
|
endif
|
||||||
|
|
||||||
############################################################
|
############################################################
|
||||||
|
|||||||
187
test/test-link-abi.py
Executable file
187
test/test-link-abi.py
Executable file
@@ -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())
|
||||||
Reference in New Issue
Block a user