From 4471e1495c35276928076bc91c5937784404c62a Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Tue, 29 Sep 2026 14:14:29 +0300 Subject: [PATCH 1/2] guix: fail the build when a binary embeds a stdlib header path gsl::not_null defaults a nostd::source_location argument in its constructor (src/gsl/pointers.h), so constructing one through a forwarding wrapper - std::stack::emplace(), std::optional::emplace(), std::make_unique() - captures the wrapper's own header instead of the call site. Nothing catches that today. It costs us twice. gsl::details::terminate() prints the captured location (src/gsl/assert.cpp), so a failed precondition names a standard library internal rather than the code that broke it. And the recorded path belongs to the toolchain, so on darwin it comes from the macOS SDK, whose location varies between builders and makes the release binaries irreproducible. 966f53207b did exactly this in evo/creditpool.cpp and it went unnoticed until guix attestation for v24.0.0-rc.1 disagreed on the macOS hashes - six days after the commit landed, and only because one builder kept 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 to fire at all. Add a check that fires on a single build instead, on any host. Verified against the real artifacts: the pre-fix build of 0f876369f9 fails on dashd, dash-qt and test_dash, which are precisely the three binaries that differed between builders, and passes on dash-cli, dash-tx, dash-util and dash-wallet, which are precisely the four that were byte-identical. Only the sections that hold string literals are examined. DWARF names standard library headers legitimately, 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_*' and a denylist lets the whole DWARF include-directory table through. Naming the sections we want cannot pick up debug data by accident - an object file carrying twelve stdlib paths reports only the one in __TEXT,__cstring. Each hit is reported with its section and offset, so a real literal is distinguishable at a glance and can be attributed to referencing code without a rebuild. Co-Authored-By: Claude Opus 5 --- Makefile.am | 1 + contrib/guix/libexec/build.sh | 2 + contrib/guix/stdlib-path-check.py | 95 +++++++++++++++++++++++++++++++ src/Makefile.am | 6 +- 4 files changed, 103 insertions(+), 1 deletion(-) create mode 100755 contrib/guix/stdlib-path-check.py diff --git a/Makefile.am b/Makefile.am index d16a7081bda6..2eaf58ff3c54 100644 --- a/Makefile.am +++ b/Makefile.am @@ -55,6 +55,7 @@ DIST_SHARE = \ BIN_CHECKS=$(top_srcdir)/contrib/guix/symbol-check.py \ $(top_srcdir)/contrib/guix/security-check.py \ + $(top_srcdir)/contrib/guix/stdlib-path-check.py \ $(top_srcdir)/contrib/devtools/utils.py WINDOWS_PACKAGING = $(top_srcdir)/share/pixmaps/dash.ico \ diff --git a/contrib/guix/libexec/build.sh b/contrib/guix/libexec/build.sh index d1739d374f4d..a28778895730 100755 --- a/contrib/guix/libexec/build.sh +++ b/contrib/guix/libexec/build.sh @@ -287,6 +287,8 @@ mkdir -p "$DISTSRC" make -C src --jobs=1 check-security ${V:+V=1} # Check that executables only contain allowed version symbols. make -C src --jobs=1 check-symbols ${V:+V=1} + # Check that no executable embeds a standard library header path. + make -C src --jobs=1 check-stdlib-paths ${V:+V=1} mkdir -p "$OUTDIR" diff --git a/contrib/guix/stdlib-path-check.py b/contrib/guix/stdlib-path-check.py new file mode 100755 index 000000000000..c58abbb13c11 --- /dev/null +++ b/contrib/guix/stdlib-path-check.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Dash Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +''' +Check that no executable embeds the path of a C++ standard library header. + +Such a path means a source location was captured inside a standard library +header instead of at the call site. gsl::not_null is the way this happens here: +its constructor defaults a nostd::source_location argument, so constructing one +through a forwarding wrapper - std::stack::emplace(), std::optional::emplace(), +std::make_unique() - records the wrapper's header rather than our own code. + +That costs us twice. gsl::details::terminate() prints the location, so a failed +precondition names a standard library internal instead of the code that broke +it. And the path is the toolchain's, so on darwin it comes from the macOS SDK, +whose location differs between builders and makes the release binaries +irreproducible. + +Only the sections that hold string literals are examined. DWARF legitimately +names standard library headers for inlined template code, and identifying +those sections by name is not portable - PE stores a long section name as an +offset into the string table, so they appear as '/81' rather than '.debug_*'. +Naming the sections we want instead cannot pick up debug data by accident. + +Exit status will be 0 if successful, and the program will be silent. +Otherwise the exit status will be 1 and it will log which executables embed +which paths, and the section each was found in - which is what tells you +whether a hit is a string literal or debug data this script failed to skip. + +Example usage: + + find ../path/to/binaries -type f -executable | xargs python3 contrib/guix/stdlib-path-check.py +''' +import re +import sys + +import lief + +# Matches the include directory of both libc++ (include/c++/v1) and libstdc++ +# (include/c++/), wherever the toolchain or sysroot places it. +STDLIB_INCLUDE = re.compile(rb'[\x20-\x7e]*include/c\+\+/[\x20-\x7e]*') + +# Where a string literal ends up: .rodata and its variants on ELF, .rdata on +# PE, __cstring and __const on Mach-O. +LITERAL_SECTIONS = ('.rodata', '.rdata', '__cstring', '__const') + +def section_label(section) -> str: + # Mach-O sections are only meaningful together with their segment. + segment = getattr(section, 'segment_name', '') + return f'{segment},{section.name}' if segment else section.name + +def holds_string_literals(section) -> bool: + return section.name.startswith(LITERAL_SECTIONS) + +def embedded_stdlib_paths(filename) -> list: + binary = lief.parse(filename) + if binary is None: + raise IOError(f'{filename}: unable to parse') + found: dict = {} + for section in binary.sections: + if not holds_string_literals(section): + continue + label = section_label(section) + content = bytes(section.content) + for match in STDLIB_INCLUDE.finditer(content): + path = match.group().decode(errors='replace') + # Identical literals are usually merged, so the count is a lower + # bound on the number of sites. Offsets let a hit be attributed to + # referencing code without rebuilding anything. + found.setdefault((label, path), []).append( + section.virtual_address + match.start()) + return sorted((label, path, offsets) + for (label, path), offsets in found.items()) + +def main() -> None: + retval = 0 + for filename in sys.argv[1:]: + try: + paths = embedded_stdlib_paths(filename) + except IOError as e: + print(f'{e}') + retval = 1 + continue + for label, path, offsets in paths: + where = ' '.join(f'0x{o:x}' for o in offsets[:4]) + if len(offsets) > 4: + where += ' ...' + print(f'{filename}: {label} embeds standard library header path ' + f'{path} (x{len(offsets)} at {where})') + retval = 1 + sys.exit(retval) + +if __name__ == '__main__': + main() diff --git a/src/Makefile.am b/src/Makefile.am index 30db5207d67c..b5e88df4d4e4 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -159,7 +159,7 @@ if BUILD_BITCOIN_CHAINSTATE bin_PROGRAMS += dash-chainstate endif -.PHONY: FORCE check-symbols check-security +.PHONY: FORCE check-symbols check-security check-stdlib-paths # dash core # BITCOIN_CORE_H = \ active/context.h \ @@ -1495,6 +1495,10 @@ if HARDEN $(AM_V_at) $(PYTHON) $(top_srcdir)/contrib/guix/security-check.py $(bin_PROGRAMS) endif +check-stdlib-paths: $(bin_PROGRAMS) + @echo "Checking for embedded standard library header paths..." + $(AM_V_at) $(PYTHON) $(top_srcdir)/contrib/guix/stdlib-path-check.py $(bin_PROGRAMS) + osx_debug: $(bin_PROGRAMS) 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 From b6b9b75b855eff8541cfcb691fcb13ed492f2049 Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Tue, 29 Sep 2026 14:14:45 +0300 Subject: [PATCH 2/2] ci: run the stdlib header path check on the linux64 and mac builds The guix build is the wrong place to learn that a source location was captured inside a standard library header: it runs on tags and on labelled pull requests, so a regression can sit on develop for weeks. Nothing about the check needs guix. Unlike check-symbols and check-security, which assert release-artifact properties - the glibc floor, the hardening flags - that a native CI build legitimately violates, an embedded standard library header path is wrong in any build on any host. Run it from build_src.sh behind RUN_STDLIB_PATH_CHECK, enabled for linux64 and mac so both object formats and both standard libraries are covered. It stays off by default rather than on. Sanitizer and fuzz builds record source locations for their own diagnostics and are expected to name standard library headers legitimately, so enabling it there would report instrumentation as a defect. Those targets can be revisited once we know what they actually embed. Note that these builds run from a distdir, so the script has to be listed in EXTRA_DIST alongside the other binary checks, which the preceding commit does. Co-Authored-By: Claude Opus 5 --- ci/dash/build_src.sh | 7 +++++++ ci/test/00_setup_env.sh | 5 +++++ ci/test/00_setup_env_mac.sh | 1 + ci/test/00_setup_env_native_qt5.sh | 1 + 4 files changed, 14 insertions(+) diff --git a/ci/dash/build_src.sh b/ci/dash/build_src.sh index 391224587271..2184966c28eb 100755 --- a/ci/dash/build_src.sh +++ b/ci/dash/build_src.sh @@ -47,6 +47,13 @@ bash -c "${MAYBE_BEAR} ${MAYBE_TOKEN} make ${MAKEJOBS} ${GOAL}" || ( echo "Build ccache --version | head -n 1 && ccache --show-stats +# A standard library header path in an executable means a source location was +# captured inside one, which both misreports where a failure came from and, on +# darwin, embeds a path that differs between builders. +if [ "${RUN_STDLIB_PATH_CHECK}" = "true" ]; then + make -C src --jobs=1 check-stdlib-paths +fi + if [ -n "$USE_VALGRIND" ]; then echo "valgrind in USE!" "${BASE_ROOT_DIR}/ci/test/wrap-valgrind.sh" diff --git a/ci/test/00_setup_env.sh b/ci/test/00_setup_env.sh index 58a492fa7b37..3ebb2140afee 100755 --- a/ci/test/00_setup_env.sh +++ b/ci/test/00_setup_env.sh @@ -39,6 +39,11 @@ export USE_BUSY_BOX=${USE_BUSY_BOX:-false} export RUN_UNIT_TESTS=${RUN_UNIT_TESTS:-true} export RUN_FUNCTIONAL_TESTS=${RUN_FUNCTIONAL_TESTS:-true} export RUN_TIDY=${RUN_TIDY:-false} +# Whether to check the built executables for embedded standard library +# header paths. Off by default: sanitizer and fuzz builds record source +# locations for their own diagnostics, so they are expected to name +# standard library headers legitimately. +export RUN_STDLIB_PATH_CHECK=${RUN_STDLIB_PATH_CHECK:-false} # By how much to scale the test_runner timeouts (option --timeout-factor). # This is needed because some ci machines have slow CPU or disk, so sanitizers # might be slow or a reindex might be waiting on disk IO. diff --git a/ci/test/00_setup_env_mac.sh b/ci/test/00_setup_env_mac.sh index 1c1a738e107d..5b1dd742a525 100755 --- a/ci/test/00_setup_env_mac.sh +++ b/ci/test/00_setup_env_mac.sh @@ -15,3 +15,4 @@ export RUN_UNIT_TESTS=false export RUN_FUNCTIONAL_TESTS=false export GOAL="all deploy" export BITCOIN_CONFIG="--with-gui --enable-reduce-exports --disable-miner" +export RUN_STDLIB_PATH_CHECK=true diff --git a/ci/test/00_setup_env_native_qt5.sh b/ci/test/00_setup_env_native_qt5.sh index 3925c7965a96..09e97fc5fd91 100755 --- a/ci/test/00_setup_env_native_qt5.sh +++ b/ci/test/00_setup_env_native_qt5.sh @@ -16,3 +16,4 @@ export RUN_UNIT_TESTS="false" export GOAL="install" export DOWNLOAD_PREVIOUS_RELEASES="true" export BITCOIN_CONFIG="--enable-zmq --with-libs=no --enable-reduce-exports CPPFLAGS='-DBOOST_MULTI_INDEX_ENABLE_SAFE_MODE' LDFLAGS=-static-libstdc++" +export RUN_STDLIB_PATH_CHECK=true