Skip to content

Commit 7c1c91d

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

2 files changed

Lines changed: 222 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: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
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+
"application/x-executable",
44+
"application/x-pie-executable",
45+
"application/x-sharedlib",
46+
"application/x-mach-binary",
47+
})
48+
49+
50+
def _run(args: list[str]) -> str:
51+
return subprocess.run( # noqa: S603
52+
args,
53+
check=False,
54+
text=True,
55+
stdin=subprocess.DEVNULL,
56+
stdout=subprocess.PIPE,
57+
stderr=subprocess.DEVNULL,
58+
).stdout
59+
60+
61+
def _is_binary(path: Path) -> bool:
62+
mime = _run(["file", "--brief", "--mime-type", str(path)]).strip()
63+
return mime in _BINARY_MIME_TYPES
64+
65+
66+
def _iter_binaries(build_dir: Path) -> Iterator[Path]:
67+
for path in sorted(build_dir.rglob("*")):
68+
if "CMakeFiles" in path.parts or not path.is_file():
69+
continue
70+
if _is_binary(path):
71+
yield path
72+
73+
74+
def _resolved_libs_linux(binary: Path) -> dict[str, str]:
75+
# One ldd call yields "SONAME => /resolved/path" (or "=> not found") per dependency.
76+
libs: dict[str, str] = {}
77+
for line in _run(["ldd", str(binary)]).splitlines():
78+
fields = line.split()
79+
if "=>" not in fields:
80+
continue
81+
target = fields[fields.index("=>") + 1]
82+
libs[fields[0]] = _NOT_FOUND if target == "not" else target
83+
return libs
84+
85+
86+
def _macos_rpaths(binary: Path) -> Iterator[str]:
87+
lines = _run(["otool", "-l", str(binary)]).splitlines()
88+
loader = str(binary.parent)
89+
for index, line in enumerate(lines):
90+
if line.strip() != "cmd LC_RPATH":
91+
continue
92+
for follow in lines[index + 1 : index + 4]:
93+
stripped = follow.strip()
94+
if stripped.startswith("path "):
95+
raw = stripped.split(" ", 2)[1]
96+
for placeholder in ("@loader_path", "@executable_path"):
97+
raw = raw.replace(placeholder, loader)
98+
yield raw
99+
break
100+
101+
102+
def _resolve_install_name(binary: Path, name: str, rpaths: list[str]) -> str:
103+
if name.startswith("/"):
104+
return name # absolute install name: dyld uses it verbatim
105+
if name.startswith(("@loader_path/", "@executable_path/")):
106+
candidate = binary.parent / name.split("/", 1)[1]
107+
return str(candidate) if candidate.exists() else _NOT_FOUND
108+
if name.startswith("@rpath/"):
109+
suffix = name[len("@rpath/") :]
110+
for rpath in rpaths:
111+
candidate = Path(rpath) / suffix
112+
if candidate.exists():
113+
return str(candidate)
114+
return _NOT_FOUND
115+
116+
117+
def _resolved_libs_macos(binary: Path) -> dict[str, str]:
118+
# One otool -L call lists every dependency's install name; resolve each statically.
119+
lines = _run(["otool", "-L", str(binary)]).splitlines()[1:]
120+
names = [name for name in (line.strip().split(" ", 1)[0] for line in lines) if name]
121+
has_rpath = any(name.startswith("@rpath/") for name in names)
122+
rpaths = list(_macos_rpaths(binary)) if has_rpath else []
123+
libs: dict[str, str] = {}
124+
for name in names:
125+
libs[name.rsplit("/", 1)[-1]] = _resolve_install_name(binary, name, rpaths)
126+
return libs
127+
128+
129+
def _resolved_libs(binary: Path) -> dict[str, str]:
130+
if platform.system() == "Darwin":
131+
return _resolved_libs_macos(binary)
132+
return _resolved_libs_linux(binary)
133+
134+
135+
def _check(
136+
name: str, libs: dict[str, str], soname: str, expected_prefix: str
137+
) -> bool | None:
138+
# None: the binary does not depend on `soname`. True/False: pass/fail (logged).
139+
path = libs.get(soname)
140+
if path is None:
141+
return None
142+
if path == _NOT_FOUND:
143+
_logger.error("FAIL %s: %s not found at runtime", name, soname)
144+
return False
145+
if path == expected_prefix or path.startswith(f"{expected_prefix}/"):
146+
_logger.info("ok %s: %s => %s", name, soname, path)
147+
return True
148+
msg = "FAIL %s: %s => %s (want under %s)"
149+
_logger.error(msg, name, soname, path, expected_prefix)
150+
return False
151+
152+
153+
def _verify(binaries: list[Path], sonames: list[str], expected_prefix: str) -> bool:
154+
ok = True
155+
seen: set[str] = set()
156+
for binary in binaries:
157+
libs = _resolved_libs(binary)
158+
for soname in sonames:
159+
result = _check(binary.name, libs, soname, expected_prefix)
160+
if result is None:
161+
continue
162+
seen.add(soname)
163+
ok = ok and result
164+
# Guard against silently verifying nothing (wrong build dir or unexpected linkage).
165+
for soname in sonames:
166+
if soname not in seen:
167+
_logger.error("FAIL no binary depends on %s; nothing verified", soname)
168+
ok = False
169+
return ok
170+
171+
172+
def main() -> bool:
173+
logging.basicConfig(level=logging.INFO, format="%(message)s")
174+
175+
build_dir = Path(os.environ["usage_build_dir"]) # noqa: SIM112
176+
stdlib = os.environ["usage_stdlib"] # noqa: SIM112
177+
expected_prefix = os.environ["usage_expected_prefix"].rstrip("/") # noqa: SIM112
178+
179+
sonames = _SONAMES.get((stdlib, platform.system()))
180+
if sonames is None:
181+
_logger.error("Unsupported: stdlib=%s platform=%s", stdlib, platform.system())
182+
return False
183+
binaries = list(_iter_binaries(build_dir))
184+
if not binaries:
185+
_logger.error("No binaries found under %s", build_dir)
186+
return False
187+
188+
if not _verify(binaries, sonames, expected_prefix):
189+
return False
190+
_logger.info("runtime-stdlib OK: %s loads from %s", stdlib, expected_prefix)
191+
return True
192+
193+
194+
if __name__ == "__main__":
195+
if not main():
196+
sys.exit(1)

0 commit comments

Comments
 (0)