]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
test: add dlopen note checker
authorYu Watanabe <watanabe.yu+github@gmail.com>
Wed, 8 Jul 2026 18:48:33 +0000 (03:48 +0900)
committerYu Watanabe <watanabe.yu+github@gmail.com>
Fri, 10 Jul 2026 07:35:19 +0000 (16:35 +0900)
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.

meson.build
test/meson.build
test/test-dlopen-note.py [new file with mode: 0755]

index 30ed31b03c7bfc32457bbe72a3117d4bb17bcb41..30975765617e6aec8974b66fa6114512ff25fbbb 100644 (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,
 )
index f3a8541f8e61495362a6a184f185b0ff477d46b9..845b262846d975a8d7934c5946aa5d0fe9493abe 100644 (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
diff --git a/test/test-dlopen-note.py b/test/test-dlopen-note.py
new file mode 100755 (executable)
index 0000000..7882ab3
--- /dev/null
@@ -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()