Skip to content

Commit b0366ad

Browse files
Merge #7773: ci: fail the build when an executable embeds a standard library header path
b6b9b75 ci: run the stdlib header path check on the linux64 and mac builds (UdjinM6) 4471e14 guix: fail the build when a binary embeds a stdlib header path (UdjinM6) Pull request description: ## Issue being fixed or feature implemented A source location captured inside a standard library header is wrong twice over: `gsl::details::terminate()` prints it, so a failed precondition names a standard library internal instead of the code that broke it, and the recorded path belongs to the toolchain, so on darwin it comes from the macOS SDK - whose location differs between builders and makes the release binaries irreproducible. Nothing catches that today. 966f532 introduced exactly this bug in `evo/creditpool.cpp` and it went unnoticed until guix attestation for v24.0.0-rc.1 disagreed on the macOS hashes: just over seven days after the commit landed, and only because one builder happened to keep their SDK somewhere other than the rest. Comparing hashes across builders is a poor detector. It needs a second builder, a full release build, and a difference in their setups before it fires at all, and it fires at the worst possible moment. ## What was done? A check that fires on a single build instead. `contrib/guix/stdlib-path-check.py` parses each executable with LIEF and fails if one embeds a C++ standard library header path, wired as a `check-stdlib-paths` make target beside the existing `check-symbols` and `check-security`, and invoked from the guix build. It also runs in regular CI, behind `RUN_STDLIB_PATH_CHECK`, enabled for the `linux64` and `mac` targets. `check-symbols` could not be run there - it caps GLIBC at 2.31 (`contrib/guix/symbol-check.py:31-38`) while a native CI build links against the container's far newer glibc, so it asserts a property of release artifacts that a native build legitimately violates - and `check-security` is simply not wired into regular CI today. This check has no such dependence: outside the instrumented builds noted below, an embedded standard library header path is wrong regardless of host or toolchain. Regular CI builds from a `make distdir` tree (`ci/dash/build_src.sh:29-37`), so the script is listed in `BIN_CHECKS`/`EXTRA_DIST`. Both targets matter, and neither is redundant. On libc++ the relocation form of this bug is invisible, because clang proves the moved-from pointer non-null and drops the branch that keeps the string alive; gcc does not. On libstdc++ it is visible. Conversely the original rc.1 form - a raw pointer forwarded into the standard library - is visible on both. So darwin alone cannot cover this class, and `linux64` should not be dropped on the assumption that it does. Only sections that hold string literals are examined: `.rodata`, `.rdata`, `__cstring`, `__const`, and anything with those prefixes, so ELF variants such as `.rodata.str1.1` are included. DWARF legitimately names standard library headers for inlined template code, and skipping debug sections by name is not portable - PE stores a long section name as an offset into the string table, so they read as `/81` rather than `.debug_*`. A first version denied those by name and drowned the mingw build in thousands of DWARF false positives. Naming the sections we want cannot pick up debug data by accident. The trade is deliberate: a literal in an unusual section would be missed, which for a gate that blocks builds is the safer direction. Each hit reports its section, how many times the path occurs, and the first few virtual addresses, so a real literal is distinguishable at a glance from debug data, and can be attributed to the referencing code with `objdump` without a rebuild. A file LIEF cannot parse is also a failure rather than a silent pass. `RUN_STDLIB_PATH_CHECK` defaults to false, and only `linux64` and `mac` opt in. That is a default rather than a hard exclusion - a target that inherited the variable as true would run the check - but no target sets it today. Sanitizer and fuzz builds are deliberately not opted in: their instrumentation records source locations for its own diagnostics and is expected to name standard library headers legitimately, so enabling it there would report instrumentation as a defect. They can be revisited once we know what they actually embed. ## How Has This Been Tested? The check found a real second bug on its first run, which is the strongest evidence for it. After #7760 the linux release binaries still embedded `bits/stl_construct.h`; that turned out to be a separate defect in `gsl::not_null`, fixed in #7772, which source review had missed. Against real artifacts: - the pre-fix guix build of 0f87636 fails on `dashd`, `dash-qt` and `test_dash` - precisely the three binaries that differed between builders on rc.1 - and passes on `dash-cli`, `dash-tx`, `dash-util` and `dash-wallet`, precisely the four that were byte-identical - discrimination was checked directly: an object file containing twelve standard library paths in total reports exactly one, the string literal in `__TEXT,__cstring` - no false positives on an ordinary local non-guix build of `dashd`, `test_dash`, `dash-qt` and `dash-cli` The two halves were separated across branches so each can be observed on its own, since this PR on its own is expected to fail until #7772 merges: - [`check-stdlib-paths`](https://github.com/UdjinM6/dash/commits/refs/heads/check-stdlib-paths/) - this PR, the check without the fix. Regular CI `linux64` and the guix libstdc++ hosts fail on `/usr/include/c++/13/bits/stl_construct.h`; `mac` and both guix darwin hosts pass. That asymmetry is the coverage point above: clang drops the branch that keeps the string alive, gcc does not. - [`check-stdlib-paths-with-fix`](https://github.com/UdjinM6/dash/commits/refs/heads/check-stdlib-paths-with-fix/) - this PR rebased on top of #7772, kept purely as evidence and not proposed for merge. Every guix host passes - `x86_64-linux-gnu`, `riscv64`, `aarch64`, `powerpc64`, `x86_64-w64-mingw32` and both darwin - along with `linux64` and `mac` in regular CI. Taken together: the check fails on a tree with the bug, passes on a tree with the fix, and produces no false positives across four object-format and standard-library combinations. ## Breaking Changes None at runtime. This adds a build-time check; a build that would have produced a binary embedding such a path now fails instead. ## Checklist: - [x] I have performed a self-review of my own code - [x] I have made corresponding changes to the documentation - [x] I have assigned this pull request to a milestone 🤖 Generated with [Claude Code](https://claude.com/claude-code) Top commit has no ACKs. Tree-SHA512: b4941bfb9627910a4152b7b836a1bc92031eeecd5e38bc1765c89dc6509f570933e681193b106461bad01afb560e5ace939c10b0dbc38785aaf8ccf7b44e2a44
2 parents 1e420ba + b6b9b75 commit b0366ad

8 files changed

Lines changed: 117 additions & 1 deletion

File tree

‎Makefile.am‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ DIST_SHARE = \
5555

5656
BIN_CHECKS=$(top_srcdir)/contrib/guix/symbol-check.py \
5757
$(top_srcdir)/contrib/guix/security-check.py \
58+
$(top_srcdir)/contrib/guix/stdlib-path-check.py \
5859
$(top_srcdir)/contrib/devtools/utils.py
5960

6061
WINDOWS_PACKAGING = $(top_srcdir)/share/pixmaps/dash.ico \

‎ci/dash/build_src.sh‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ bash -c "${MAYBE_BEAR} ${MAYBE_TOKEN} make ${MAKEJOBS} ${GOAL}" || ( echo "Build
4747

4848
ccache --version | head -n 1 && ccache --show-stats
4949

50+
# A standard library header path in an executable means a source location was
51+
# captured inside one, which both misreports where a failure came from and, on
52+
# darwin, embeds a path that differs between builders.
53+
if [ "${RUN_STDLIB_PATH_CHECK}" = "true" ]; then
54+
make -C src --jobs=1 check-stdlib-paths
55+
fi
56+
5057
if [ -n "$USE_VALGRIND" ]; then
5158
echo "valgrind in USE!"
5259
"${BASE_ROOT_DIR}/ci/test/wrap-valgrind.sh"

‎ci/test/00_setup_env.sh‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ export USE_BUSY_BOX=${USE_BUSY_BOX:-false}
3939
export RUN_UNIT_TESTS=${RUN_UNIT_TESTS:-true}
4040
export RUN_FUNCTIONAL_TESTS=${RUN_FUNCTIONAL_TESTS:-true}
4141
export RUN_TIDY=${RUN_TIDY:-false}
42+
# Whether to check the built executables for embedded standard library
43+
# header paths. Off by default: sanitizer and fuzz builds record source
44+
# locations for their own diagnostics, so they are expected to name
45+
# standard library headers legitimately.
46+
export RUN_STDLIB_PATH_CHECK=${RUN_STDLIB_PATH_CHECK:-false}
4247
# By how much to scale the test_runner timeouts (option --timeout-factor).
4348
# This is needed because some ci machines have slow CPU or disk, so sanitizers
4449
# might be slow or a reindex might be waiting on disk IO.

‎ci/test/00_setup_env_mac.sh‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ export RUN_UNIT_TESTS=false
1515
export RUN_FUNCTIONAL_TESTS=false
1616
export GOAL="all deploy"
1717
export BITCOIN_CONFIG="--with-gui --enable-reduce-exports --disable-miner"
18+
export RUN_STDLIB_PATH_CHECK=true

‎ci/test/00_setup_env_native_qt5.sh‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ export RUN_UNIT_TESTS="false"
1616
export GOAL="install"
1717
export DOWNLOAD_PREVIOUS_RELEASES="true"
1818
export BITCOIN_CONFIG="--enable-zmq --with-libs=no --enable-reduce-exports CPPFLAGS='-DBOOST_MULTI_INDEX_ENABLE_SAFE_MODE' LDFLAGS=-static-libstdc++"
19+
export RUN_STDLIB_PATH_CHECK=true

‎contrib/guix/libexec/build.sh‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,8 @@ mkdir -p "$DISTSRC"
287287
make -C src --jobs=1 check-security ${V:+V=1}
288288
# Check that executables only contain allowed version symbols.
289289
make -C src --jobs=1 check-symbols ${V:+V=1}
290+
# Check that no executable embeds a standard library header path.
291+
make -C src --jobs=1 check-stdlib-paths ${V:+V=1}
290292

291293
mkdir -p "$OUTDIR"
292294

‎contrib/guix/stdlib-path-check.py‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) 2026 The Dash Core developers
3+
# Distributed under the MIT software license, see the accompanying
4+
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
5+
'''
6+
Check that no executable embeds the path of a C++ standard library header.
7+
8+
Such a path means a source location was captured inside a standard library
9+
header instead of at the call site. gsl::not_null is the way this happens here:
10+
its constructor defaults a nostd::source_location argument, so constructing one
11+
through a forwarding wrapper - std::stack::emplace(), std::optional::emplace(),
12+
std::make_unique() - records the wrapper's header rather than our own code.
13+
14+
That costs us twice. gsl::details::terminate() prints the location, so a failed
15+
precondition names a standard library internal instead of the code that broke
16+
it. And the path is the toolchain's, so on darwin it comes from the macOS SDK,
17+
whose location differs between builders and makes the release binaries
18+
irreproducible.
19+
20+
Only the sections that hold string literals are examined. DWARF legitimately
21+
names standard library headers for inlined template code, and identifying
22+
those sections by name is not portable - PE stores a long section name as an
23+
offset into the string table, so they appear as '/81' rather than '.debug_*'.
24+
Naming the sections we want instead cannot pick up debug data by accident.
25+
26+
Exit status will be 0 if successful, and the program will be silent.
27+
Otherwise the exit status will be 1 and it will log which executables embed
28+
which paths, and the section each was found in - which is what tells you
29+
whether a hit is a string literal or debug data this script failed to skip.
30+
31+
Example usage:
32+
33+
find ../path/to/binaries -type f -executable | xargs python3 contrib/guix/stdlib-path-check.py
34+
'''
35+
import re
36+
import sys
37+
38+
import lief
39+
40+
# Matches the include directory of both libc++ (include/c++/v1) and libstdc++
41+
# (include/c++/<version>), wherever the toolchain or sysroot places it.
42+
STDLIB_INCLUDE = re.compile(rb'[\x20-\x7e]*include/c\+\+/[\x20-\x7e]*')
43+
44+
# Where a string literal ends up: .rodata and its variants on ELF, .rdata on
45+
# PE, __cstring and __const on Mach-O.
46+
LITERAL_SECTIONS = ('.rodata', '.rdata', '__cstring', '__const')
47+
48+
def section_label(section) -> str:
49+
# Mach-O sections are only meaningful together with their segment.
50+
segment = getattr(section, 'segment_name', '')
51+
return f'{segment},{section.name}' if segment else section.name
52+
53+
def holds_string_literals(section) -> bool:
54+
return section.name.startswith(LITERAL_SECTIONS)
55+
56+
def embedded_stdlib_paths(filename) -> list:
57+
binary = lief.parse(filename)
58+
if binary is None:
59+
raise IOError(f'{filename}: unable to parse')
60+
found: dict = {}
61+
for section in binary.sections:
62+
if not holds_string_literals(section):
63+
continue
64+
label = section_label(section)
65+
content = bytes(section.content)
66+
for match in STDLIB_INCLUDE.finditer(content):
67+
path = match.group().decode(errors='replace')
68+
# Identical literals are usually merged, so the count is a lower
69+
# bound on the number of sites. Offsets let a hit be attributed to
70+
# referencing code without rebuilding anything.
71+
found.setdefault((label, path), []).append(
72+
section.virtual_address + match.start())
73+
return sorted((label, path, offsets)
74+
for (label, path), offsets in found.items())
75+
76+
def main() -> None:
77+
retval = 0
78+
for filename in sys.argv[1:]:
79+
try:
80+
paths = embedded_stdlib_paths(filename)
81+
except IOError as e:
82+
print(f'{e}')
83+
retval = 1
84+
continue
85+
for label, path, offsets in paths:
86+
where = ' '.join(f'0x{o:x}' for o in offsets[:4])
87+
if len(offsets) > 4:
88+
where += ' ...'
89+
print(f'{filename}: {label} embeds standard library header path '
90+
f'{path} (x{len(offsets)} at {where})')
91+
retval = 1
92+
sys.exit(retval)
93+
94+
if __name__ == '__main__':
95+
main()

‎src/Makefile.am‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ if BUILD_BITCOIN_CHAINSTATE
159159
bin_PROGRAMS += dash-chainstate
160160
endif
161161

162-
.PHONY: FORCE check-symbols check-security
162+
.PHONY: FORCE check-symbols check-security check-stdlib-paths
163163
# dash core #
164164
BITCOIN_CORE_H = \
165165
active/context.h \
@@ -1495,6 +1495,10 @@ if HARDEN
14951495
$(AM_V_at) $(PYTHON) $(top_srcdir)/contrib/guix/security-check.py $(bin_PROGRAMS)
14961496
endif
14971497

1498+
check-stdlib-paths: $(bin_PROGRAMS)
1499+
@echo "Checking for embedded standard library header paths..."
1500+
$(AM_V_at) $(PYTHON) $(top_srcdir)/contrib/guix/stdlib-path-check.py $(bin_PROGRAMS)
1501+
14981502

14991503
osx_debug: $(bin_PROGRAMS)
15001504
for i in $(bin_PROGRAMS); do mkdir -p $$i.dSYM/Contents/Resources/DWARF && $(DSYMUTIL_FLAT) -o $$i.dSYM/Contents/Resources/DWARF/$$(basename $$i) $$i &> /dev/null ; done

0 commit comments

Comments
 (0)