From ca1f92614e52447821dd05b54c023eb80ce44705 Mon Sep 17 00:00:00 2001 From: Danny van Dyk Date: Sun, 9 Aug 2026 15:49:23 +0100 Subject: [PATCH] Make the macOS dependency bundle self-describing b2 install does not implement DESTDIR the way autotools/CMake do: every libboost_*.dylib ships with a bare (or, for libboost_filesystem, an @rpath) install name and bare/@rpath intra-bundle dependency references, with no LC_RPATH anywhere to resolve them -- confirmed by parsing the published tarball's Mach-O load commands directly. yaml-cpp has the same @rpath defect; GSL/gslcblas/FFTW3 already got their own install names right. None of this is loadable without DYLD_LIBRARY_PATH, which SIP strips on GitHub's runners whenever a protected binary (/bin/sh, /usr/bin/python3, ...) is exec'd -- not a usable workaround. fix-install-names.bash normalises every dylib's install name and every intra-bundle dependency reference to its final absolute ${PREFIX}/lib/ path (applied uniformly to all four dependencies, not just Boost, since the same loop covers them), re-signing ad-hoc after each install_name_tool rewrite invalidates the signature. verify-bundle.bash then re-checks the assembled tarball end-to-end before it is pushed -- absolute install names, no bare/@rpath/DESTDIR references, no LC_RPATH, valid signatures -- so a future dependency bump can't reintroduce this silently. Both scripts were exercised against a harness standing in for otool/install_name_tool/codesign (seeded from the real tarball's load commands), not against the real tools on real macOS hardware. --- .github/workflows/macos-build+deploy.yaml | 11 +++ macos/arm64/fix-install-names.bash | 54 ++++++++++++++ macos/arm64/verify-bundle.bash | 87 +++++++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100755 macos/arm64/fix-install-names.bash create mode 100755 macos/arm64/verify-bundle.bash diff --git a/.github/workflows/macos-build+deploy.yaml b/.github/workflows/macos-build+deploy.yaml index 55b692c..a85d413 100644 --- a/.github/workflows/macos-build+deploy.yaml +++ b/.github/workflows/macos-build+deploy.yaml @@ -72,12 +72,23 @@ jobs: name: Build Boost shell: bash run: ./macos/${{ matrix.version }}/build-boost.bash + - + name: Normalise install names + shell: bash + run: ./macos/${{ matrix.version }}/fix-install-names.bash - name: Create tarball shell: bash run: | cd ${DESTDIR} tar zcf ${OUTPUTDIR}/macos-${{ matrix.version }}.tar.gz * + - + name: Verify bundle is self-describing + shell: bash + run: | + mkdir -p /tmp/VERIFY + tar xzf ${OUTPUTDIR}/macos-${{ matrix.version }}.tar.gz -C /tmp/VERIFY + ./macos/${{ matrix.version }}/verify-bundle.bash /tmp/VERIFY - name: Push tarball to ghcr.io shell: bash diff --git a/macos/arm64/fix-install-names.bash b/macos/arm64/fix-install-names.bash new file mode 100755 index 0000000..c6a73b4 --- /dev/null +++ b/macos/arm64/fix-install-names.bash @@ -0,0 +1,54 @@ +set -eo pipefail +set -x + +LIBDIR="${DESTDIR}${PREFIX}/lib" + +# b2 install (Boost) does not implement DESTDIR the way autotools/CMake do: it leaves +# libboost_*.dylib with a bare LC_ID_DYLIB (or, for libboost_filesystem and the like, +# an @rpath one) and bare/@rpath intra-bundle LC_LOAD_DYLIB references, with no +# LC_RPATH anywhere to resolve them. GSL/yaml-cpp/FFTW3 got their own install name +# right, but not necessarily every reference to a sibling library, so the same loop +# below is applied to every dylib in the bundle rather than special-cased to Boost. +for dylib in "${LIBDIR}"/*.dylib ; do + # The various libfoo.dylib -> libfoo.N.dylib symlinks carry no load commands of + # their own and need no rewriting. + [ -L "${dylib}" ] && continue + + name=$(basename "${dylib}") + + # The install name is what every future consumer (including the eos build itself) + # embeds as its own dependency reference, so it must always be the final absolute + # path -- regardless of whether it is currently bare, @rpath-based, or (were it to + # happen) DESTDIR-prefixed. + install_name_tool -id "${PREFIX}/lib/${name}" "${dylib}" + + # Rewrite every dependency on another dylib shipped in this same bundle -- bare, + # @rpath/-based, or DESTDIR-prefixed -- to its final absolute path. System + # libraries (/usr/lib/..., /System/...) are left untouched. + otool -L "${dylib}" | tail -n +2 | awk '{print $1}' | while read -r dep ; do + case "${dep}" in + /usr/lib/*|/System/*) + continue + ;; + esac + depname=$(basename "${dep}") + if [ -e "${LIBDIR}/${depname}" ] && [ "${dep}" != "${PREFIX}/lib/${depname}" ] ; then + install_name_tool -change "${dep}" "${PREFIX}/lib/${depname}" "${dylib}" + fi + done + + # install_name_tool invalidates the code signature, and arm64 macOS refuses to + # load an unsigned (or invalidated) Mach-O. Do not rely on install_name_tool's + # occasional automatic ad-hoc re-signing -- do it explicitly. + codesign --force --sign - "${dylib}" + + # Fail here, immediately, rather than waiting for the end-to-end verification + # step to notice a rewrite that didn't stick. `otool -L` always echoes the path + # it was given as its first line, and that path is itself inside $DESTDIR at + # this point in the build -- skip it with `tail -n +2`, or this check would + # fail on every single file regardless of what its load commands actually say. + if otool -L "${dylib}" | tail -n +2 | grep -q "${DESTDIR}" ; then + echo "error: ${dylib} still references DESTDIR (${DESTDIR}) after normalisation" >&2 + exit 1 + fi +done diff --git a/macos/arm64/verify-bundle.bash b/macos/arm64/verify-bundle.bash new file mode 100755 index 0000000..a81d5d9 --- /dev/null +++ b/macos/arm64/verify-bundle.bash @@ -0,0 +1,87 @@ +set -eo pipefail +set -x + +# End-to-end gate: run against the extracted contents of the tarball that is about to +# be pushed (not the raw $DESTDIR staging tree), so a packaging mistake is caught too, +# not just a build-script one. Usage: verify-bundle.bash +CHECKDIR="$1" +LIBDIR="${CHECKDIR}${PREFIX}/lib" +BINDIR="${CHECKDIR}${PREFIX}/bin" + +status=0 + +fail() { + echo "error: $1" >&2 + status=1 +} + +# Every real (non-symlink) dylib must have an absolute ${PREFIX}/lib/ install +# name, every non-system dependency must likewise be an absolute ${PREFIX}/lib/... +# path (no bare names, no @rpath/@loader_path/@executable_path), no LC_RPATH may be +# present at all (the bundle is meant to be self-describing via absolute paths, not +# via rpath resolution), no load command may mention the DESTDIR staging path, and the +# ad-hoc signature install_name_tool's rewriting invalidated must have been restored. +for dylib in "${LIBDIR}"/*.dylib ; do + [ -L "${dylib}" ] && continue + name=$(basename "${dylib}") + + id=$(otool -D "${dylib}" | tail -n +2) + if [ "${id}" != "${PREFIX}/lib/${name}" ] ; then + fail "${dylib}: install name is '${id}', expected '${PREFIX}/lib/${name}'" + fi + + # `otool -L`'s first entry after the header is always the file's own LC_ID_DYLIB + # (already checked above, and reported precisely there) -- skip a further line + # with `tail -n +3` so a broken id isn't also reported a second time here as a + # generic "dependency" failure. + while read -r dep ; do + case "${dep}" in + /usr/lib/*|/System/*|"${PREFIX}"/lib/*) + continue + ;; + esac + fail "${dylib}: dependency '${dep}' is not an absolute ${PREFIX}/lib path" + done < <(otool -L "${dylib}" | tail -n +3 | awk '{print $1}') + + if otool -l "${dylib}" | grep -q "cmd LC_RPATH" ; then + fail "${dylib}: carries an LC_RPATH -- the bundle must resolve via absolute paths only" + fi + + # `otool -D`/`-L` echo the path they were given as their first line, which is + # itself under $CHECKDIR here -- skip it with `tail -n +2` so this only inspects + # the actual load-command values, not the argument echoed back. + if otool -D "${dylib}" | tail -n +2 | grep -q "${DESTDIR}" || otool -L "${dylib}" | tail -n +2 | grep -q "${DESTDIR}" ; then + fail "${dylib}: references the DESTDIR staging path (${DESTDIR})" + fi + + if ! codesign --verify --strict "${dylib}" ; then + fail "${dylib}: does not carry a valid code signature" + fi +done + +# The bundle also ships a handful of Mach-O executables (gsl-histogram, gsl-randist, +# fftw-wisdom); they carry no LC_ID_DYLIB but the same dependency/DESTDIR/signature +# invariants apply, and a future dependency bump could break them just as easily. +for exe in "${BINDIR}"/* ; do + [ -L "${exe}" ] && continue + file "${exe}" | grep -q "Mach-O" || continue + + while read -r dep ; do + case "${dep}" in + /usr/lib/*|/System/*|"${PREFIX}"/lib/*) + continue + ;; + esac + fail "${exe}: dependency '${dep}' is not an absolute ${PREFIX}/lib path" + done < <(otool -L "${exe}" | tail -n +2 | awk '{print $1}') + + if otool -L "${exe}" | tail -n +2 | grep -q "${DESTDIR}" ; then + fail "${exe}: references the DESTDIR staging path (${DESTDIR})" + fi + + if ! codesign --verify --strict "${exe}" ; then + fail "${exe}: does not carry a valid code signature" + fi +done + +exit "${status}"