Skip to content

Commit 3edc7a9

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

2 files changed

Lines changed: 260 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_BUILDTYPE: ${{ matrix.buildType }}
151+
MATRIX_CXXLIB: ${{ matrix.cxxLib }}
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: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
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 shlex
18+
import subprocess
19+
import sys
20+
from pathlib import Path
21+
from typing import TYPE_CHECKING
22+
23+
if TYPE_CHECKING:
24+
from collections.abc import Iterator
25+
26+
_logger = logging.getLogger(os.environ.get("MISE_TASK_NAME", __name__))
27+
28+
# Sentinel: the binary depends on the SONAME but the loader cannot resolve it.
29+
_NOT_FOUND = "<not-found>"
30+
31+
# Runtime libraries whose load path is pinned, per (stdlib, platform). For libc++ we
32+
# also pin libunwind (LLVM's unwinder, linked via -lunwind).
33+
_SONAMES: dict[tuple[str, str], list[str]] = {
34+
("libstdc++", "Linux"): ["libstdc++.so.6"],
35+
("libstdc++", "Darwin"): ["libstdc++.6.dylib"],
36+
("libc++", "Linux"): ["libc++.so.1", "libunwind.so.1"],
37+
("libc++", "Darwin"): ["libc++.1.dylib", "libunwind.1.dylib"],
38+
}
39+
40+
# MIME types (per `file --mime-type`) of the artifacts we inspect: ELF executables
41+
# (position-independent or not), shared libraries, and Mach-O images. Objects,
42+
# archives and text files report other types and are skipped.
43+
_BINARY_MIME_TYPES = frozenset(
44+
{
45+
"application/x-executable",
46+
"application/x-pie-executable",
47+
"application/x-sharedlib",
48+
"application/x-mach-binary",
49+
}
50+
)
51+
52+
53+
def _run(args: list[str]) -> str:
54+
result = subprocess.run( # noqa: S603
55+
args,
56+
check=False,
57+
text=True,
58+
stdin=subprocess.DEVNULL,
59+
capture_output=True,
60+
)
61+
if result.returncode != 0:
62+
# Surface unexpected tool failures instead of silently skipping the binary.
63+
_logger.warning(
64+
"%s exited %d: %s",
65+
shlex.join(args),
66+
result.returncode,
67+
result.stderr.strip(),
68+
)
69+
return result.stdout
70+
71+
72+
def _is_binary(path: Path) -> bool:
73+
mime = _run(["file", "--brief", "--mime-type", str(path)]).strip()
74+
return mime in _BINARY_MIME_TYPES
75+
76+
77+
def _iter_binaries(build_dir: Path) -> Iterator[Path]:
78+
for path in sorted(build_dir.rglob("*")):
79+
if "CMakeFiles" in path.parts or not path.is_file():
80+
continue
81+
if _is_binary(path):
82+
yield path
83+
84+
85+
def _resolved_libs_linux(binary: Path) -> dict[str, str]:
86+
# One ldd call yields "SONAME => /resolved/path" (or "=> not found") per dependency.
87+
libs: dict[str, str] = {}
88+
for line in _run(["ldd", str(binary)]).splitlines():
89+
fields = line.split()
90+
if "=>" not in fields:
91+
continue
92+
target = fields[fields.index("=>") + 1]
93+
libs[fields[0]] = _NOT_FOUND if target == "not" else target
94+
return libs
95+
96+
97+
def _expand_at_path(raw: str, loader: str, *, is_dylib: bool) -> str:
98+
# @loader_path -> the inspected file's own dir (valid for exe and dylib).
99+
# @executable_path -> the main executable's dir, which equals the inspected file's
100+
# dir only when this file is the executable; for a dylib it can't resolve here.
101+
raw = raw.replace("@loader_path", loader)
102+
if not is_dylib:
103+
raw = raw.replace("@executable_path", loader)
104+
return raw
105+
106+
107+
def _macos_rpaths(
108+
load_lines: list[str], loader: str, *, is_dylib: bool
109+
) -> Iterator[str]:
110+
for index, line in enumerate(load_lines):
111+
if line.strip() != "cmd LC_RPATH":
112+
continue
113+
for follow in load_lines[index + 1 : index + 4]:
114+
stripped = follow.strip()
115+
if stripped.startswith("path "):
116+
path = stripped.split(" ", 2)[1]
117+
yield _expand_at_path(path, loader, is_dylib=is_dylib)
118+
break
119+
120+
121+
def _resolve_install_name(
122+
binary: Path, name: str, rpaths: list[str], *, is_dylib: bool
123+
) -> str:
124+
if name.startswith("/"):
125+
# Absolute install name: dyld uses it verbatim. A missing file is unresolved,
126+
# consistent with the @rpath / @loader_path branches (and avoids a false pass
127+
# when expected_prefix is broad).
128+
return name if Path(name).exists() else _NOT_FOUND
129+
if name.startswith("@rpath/"):
130+
suffix = name[len("@rpath/") :]
131+
for rpath in rpaths:
132+
candidate = Path(rpath) / suffix
133+
if candidate.exists():
134+
return str(candidate)
135+
return _NOT_FOUND
136+
expanded = _expand_at_path(name, str(binary.parent), is_dylib=is_dylib)
137+
if expanded.startswith("/"): # @loader_path, or @executable_path for an executable
138+
return expanded if Path(expanded).exists() else _NOT_FOUND
139+
return _NOT_FOUND # e.g. @executable_path in a dylib: not statically resolvable
140+
141+
142+
def _resolved_libs_macos(binary: Path) -> dict[str, str]:
143+
# otool -l gives LC_ID_DYLIB (exe vs dylib) and LC_RPATH; otool -L the dependencies.
144+
load_lines = _run(["otool", "-l", str(binary)]).splitlines()
145+
is_dylib = any(line.strip() == "cmd LC_ID_DYLIB" for line in load_lines)
146+
loader = str(binary.parent)
147+
dep_lines = _run(["otool", "-L", str(binary)]).splitlines()[1:]
148+
names = [n for n in (line.strip().split(" ", 1)[0] for line in dep_lines) if n]
149+
rpaths: list[str] = []
150+
if any(n.startswith("@rpath/") for n in names):
151+
rpaths = list(_macos_rpaths(load_lines, loader, is_dylib=is_dylib))
152+
libs: dict[str, str] = {}
153+
for name in names:
154+
libs[name.rsplit("/", 1)[-1]] = _resolve_install_name(
155+
binary, name, rpaths, is_dylib=is_dylib
156+
)
157+
return libs
158+
159+
160+
def _resolved_libs(binary: Path) -> dict[str, str]:
161+
if platform.system() == "Darwin":
162+
return _resolved_libs_macos(binary)
163+
return _resolved_libs_linux(binary)
164+
165+
166+
def _under_prefix(path: str, prefix: str) -> bool:
167+
# Resolve both sides so a Homebrew opt/... prefix matches its Cellar real path.
168+
real_path = Path(path).resolve()
169+
real_prefix = Path(prefix).resolve()
170+
return real_path == real_prefix or real_prefix in real_path.parents
171+
172+
173+
def _check(
174+
name: str, libs: dict[str, str], soname: str, expected_prefix: str
175+
) -> bool | None:
176+
# None: the binary does not depend on `soname`. True/False: pass/fail (logged).
177+
path = libs.get(soname)
178+
if path is None:
179+
return None
180+
if path == _NOT_FOUND:
181+
_logger.error("FAIL %s: %s not found at runtime", name, soname)
182+
return False
183+
if _under_prefix(path, expected_prefix):
184+
_logger.info("ok %s: %s => %s", name, soname, path)
185+
return True
186+
msg = "FAIL %s: %s => %s (want under %s)"
187+
_logger.error(msg, name, soname, path, expected_prefix)
188+
return False
189+
190+
191+
def _verify(binaries: list[Path], sonames: list[str], expected_prefix: str) -> bool:
192+
ok = True
193+
seen: set[str] = set()
194+
for binary in binaries:
195+
libs = _resolved_libs(binary)
196+
for soname in sonames:
197+
result = _check(binary.name, libs, soname, expected_prefix)
198+
if result is None:
199+
continue
200+
seen.add(soname)
201+
ok = ok and result
202+
# Guard against silently verifying nothing (wrong build dir or unexpected linkage).
203+
for soname in sonames:
204+
if soname not in seen:
205+
_logger.error("FAIL no binary depends on %s; nothing verified", soname)
206+
ok = False
207+
return ok
208+
209+
210+
def main() -> bool:
211+
logging.basicConfig(level=logging.INFO, format="%(message)s")
212+
213+
build_dir = Path(os.environ["usage_build_dir"]) # noqa: SIM112
214+
stdlib = os.environ["usage_stdlib"] # noqa: SIM112
215+
expected_prefix = os.environ["usage_expected_prefix"].rstrip("/") # noqa: SIM112
216+
217+
sonames = _SONAMES.get((stdlib, platform.system()))
218+
if sonames is None:
219+
_logger.error("Unsupported: stdlib=%s platform=%s", stdlib, platform.system())
220+
return False
221+
binaries = list(_iter_binaries(build_dir))
222+
if not binaries:
223+
_logger.error("No binaries found under %s", build_dir)
224+
return False
225+
226+
if not _verify(binaries, sonames, expected_prefix):
227+
return False
228+
_logger.info("runtime-stdlib OK: %s loads from %s", stdlib, expected_prefix)
229+
return True
230+
231+
232+
if __name__ == "__main__":
233+
if not main():
234+
sys.exit(1)

0 commit comments

Comments
 (0)