Skip to content

Commit f6b0a83

Browse files
committed
Verify C++ binaries run with right stdlib
1 parent 20a036d commit f6b0a83

2 files changed

Lines changed: 224 additions & 0 deletions

File tree

.github/workflows/cpp-build-test-run.yaml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,32 @@ jobs:
145145
cmakeListsTxtPath: solvers/cpp/CMakeLists.txt
146146
workflowPreset: ${{ steps.preset-name.outputs.name }}
147147

148+
- name: Verify runtime C++ standard library
149+
env:
150+
MATRIX_CXXLIB: ${{ matrix.cxxLib }}
151+
MATRIX_BUILDTYPE: ${{ matrix.buildType }}
152+
run: |
153+
case "${MATRIX_CXXLIB}" in
154+
libstdc++)
155+
stdlib=libstdc++
156+
expectedPrefix="${HOMEBREW_PREFIX}/opt/gcc@${GCC_MAJOR_VERSION}"
157+
;;
158+
libc++)
159+
stdlib=libc++
160+
if [[ "${MATRIX_BUILDTYPE}" == "hardened" ]]; then
161+
expectedPrefix="${HARDENED_LIBCXX_DIR}"
162+
else
163+
expectedPrefix="${HOMEBREW_PREFIX}/opt/llvm@${LLVM_MAJOR_VERSION}"
164+
fi
165+
;;
166+
*)
167+
echo "::error::Unknown cxxLib: ${MATRIX_CXXLIB}"
168+
exit 1
169+
;;
170+
esac
171+
mise run cpp:verify-runtime-stdlib -- solvers/cpp/build "${stdlib}" "${expectedPrefix}"
172+
shell: bash
173+
148174
- name: Restore aoc-main uv project
149175
uses: ./.github/actions/uv-project-setup
150176
with:
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
#!/usr/bin/env python3
2+
# [MISE] description="Verify binaries load the intended libstdc++/libc++ at runtime"
3+
# [USAGE] arg "<build_dir>" help="CMake build directory to scan"
4+
# [USAGE] arg "<stdlib>" help="Intended C++ standard library" {
5+
# [USAGE] choices "libstdc++" "libc++"
6+
# [USAGE] }
7+
# [USAGE] arg "<expected_prefix>" help="Prefix the runtime libraries must load from"
8+
#
9+
# Why this exists: -stdlib=... / -L... only affect the *static* link. At runtime the
10+
# loader resolves the bare SONAME (e.g. libstdc++.so.6) via its own search, which can
11+
# silently land on a *system* library that happens to be ABI-compatible. Running the
12+
# tests does not catch that. This task inspects the actual loader resolution of every
13+
# built binary and asserts the intended library loads from <expected_prefix>.
14+
import logging
15+
import os
16+
import platform
17+
import subprocess
18+
import sys
19+
from pathlib import Path
20+
from typing import TYPE_CHECKING
21+
22+
if TYPE_CHECKING:
23+
from collections.abc import Iterator
24+
25+
_logger = logging.getLogger(os.environ.get("MISE_TASK_NAME", __name__))
26+
27+
# Sentinel: the binary depends on the SONAME but the loader cannot resolve it.
28+
_NOT_FOUND = "<not-found>"
29+
30+
# Runtime libraries whose load path is pinned, per (stdlib, platform). For libc++ we
31+
# also pin libunwind (LLVM's unwinder, linked via -lunwind).
32+
_SONAMES: dict[tuple[str, str], list[str]] = {
33+
("libstdc++", "Linux"): ["libstdc++.so.6"],
34+
("libstdc++", "Darwin"): ["libstdc++.6.dylib"],
35+
("libc++", "Linux"): ["libc++.so.1", "libunwind.so.1"],
36+
("libc++", "Darwin"): ["libc++.1.dylib", "libunwind.1.dylib"],
37+
}
38+
39+
# MIME types (per `file --mime-type`) of the artifacts we inspect: ELF executables
40+
# (position-independent or not), shared libraries, and Mach-O images. Objects,
41+
# archives and text files report other types and are skipped.
42+
_BINARY_MIME_TYPES = frozenset(
43+
{
44+
"application/x-executable",
45+
"application/x-pie-executable",
46+
"application/x-sharedlib",
47+
"application/x-mach-binary",
48+
}
49+
)
50+
51+
52+
def _run(args: list[str]) -> str:
53+
return subprocess.run( # noqa: S603
54+
args,
55+
check=False,
56+
text=True,
57+
stdin=subprocess.DEVNULL,
58+
stdout=subprocess.PIPE,
59+
stderr=subprocess.DEVNULL,
60+
).stdout
61+
62+
63+
def _is_binary(path: Path) -> bool:
64+
mime = _run(["file", "--brief", "--mime-type", str(path)]).strip()
65+
return mime in _BINARY_MIME_TYPES
66+
67+
68+
def _iter_binaries(build_dir: Path) -> Iterator[Path]:
69+
for path in sorted(build_dir.rglob("*")):
70+
if "CMakeFiles" in path.parts or not path.is_file():
71+
continue
72+
if _is_binary(path):
73+
yield path
74+
75+
76+
def _resolved_libs_linux(binary: Path) -> dict[str, str]:
77+
# One ldd call yields "SONAME => /resolved/path" (or "=> not found") per dependency.
78+
libs: dict[str, str] = {}
79+
for line in _run(["ldd", str(binary)]).splitlines():
80+
fields = line.split()
81+
if "=>" not in fields:
82+
continue
83+
target = fields[fields.index("=>") + 1]
84+
libs[fields[0]] = _NOT_FOUND if target == "not" else target
85+
return libs
86+
87+
88+
def _macos_rpaths(binary: Path) -> Iterator[str]:
89+
lines = _run(["otool", "-l", str(binary)]).splitlines()
90+
loader = str(binary.parent)
91+
for index, line in enumerate(lines):
92+
if line.strip() != "cmd LC_RPATH":
93+
continue
94+
for follow in lines[index + 1 : index + 4]:
95+
stripped = follow.strip()
96+
if stripped.startswith("path "):
97+
raw = stripped.split(" ", 2)[1]
98+
for placeholder in ("@loader_path", "@executable_path"):
99+
raw = raw.replace(placeholder, loader)
100+
yield raw
101+
break
102+
103+
104+
def _resolve_install_name(binary: Path, name: str, rpaths: list[str]) -> str:
105+
if name.startswith("/"):
106+
return name # absolute install name: dyld uses it verbatim
107+
if name.startswith(("@loader_path/", "@executable_path/")):
108+
candidate = binary.parent / name.split("/", 1)[1]
109+
return str(candidate) if candidate.exists() else _NOT_FOUND
110+
if name.startswith("@rpath/"):
111+
suffix = name[len("@rpath/") :]
112+
for rpath in rpaths:
113+
candidate = Path(rpath) / suffix
114+
if candidate.exists():
115+
return str(candidate)
116+
return _NOT_FOUND
117+
118+
119+
def _resolved_libs_macos(binary: Path) -> dict[str, str]:
120+
# One otool -L call lists every dependency's install name; resolve each statically.
121+
lines = _run(["otool", "-L", str(binary)]).splitlines()[1:]
122+
names = [name for name in (line.strip().split(" ", 1)[0] for line in lines) if name]
123+
has_rpath = any(name.startswith("@rpath/") for name in names)
124+
rpaths = list(_macos_rpaths(binary)) if has_rpath else []
125+
libs: dict[str, str] = {}
126+
for name in names:
127+
libs[name.rsplit("/", 1)[-1]] = _resolve_install_name(binary, name, rpaths)
128+
return libs
129+
130+
131+
def _resolved_libs(binary: Path) -> dict[str, str]:
132+
if platform.system() == "Darwin":
133+
return _resolved_libs_macos(binary)
134+
return _resolved_libs_linux(binary)
135+
136+
137+
def _check(
138+
name: str, libs: dict[str, str], soname: str, expected_prefix: str
139+
) -> bool | None:
140+
# None: the binary does not depend on `soname`. True/False: pass/fail (logged).
141+
path = libs.get(soname)
142+
if path is None:
143+
return None
144+
if path == _NOT_FOUND:
145+
_logger.error("FAIL %s: %s not found at runtime", name, soname)
146+
return False
147+
if path == expected_prefix or path.startswith(f"{expected_prefix}/"):
148+
_logger.info("ok %s: %s => %s", name, soname, path)
149+
return True
150+
msg = "FAIL %s: %s => %s (want under %s)"
151+
_logger.error(msg, name, soname, path, expected_prefix)
152+
return False
153+
154+
155+
def _verify(binaries: list[Path], sonames: list[str], expected_prefix: str) -> bool:
156+
ok = True
157+
seen: set[str] = set()
158+
for binary in binaries:
159+
libs = _resolved_libs(binary)
160+
for soname in sonames:
161+
result = _check(binary.name, libs, soname, expected_prefix)
162+
if result is None:
163+
continue
164+
seen.add(soname)
165+
ok = ok and result
166+
# Guard against silently verifying nothing (wrong build dir or unexpected linkage).
167+
for soname in sonames:
168+
if soname not in seen:
169+
_logger.error("FAIL no binary depends on %s; nothing verified", soname)
170+
ok = False
171+
return ok
172+
173+
174+
def main() -> bool:
175+
logging.basicConfig(level=logging.INFO, format="%(message)s")
176+
177+
build_dir = Path(os.environ["usage_build_dir"]) # noqa: SIM112
178+
stdlib = os.environ["usage_stdlib"] # noqa: SIM112
179+
expected_prefix = os.environ["usage_expected_prefix"].rstrip("/") # noqa: SIM112
180+
181+
sonames = _SONAMES.get((stdlib, platform.system()))
182+
if sonames is None:
183+
_logger.error("Unsupported: stdlib=%s platform=%s", stdlib, platform.system())
184+
return False
185+
binaries = list(_iter_binaries(build_dir))
186+
if not binaries:
187+
_logger.error("No binaries found under %s", build_dir)
188+
return False
189+
190+
if not _verify(binaries, sonames, expected_prefix):
191+
return False
192+
_logger.info("runtime-stdlib OK: %s loads from %s", stdlib, expected_prefix)
193+
return True
194+
195+
196+
if __name__ == "__main__":
197+
if not main():
198+
sys.exit(1)

0 commit comments

Comments
 (0)