File indexing completed on 2026-06-16 07:48:12
0001 """Linker-isolation regression tests for libActsPluginArrow.
0002
0003 When the plugin is built with ACTS_ARROW_ISOLATED=ON, the arrow/parquet
0004 static archives are absorbed into the plugin .dylib/.so with hidden
0005 visibility plus an exported-symbols allowlist (see
0006 Plugins/Arrow/cmake/exported_symbols.{txt,ld}). The intent is that the
0007 plugin presents NO arrow or parquet symbols to the rest of the process,
0008 so a co-loaded pyarrow (which has its own libarrow) cannot collide with
0009 ACTS's bundled archives under any dlopen mode.
0010
0011 These tests verify the resulting library actually upholds that contract:
0012
0013 - no LC_LOAD_DYLIB / NEEDED entry for libarrow*, libparquet*, or
0014 libarrow_dataset*;
0015 - no externally-defined symbol whose top-level namespace (after
0016 demangling) is `arrow::`, `parquet::`, or `arrow_vendored::`.
0017
0018 When isolation is off the plugin links arrow dynamically and the test
0019 skips — both checks are vacuously meaningless in that mode.
0020 """
0021
0022 import os
0023 import re
0024 import shutil
0025 import subprocess
0026 import sys
0027 from pathlib import Path
0028
0029 import pytest
0030
0031 from helpers import arrowEnabled
0032
0033 pytestmark = pytest.mark.skipif(
0034 not arrowEnabled, reason="Arrow/Parquet bindings not built"
0035 )
0036
0037
0038 _REPO_ROOT = Path(__file__).resolve().parents[3]
0039 _LIB_NAME = "libActsPluginArrow"
0040
0041
0042 def _plugin_lib() -> Path:
0043 """Locate the built plugin library in build/lib."""
0044 libdir = _REPO_ROOT / "build" / "lib"
0045 suffix = ".dylib" if sys.platform == "darwin" else ".so"
0046 path = libdir / f"{_LIB_NAME}{suffix}"
0047 if not path.exists():
0048 pytest.skip(f"{path} does not exist")
0049 return path
0050
0051
0052 def _direct_deps(lib: Path) -> list[str]:
0053 """Return the direct dynamic-linker dependencies of the library."""
0054 if sys.platform == "darwin":
0055 out = subprocess.check_output(["otool", "-L", str(lib)], text=True)
0056 deps: list[str] = []
0057
0058 for line in out.splitlines()[1:]:
0059 line = line.strip()
0060 if not line:
0061 continue
0062
0063 deps.append(line.split(" ", 1)[0])
0064 return deps
0065 out = subprocess.check_output(["readelf", "-d", str(lib)], text=True)
0066 deps = []
0067 for line in out.splitlines():
0068 m = re.search(r"\(NEEDED\).*Shared library:\s*\[(.+)\]", line)
0069 if m:
0070 deps.append(m.group(1))
0071 return deps
0072
0073
0074 def _is_arrow_lib(name: str) -> bool:
0075 base = os.path.basename(name)
0076 return base.startswith(("libarrow", "libparquet"))
0077
0078
0079 def _exported_symbols_demangled(lib: Path) -> list[str]:
0080 """Return demangled names of externally-defined symbols in the library."""
0081 if sys.platform == "darwin":
0082
0083 argv = ["nm", "-gUj", str(lib)]
0084 else:
0085 argv = ["nm", "-D", "--defined-only", "--format=just-symbols", str(lib)]
0086 raw = subprocess.check_output(argv, text=True, stderr=subprocess.DEVNULL)
0087 mangled = [line.strip() for line in raw.splitlines() if line.strip()]
0088 if not mangled:
0089 return []
0090 cxxfilt = shutil.which("c++filt") or shutil.which("llvm-cxxfilt")
0091 if cxxfilt is None:
0092 pytest.skip("c++filt / llvm-cxxfilt not available")
0093 proc = subprocess.run(
0094 [cxxfilt],
0095 input="\n".join(mangled),
0096 capture_output=True,
0097 text=True,
0098 check=True,
0099 )
0100 return proc.stdout.splitlines()
0101
0102
0103 def _isolation_active(lib: Path) -> bool:
0104 """True iff the build appears to use ACTS_ARROW_ISOLATED=ON.
0105
0106 Heuristic: in isolated mode the plugin has absorbed the arrow/parquet
0107 archives and so should not list libarrow*/libparquet* in its direct
0108 deps. In non-isolated mode it links those .dylibs/.sos directly.
0109 """
0110 return not any(_is_arrow_lib(d) for d in _direct_deps(lib))
0111
0112
0113
0114 _FORBIDDEN_NS_RE = re.compile(r"^(arrow|parquet|arrow_vendored)::")
0115
0116
0117 def test_no_arrow_dynamic_dependencies():
0118 """The plugin must not pull libarrow* / libparquet* into the process
0119 via its own dynamic-link table."""
0120 lib = _plugin_lib()
0121 if not _isolation_active(lib):
0122 pytest.skip("ACTS_ARROW_ISOLATED appears OFF; isolation check N/A")
0123 arrow_deps = [d for d in _direct_deps(lib) if _is_arrow_lib(d)]
0124 assert not arrow_deps, (
0125 f"{lib.name} unexpectedly references arrow/parquet shared "
0126 f"libraries: {arrow_deps}"
0127 )
0128
0129
0130 def test_no_exported_arrow_symbols():
0131 """No externally-defined symbol's top-level namespace may be
0132 arrow::, parquet::, or arrow_vendored::. Substring matches against
0133 'arrow' inside ACTS-namespaced symbols (e.g. signatures taking
0134 arrow::Table) are filtered out by checking the demangled prefix."""
0135 lib = _plugin_lib()
0136 if not _isolation_active(lib):
0137 pytest.skip("ACTS_ARROW_ISOLATED appears OFF; symbol-leak check N/A")
0138
0139 demangled = _exported_symbols_demangled(lib)
0140 assert demangled, f"{lib.name} exports no symbols at all (suspicious)"
0141
0142 leaks = [s for s in demangled if _FORBIDDEN_NS_RE.match(s)]
0143 assert not leaks, (
0144 f"{lib.name} leaks {len(leaks)} arrow/parquet symbol(s) "
0145 f"out of {len(demangled)} exported. First few:\n " + "\n ".join(leaks[:10])
0146 )
0147
0148
0149 def test_acts_symbols_are_exported():
0150 """Sanity counterpart: at least one symbol in the Acts/ActsPlugins/
0151 ActsExamples namespace should be exported. Catches the failure mode
0152 where the export allowlist is so strict it hides everything."""
0153 lib = _plugin_lib()
0154 demangled = _exported_symbols_demangled(lib)
0155 acts_syms = [
0156 s for s in demangled if re.match(r"^(Acts|ActsPlugins|ActsExamples)::", s)
0157 ]
0158 assert acts_syms, (
0159 f"{lib.name} exports no Acts*::* symbols (export allowlist "
0160 f"too strict?). Total exports: {len(demangled)}"
0161 )