test: add dlopen note checker

This introduces a test utility to validate the presence and correctness
of dlopen ELF notes.

The test extracts and compares dlopen ELF notes between the dynamic and
static versions of target binaries to catch missing entries. Furthermore,
it scans the code for dlopen_foo() invocations to ensure that no stale or
unused notes remain in the final binary, thereby verifying the linker's
garbage-collection integration.
This commit is contained in:
Yu Watanabe
2026-07-09 03:48:33 +09:00
parent 01e02cb94f
commit 5b05fbe53e
3 changed files with 348 additions and 1 deletions

View File

@@ -15,7 +15,7 @@ project('systemd', 'c',
add_test_setup(
'default',
exclude_suites : ['clang-tidy', 'coccinelle', 'unused-symbols', 'integration-tests'],
exclude_suites : ['clang-tidy', 'coccinelle', 'dlopen', 'unused-symbols', 'integration-tests'],
exe_wrapper : find_program('tools/test-crash-trace.sh'),
is_default : true,
)

View File

@@ -279,6 +279,60 @@ endif
############################################################
if want_tests != 'false' and pyelftools.found()
test_dlopen_note_py = files('test-dlopen-note.py')
foreach name, exe : executables_by_name
if name.endswith('.static') or name.endswith('.shared')
continue
endif
name_static = name + '.static'
if name + '.standalone' in executables_by_name
exe_shared = exe
exe_static = executables_by_name.get(name + '.standalone')
elif name + '.shared' in executables_by_name
exe_shared = executables_by_name.get(name + '.shared')
exe_static = exe
else
continue
endif
test(name,
test_dlopen_note_py,
env : test_env,
args : ['--shared', exe_shared.full_path(), '--static', exe_static.full_path()],
depends : [exe_shared, exe_static],
suite : 'dlopen')
endforeach
foreach name, lib : modules_by_name
if name.endswith('.standalone')
continue
endif
lib_static = modules_by_name.get(name + '.standalone', lib)
test('lib' + lib.name(),
test_dlopen_note_py,
env : test_env,
args : ['--shared', lib.full_path(), '--static', lib_static.full_path()],
depends : [lib, lib_static],
suite : 'dlopen')
endforeach
foreach lib : [libshared, libsystemd, libudev]
test('lib' + lib.name(),
test_dlopen_note_py,
env : test_env,
args : ['--shared', lib.full_path(), '--static', lib.full_path()],
depends : [lib],
suite : 'dlopen')
endforeach
endif
############################################################
if want_tests != 'false'
subdir('integration-tests')
endif

293
test/test-dlopen-note.py Executable file
View File

@@ -0,0 +1,293 @@
#!/usr/bin/env python3
"""
Compare statically linked dlopen symbols against shared library ELF notes.
This script extracts surviving dlopen_* symbols from a statically linked
executable (representing the true minimal set of dynamically loaded features
actually invoked by the code after linker optimizations like --gc-sections)
and compares them against the embedded FDO .note.dlopen metadata inside a
shared library or dynamic executable.
It highlights missing metadata, over-included (dead) metadata, and detects
any unmapped symbols that require script updates.
"""
import argparse
import enum
import json
import os
import re
import sys
from functools import total_ordering
from pathlib import Path
from typing import Any
try:
from elftools.elf.elffile import ELFFile
from elftools.elf.sections import NoteSection, SymbolTableSection
except ImportError:
print("Error: 'elftools' python module not found.", file=sys.stderr)
sys.exit(1)
# Function filter: Maps wrapper/sentinel symbols to real symbols or an empty list (ignore).
FUNCTION_FILTER = {
# Filtered out (internal helpers, verbose loggers, sentinels)
'dlopen_safe': [],
'dlopen_verbose': [],
'dlopen_many_sym_or_warn_sentinel': [],
'dlopen_dw_has_dwfl_set_sysroot': [],
# Mapped to multiple functions (expands generic wrappers into specific sub-layers)
'dlopen_tpm2': [
'dlopen_tpm2_esys',
'dlopen_tpm2_mu',
'dlopen_tpm2_rc',
'dlopen_tpm2_tcti_device',
],
}
# Direct mapping from clean symbol name to expected FDO dlopen feature name.
FUNCTION_TO_FEATURE = {
'dlopen_bpf': 'bpf',
'dlopen_bzip2': 'bzip2',
'dlopen_cryptsetup': 'cryptsetup',
'dlopen_curl': 'curl',
'dlopen_dw': 'dw',
'dlopen_elf': 'elf',
'dlopen_fdisk': 'fdisk',
'dlopen_gnutls': 'gnutls',
'dlopen_idn': 'idn',
'dlopen_libacl': 'acl',
'dlopen_libapparmor': 'apparmor',
'dlopen_libarchive': 'archive',
'dlopen_libaudit': 'audit',
'dlopen_libblkid': 'blkid',
'dlopen_libcrypt': 'crypt',
'dlopen_libcrypto': 'libcrypto',
'dlopen_libfido2': 'fido2',
'dlopen_libkmod': 'kmod',
'dlopen_libmount': 'mount',
'dlopen_libpam': 'pam',
'dlopen_libseccomp': 'seccomp',
'dlopen_libselinux': 'selinux',
'dlopen_libssl': 'libssl',
'dlopen_lz4': 'lz4',
'dlopen_microhttpd': 'microhttpd',
'dlopen_p11kit': 'p11-kit',
'dlopen_passwdqc': 'passwdqc',
'dlopen_pcre2': 'pcre2',
'dlopen_pwquality': 'pwquality',
'dlopen_qrencode': 'qrencode',
'dlopen_tpm2_esys': 'tpm (esys)',
'dlopen_tpm2_mu': 'tpm (mu)',
'dlopen_tpm2_rc': 'tpm (rc)',
'dlopen_tpm2_tcti_device': 'tpm (tcti-device)',
'dlopen_xkbcommon': 'xkbcommon',
'dlopen_xz': 'lzma',
'dlopen_zlib': 'zlib',
'dlopen_zstd': 'zstd',
}
def dlopen_function_append(name: str, function_names: set[str]) -> None:
"""Add a symbol to the set, expanding or dropping based on FUNCTION_FILTER."""
function_names.update(FUNCTION_FILTER.get(name, [name]))
def dlopen_functions_parse(path: Path) -> set[str]:
"""Parse ELF symbol tables and extract surviving dlopen_* function names."""
function_names: set[str] = set()
try:
with path.open('rb') as f:
elffile = ELFFile(f)
for section in elffile.iter_sections():
if not isinstance(section, SymbolTableSection):
continue
for symbol in section.iter_symbols():
# Ignore undefined (externally referenced) or non-function symbols
if symbol['st_info']['type'] != 'STT_FUNC' or symbol['st_shndx'] == 'SHN_UNDEF':
continue
if m := re.match(r'^(?:sym_)?(dlopen_[a-zA-Z0-9_]+)$', symbol.name):
dlopen_function_append(m.group(1), function_names)
except Exception as e:
print(f"Failed to read '{path}': {e}", file=sys.stderr)
sys.exit(1)
return function_names
def dlopen_note_get_feature(item: dict[str, Any]) -> str:
if 'feature' not in item:
return None
feature = item['feature']
if feature != 'tpm':
return feature
# TPM features are collapsed into "tpm" in the ELF note. We iterate over
# sonames to correctly map them back to detailed granular layers.
for soname in item.get('soname', []):
if 'esys' in soname:
return 'tpm (esys)'
if 'mu' in soname:
return 'tpm (mu)'
if 'rc' in soname:
return 'tpm (rc)'
if 'tcti-device' in soname:
return 'tpm (tcti-device)'
else:
return feature # If not found, let's return 'tpm' as is, and warn later.
@total_ordering
class Priority(enum.Enum):
suggested = 1
recommended = 2
required = 3
def __lt__(self, other):
return self.value < other.value
def __str__(self):
return self.name
def dlopen_note_append(item: dict[str, Any], entries: dict[str, dict[str, Any]]) -> None:
"""Store the parsed note item into the dictionary."""
feat = dlopen_note_get_feature(item)
prio = Priority[item.get('priority', 'recommended')]
if feat in entries and Priority[entries[feat].get('priority', 'recommended')] >= prio:
return
entries[feat] = item
def dlopen_note_merge(
a: dict[str, dict[str, Any]],
b: dict[str, dict[str, Any]],
) -> dict[str, dict[str, Any]]:
c = a
for item in b.values():
dlopen_note_append(item, c)
return c
def dlopen_note_parse(path: Path) -> dict[str, dict[str, Any]]:
"""Parse .note.dlopen ELF section and extract embedded JSON metadata."""
entries: dict[str, dict[str, Any]] = {}
try:
with path.open('rb') as f:
elffile = ELFFile(f)
for section in elffile.iter_sections():
# Verify section name and type safely
if section.name != '.note.dlopen' or not isinstance(section, NoteSection):
continue
for note in section.iter_notes():
desc = note['n_desc']
# Handle raw bytes or string payloads, stripping null-terminator bytes (\x00)
desc_str = (
desc.decode('utf-8', errors='ignore').rstrip('\x00')
if isinstance(desc, bytes)
else str(desc).rstrip('\x00')
)
try:
data = json.loads(desc_str)
# According to FDO specs, payload can be a JSON array or a single dict
items = data if isinstance(data, list) else [data]
for item in items:
if isinstance(item, dict):
dlopen_note_append(item, entries)
except json.JSONDecodeError:
continue
except Exception as e:
print(f"Failed to read '{path}': {e}", file=sys.stderr)
sys.exit(1)
return entries
def dlopen_note_dump(entries: dict[str, dict[str, Any]], path: str) -> None:
print(f'=== Embedded .note.dlopen Metadata in: {path} ===')
if not entries:
print(' (No .note.dlopen metadata found)')
else:
for feat, item in sorted(entries.items()):
p = item.get('priority', 'recommended')
print(f" - {feat.ljust(20)} : {p.ljust(15)} : {' '.join(item.get('soname', []))}")
print()
def main():
parser = argparse.ArgumentParser(description='Compare dlopen symbols vs metadata notes.')
parser.add_argument('--static', required=True, help='Path to statically linked binary')
parser.add_argument('--shared', required=True, help='Path to shared library or dynamic binary')
args = parser.parse_args()
unknown_functions = set()
features_by_func = set()
if check_function_symbol := os.getenv('SYSTEMD_BUILD_MODE') == 'developer':
for name in dlopen_functions_parse(Path(args.static)):
if name not in FUNCTION_TO_FEATURE:
unknown_functions.add(name)
else:
features_by_func.add(FUNCTION_TO_FEATURE[name])
note_static = dlopen_note_parse(Path(args.static))
note_shared = dlopen_note_parse(Path(args.shared))
note_merged = dlopen_note_merge(note_static, note_shared)
dlopen_note_dump(note_static, args.static)
dlopen_note_dump(note_shared, args.shared)
print('=== [DIFF] Analysis & Verification ===')
rc = 0
for feat, item in sorted(note_merged.items()):
if feat not in note_shared:
print(f'{feat} is missing.')
rc = 1
continue
p = Priority[item.get('priority', 'recommended')]
q = Priority[note_shared[feat].get('priority', 'recommended')]
if q < p:
print(f' ⚠️ {feat} has weaker priority ({q} < {p}).')
rc = 1
continue
if check_function_symbol and feat not in features_by_func:
print(f' 💥 {feat} is set, but corresponding dlopen() is not called.')
rc = 1
continue
if feat not in note_static:
print(f' 👀 {feat} is missing in {args.static}.')
rc = 1
if unknown_functions:
print(' 💀 [UNKNOWN DLOPEN FUNCTIONS] Found unknown dlopen functions, please update tables:')
for f in sorted(unknown_functions):
print(f" * Function '{f}'")
rc = 1
if rc == 0:
print(' 🎉 PERFECT MATCH! Exactly required dlopen notes are embedded.')
sys.exit(rc)
if __name__ == '__main__':
main()