Skip to content

Commit e091b2b

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

2 files changed

Lines changed: 231 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: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
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 _under_prefix(path: str, prefix: str) -> bool:
138+
# Resolve both sides so a Homebrew opt/... prefix matches its Cellar real path.
139+
real_path = Path(path).resolve()
140+
real_prefix = Path(prefix).resolve()
141+
return real_path == real_prefix or real_prefix in real_path.parents
142+
143+
144+
def _check(
145+
name: str, libs: dict[str, str], soname: str, expected_prefix: str
146+
) -> bool | None:
147+
# None: the binary does not depend on `soname`. True/False: pass/fail (logged).
148+
path = libs.get(soname)
149+
if path is None:
150+
return None
151+
if path == _NOT_FOUND:
152+
_logger.error("FAIL %s: %s not found at runtime", name, soname)
153+
return False
154+
if _under_prefix(path, expected_prefix):
155+
_logger.info("ok %s: %s => %s", name, soname, path)
156+
return True
157+
msg = "FAIL %s: %s => %s (want under %s)"
158+
_logger.error(msg, name, soname, path, expected_prefix)
159+
return False
160+
161+
162+
def _verify(binaries: list[Path], sonames: list[str], expected_prefix: str) -> bool:
163+
ok = True
164+
seen: set[str] = set()
165+
for binary in binaries:
166+
libs = _resolved_libs(binary)
167+
for soname in sonames:
168+
result = _check(binary.name, libs, soname, expected_prefix)
169+
if result is None:
170+
continue
171+
seen.add(soname)
172+
ok = ok and result
173+
# Guard against silently verifying nothing (wrong build dir or unexpected linkage).
174+
for soname in sonames:
175+
if soname not in seen:
176+
_logger.error("FAIL no binary depends on %s; nothing verified", soname)
177+
ok = False
178+
return ok
179+
180+
181+
def main() -> bool:
182+
logging.basicConfig(level=logging.INFO, format="%(message)s")
183+
184+
build_dir = Path(os.environ["usage_build_dir"]) # noqa: SIM112
185+
stdlib = os.environ["usage_stdlib"] # noqa: SIM112
186+
expected_prefix = os.environ["usage_expected_prefix"].rstrip("/") # noqa: SIM112
187+
188+
sonames = _SONAMES.get((stdlib, platform.system()))
189+
if sonames is None:
190+
_logger.error("Unsupported: stdlib=%s platform=%s", stdlib, platform.system())
191+
return False
192+
binaries = list(_iter_binaries(build_dir))
193+
if not binaries:
194+
_logger.error("No binaries found under %s", build_dir)
195+
return False
196+
197+
if not _verify(binaries, sonames, expected_prefix):
198+
return False
199+
_logger.info("runtime-stdlib OK: %s loads from %s", stdlib, expected_prefix)
200+
return True
201+
202+
203+
if __name__ == "__main__":
204+
if not main():
205+
sys.exit(1)

0 commit comments

Comments
 (0)