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/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 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