Skip to content

Commit 3c44a71

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

2 files changed

Lines changed: 244 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: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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 _macos_rpaths(binary: Path) -> Iterator[str]:
98+
lines = _run(["otool", "-l", str(binary)]).splitlines()
99+
loader = str(binary.parent)
100+
# @executable_path is the *main executable's* dir. For an executable that is
101+
# binary.parent; for a dylib it depends on the loading process and can't be
102+
# resolved statically, so it is left unexpanded (a dylib carries LC_ID_DYLIB).
103+
is_dylib = any(line.strip() == "cmd LC_ID_DYLIB" for line in lines)
104+
for index, line in enumerate(lines):
105+
if line.strip() != "cmd LC_RPATH":
106+
continue
107+
for follow in lines[index + 1 : index + 4]:
108+
stripped = follow.strip()
109+
if stripped.startswith("path "):
110+
raw = stripped.split(" ", 2)[1].replace("@loader_path", loader)
111+
if not is_dylib:
112+
raw = raw.replace("@executable_path", loader)
113+
yield raw
114+
break
115+
116+
117+
def _resolve_install_name(binary: Path, name: str, rpaths: list[str]) -> str:
118+
if name.startswith("/"):
119+
return name # absolute install name: dyld uses it verbatim
120+
if name.startswith(("@loader_path/", "@executable_path/")):
121+
candidate = binary.parent / name.split("/", 1)[1]
122+
return str(candidate) if candidate.exists() else _NOT_FOUND
123+
if name.startswith("@rpath/"):
124+
suffix = name[len("@rpath/") :]
125+
for rpath in rpaths:
126+
candidate = Path(rpath) / suffix
127+
if candidate.exists():
128+
return str(candidate)
129+
return _NOT_FOUND
130+
131+
132+
def _resolved_libs_macos(binary: Path) -> dict[str, str]:
133+
# One otool -L call lists every dependency's install name; resolve each statically.
134+
lines = _run(["otool", "-L", str(binary)]).splitlines()[1:]
135+
names = [name for name in (line.strip().split(" ", 1)[0] for line in lines) if name]
136+
has_rpath = any(name.startswith("@rpath/") for name in names)
137+
rpaths = list(_macos_rpaths(binary)) if has_rpath else []
138+
libs: dict[str, str] = {}
139+
for name in names:
140+
libs[name.rsplit("/", 1)[-1]] = _resolve_install_name(binary, name, rpaths)
141+
return libs
142+
143+
144+
def _resolved_libs(binary: Path) -> dict[str, str]:
145+
if platform.system() == "Darwin":
146+
return _resolved_libs_macos(binary)
147+
return _resolved_libs_linux(binary)
148+
149+
150+
def _under_prefix(path: str, prefix: str) -> bool:
151+
# Resolve both sides so a Homebrew opt/... prefix matches its Cellar real path.
152+
real_path = Path(path).resolve()
153+
real_prefix = Path(prefix).resolve()
154+
return real_path == real_prefix or real_prefix in real_path.parents
155+
156+
157+
def _check(
158+
name: str, libs: dict[str, str], soname: str, expected_prefix: str
159+
) -> bool | None:
160+
# None: the binary does not depend on `soname`. True/False: pass/fail (logged).
161+
path = libs.get(soname)
162+
if path is None:
163+
return None
164+
if path == _NOT_FOUND:
165+
_logger.error("FAIL %s: %s not found at runtime", name, soname)
166+
return False
167+
if _under_prefix(path, expected_prefix):
168+
_logger.info("ok %s: %s => %s", name, soname, path)
169+
return True
170+
msg = "FAIL %s: %s => %s (want under %s)"
171+
_logger.error(msg, name, soname, path, expected_prefix)
172+
return False
173+
174+
175+
def _verify(binaries: list[Path], sonames: list[str], expected_prefix: str) -> bool:
176+
ok = True
177+
seen: set[str] = set()
178+
for binary in binaries:
179+
libs = _resolved_libs(binary)
180+
for soname in sonames:
181+
result = _check(binary.name, libs, soname, expected_prefix)
182+
if result is None:
183+
continue
184+
seen.add(soname)
185+
ok = ok and result
186+
# Guard against silently verifying nothing (wrong build dir or unexpected linkage).
187+
for soname in sonames:
188+
if soname not in seen:
189+
_logger.error("FAIL no binary depends on %s; nothing verified", soname)
190+
ok = False
191+
return ok
192+
193+
194+
def main() -> bool:
195+
logging.basicConfig(level=logging.INFO, format="%(message)s")
196+
197+
build_dir = Path(os.environ["usage_build_dir"]) # noqa: SIM112
198+
stdlib = os.environ["usage_stdlib"] # noqa: SIM112
199+
expected_prefix = os.environ["usage_expected_prefix"].rstrip("/") # noqa: SIM112
200+
201+
sonames = _SONAMES.get((stdlib, platform.system()))
202+
if sonames is None:
203+
_logger.error("Unsupported: stdlib=%s platform=%s", stdlib, platform.system())
204+
return False
205+
binaries = list(_iter_binaries(build_dir))
206+
if not binaries:
207+
_logger.error("No binaries found under %s", build_dir)
208+
return False
209+
210+
if not _verify(binaries, sonames, expected_prefix):
211+
return False
212+
_logger.info("runtime-stdlib OK: %s loads from %s", stdlib, expected_prefix)
213+
return True
214+
215+
216+
if __name__ == "__main__":
217+
if not main():
218+
sys.exit(1)

0 commit comments

Comments
 (0)