From f4a090dd9149e161ce69ee9a81631bcbff14984e Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:46:09 +0530 Subject: [PATCH] fix(appimage): sanitize build-machine RPATHs + guard libxdo bundling (#3224) (#3776) Co-authored-by: Steven Enamakel --- .github/workflows/pr-quality.yml | 17 ++ .../release/strip-appimage-graphics-libs.sh | 160 +++++++++++++++++- scripts/release/test-strip-appimage-rpaths.sh | 146 ++++++++++++++++ 3 files changed, 319 insertions(+), 4 deletions(-) create mode 100644 scripts/release/test-strip-appimage-rpaths.sh diff --git a/.github/workflows/pr-quality.yml b/.github/workflows/pr-quality.yml index 4f6c8e7c2..f228ff09c 100644 --- a/.github/workflows/pr-quality.yml +++ b/.github/workflows/pr-quality.yml @@ -72,3 +72,20 @@ jobs: 'src/**/README.md' '.github/PULL_REQUEST_TEMPLATE.md' fail: true + appimage-rpath-guard: + name: AppImage RPATH + libxdo guard + runs-on: ubuntu-latest + timeout-minutes: 5 + continue-on-error: true + if: ${{ !contains(github.event.pull_request.labels.*.name, 'docs') && !contains(github.event.pull_request.labels.*.name, 'chore') }} + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 1 + - name: Install patchelf + run: sudo apt-get update -qq && sudo apt-get install -y -qq patchelf + - name: Syntax check release script + run: bash -n scripts/release/strip-appimage-graphics-libs.sh + - name: Run AppImage RPATH sanitize + libxdo guard test (issue #3224) + run: bash scripts/release/test-strip-appimage-rpaths.sh diff --git a/scripts/release/strip-appimage-graphics-libs.sh b/scripts/release/strip-appimage-graphics-libs.sh index 8c90b6e02..baaab8034 100755 --- a/scripts/release/strip-appimage-graphics-libs.sh +++ b/scripts/release/strip-appimage-graphics-libs.sh @@ -365,6 +365,148 @@ validate_sharun_lib_path() { fi } +# is_elf — true if the file begins with the ELF magic, regardless of the +x +# bit. Shared objects (.so) are not executable but still carry the +# DT_RPATH/DT_RUNPATH we need to sanitize, so the executable-only check in +# is_executable_elf is too narrow here. +is_elf() { + local candidate="$1" + [ -f "$candidate" ] || return 1 + [ "$(LC_ALL=C head -c 4 "$candidate" 2>/dev/null || true)" = $'\177ELF' ] +} + +# sanitize_elf_rpaths — strip absolute CI build-machine paths (/home/runner, +# /__w) from the DT_RPATH/DT_RUNPATH of every bundled ELF and rewrite them to +# $ORIGIN-relative entries. +# +# Problem (issue #3224): libcef.so is copied verbatim into usr/lib by the +# vendored sharun_cef bundler and is never ldd-processed by lib4bin, so any +# absolute RUNPATH baked in on the build runner (e.g. +# `/home/runner/.cache/tauri-cef/.../shared/lib`) survives into the shipped +# AppImage. rewrite_sharun_lib_path() above only sanitizes the sharun lib.path +# TEXT file — it never touches ELF headers. Those absolute, non-existent search +# dirs leak the build-machine layout and, on a host that happens to have a +# matching path, could shadow a bundled library. +# +# Fix: patchelf every ELF whose RPATH/RUNPATH contains a CI marker, dropping the +# absolute CI components and keeping $ORIGIN-relative ones. Falls back to a +# conservative `$ORIGIN:$ORIGIN/../shared/lib` when nothing relative remains. +# ELFs whose RPATH is already clean ($ORIGIN-only or empty) are left untouched, +# so the pass is idempotent. +# +# Returns 0 if any ELF was rewritten, 1 otherwise. +sanitize_elf_rpaths() { + local appdir="$1" + if ! command -v patchelf >/dev/null 2>&1; then + echo "[strip-libs] ERROR: patchelf not found; cannot sanitize build-machine RPATHs from bundled ELFs (issue #3224). Install patchelf on the build runner." >&2 + exit 1 + fi + + # Inverted truthiness: 1 == "no change" (shell-false), flips to 0 (shell-true) + # the first time an ELF is rewritten. + local rewrote=1 + local search_roots=( + "$appdir/usr/lib" + "$appdir/shared/lib" + "$appdir/lib" + "$appdir/usr/bin" + "$appdir/bin" + ) + + local root f cur cleaned entry + for root in "${search_roots[@]}"; do + [ -d "$root" ] || continue + while IFS= read -r -d '' f; do + is_elf "$f" || continue + cur="$(patchelf --print-rpath "$f" 2>/dev/null || true)" + [ -n "$cur" ] || continue + case "$cur" in + *"/home/runner/"*|*"/__w/"*) ;; + *) continue ;; # already clean — leave it untouched (idempotent) + esac + + # Keep only $ORIGIN-relative entries; drop absolute / CI-marked ones. + local -a kept=() + local seen="" + local old_ifs="$IFS" + IFS=':' + for entry in $cur; do + IFS="$old_ifs" + [ -n "$entry" ] || { IFS=':'; continue; } + case "$entry" in + '$ORIGIN'*) ;; # relative — keep + *) IFS=':'; continue ;; # absolute — drop + esac + case "+${seen}+" in + *"+${entry}+"*) IFS=':'; continue ;; + esac + seen="${seen}+${entry}" + kept+=("$entry") + IFS=':' + done + IFS="$old_ifs" + + if [ "${#kept[@]}" -eq 0 ]; then + # No relative entry survived — synthesize a fallback that reaches the + # bundle's top-level shared/lib FROM THIS ELF's own directory. $ORIGIN is + # the ELF's dir, so the number of `../` hops equals the ELF's directory + # depth below the AppDir root: a file in usr/lib needs + # `$ORIGIN/../../shared/lib`, one in lib needs `$ORIGIN/../shared/lib`. A + # flat `$ORIGIN/../shared/lib` (the naive form) resolves to + # `usr/shared/lib` for usr/* files and would still miss libs at runtime. + local rel_dir comp up="" + rel_dir="$(dirname "${f#"$appdir"/}")" + local ifs_save="$IFS" + IFS='/' + for comp in $rel_dir; do + [ -n "$comp" ] && [ "$comp" != "." ] && up="../$up" + done + IFS="$ifs_save" + cleaned="\$ORIGIN:\$ORIGIN/${up}shared/lib" + else + cleaned="$(IFS=':'; echo "${kept[*]}")" + fi + + patchelf --set-rpath "$cleaned" "$f" + echo "[strip-libs] patchelf rpath ${f#"$appdir"/}: '$cur' -> '$cleaned'" + rewrote=0 + done < <(find "$root" -type f -print0) + done + + return $rewrote +} + +# validate_appimage_required_libs — fail the build loudly if a library the app +# binary hard-links (NEEDED) but which MUST travel inside the AppImage is absent +# from the bundle. +# +# Problem (issue #3224): the app links libxdo.so.3 via enigo +# (`#[link(name = "xdo")]`, used by src/openhuman/tools/impl/computer for Linux +# mouse/keyboard control). lib4bin's ldd-walk normally bundles it into +# shared/lib, but if a future runner image drops libxdo-dev or bumps its soname, +# the lib silently vanishes from the AppImage and the binary segfaults on launch +# on any host lacking the legacy soname (e.g. Arch, which ships libxdo.so.4). The +# .deb path already guards this via its `depends` (libxdo3) + +# linux_cef_deb_runtime_e2e; the AppImage path had no equivalent. This turns a +# silent runtime segfault into a loud build failure. +validate_appimage_required_libs() { + local appdir="$1" + if ! uses_sharun_launcher "$appdir"; then + return 0 + fi + + local root + for root in "$appdir/shared/lib" "$appdir/usr/lib" "$appdir/lib"; do + [ -d "$root" ] || continue + if [ -n "$(find "$root" -name 'libxdo.so*' -print -quit 2>/dev/null)" ]; then + return 0 + fi + done + + echo "[strip-libs] ERROR: AppImage is missing libxdo.so.* — the enigo NEEDED dependency was not bundled (issue #3224). The app would segfault on launch on hosts without the legacy libxdo soname (e.g. Arch). Ensure libxdo-dev is installed on the build runner so lib4bin's ldd-walk bundles it." >&2 + exit 1 +} + # patch_apprun_sharun_cwd — inject `cd "$APPDIR"` into AppRun before the final # exec call so sharun resolves preload/library paths relative to the AppDir # rather than the caller's CWD. @@ -463,6 +605,7 @@ strip_one_appimage() { local added_loader=0 local rewrote_libpath=0 local patched_apprun=0 + local rewrote_rpaths=0 local lib_roots=() for candidate in \ "$appdir/usr/lib" \ @@ -500,14 +643,18 @@ strip_one_appimage() { if patch_apprun_sharun_cwd "$appdir"; then patched_apprun=1 fi + if sanitize_elf_rpaths "$appdir"; then + rewrote_rpaths=1 + fi validate_sharun_lib_path "$appdir" + validate_appimage_required_libs "$appdir" - if [ "$removed" -eq 0 ] && [ "$added_loader" -eq 0 ] && [ "$rewrote_libpath" -eq 0 ] && [ "$patched_apprun" -eq 0 ]; then - echo "[strip-libs] No graphics libs or missing sharun interpreter found in $original; leaving unchanged." + if [ "$removed" -eq 0 ] && [ "$added_loader" -eq 0 ] && [ "$rewrote_libpath" -eq 0 ] && [ "$patched_apprun" -eq 0 ] && [ "$rewrote_rpaths" -eq 0 ]; then + echo "[strip-libs] No graphics libs, missing sharun interpreter, or build-machine RPATHs found in $original; leaving unchanged." rm -rf "$workdir" return fi - echo "[strip-libs] Removed $removed file(s), added $added_loader loader file(s), patched AppRun=$patched_apprun; repacking AppImage." + echo "[strip-libs] Removed $removed file(s), added $added_loader loader file(s), patched AppRun=$patched_apprun, rewrote RPATHs=$rewrote_rpaths; repacking AppImage." local rebuilt="$workdir/$name" local appimage_arch @@ -577,4 +724,9 @@ main() { done } -main "$@" +# Run main only when executed directly, not when sourced (e.g. by the +# scripts/release/test-strip-appimage-rpaths.sh regression test, which exercises +# sanitize_elf_rpaths / validate_appimage_required_libs in isolation). +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + main "$@" +fi diff --git a/scripts/release/test-strip-appimage-rpaths.sh b/scripts/release/test-strip-appimage-rpaths.sh new file mode 100644 index 000000000..0d71e5551 --- /dev/null +++ b/scripts/release/test-strip-appimage-rpaths.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# Regression test for the issue #3224 hardening in +# strip-appimage-graphics-libs.sh: +# 1. sanitize_elf_rpaths — strips /home/runner|/__w build-machine +# RPATHs from bundled ELFs, rewriting to +# $ORIGIN-relative. +# 2. validate_appimage_required_libs — hard-fails when libxdo.so.* is missing +# from a sharun AppDir. +# +# Linux-only: needs `patchelf` and a host ELF to mutate. Skips cleanly (exit 0) +# on macOS / any host without patchelf so it is a no-op on dev boxes and a real +# gate in CI (where build-desktop.yml already apt-installs patchelf). +# +# Usage: bash scripts/release/test-strip-appimage-rpaths.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TARGET="$SCRIPT_DIR/strip-appimage-graphics-libs.sh" + +skip() { + echo "[test-rpaths] SKIP: $1" + exit 0 +} + +[ -f "$TARGET" ] || { echo "[test-rpaths] FAIL: $TARGET not found" >&2; exit 1; } +command -v patchelf >/dev/null 2>&1 || skip "patchelf not installed (expected on non-Linux dev boxes)" + +# Find a small host ELF we can copy + mutate. +HOST_ELF="" +for cand in /bin/true /usr/bin/true /bin/echo; do + if [ -f "$cand" ] && [ "$(LC_ALL=C head -c 4 "$cand" 2>/dev/null || true)" = $'\177ELF' ]; then + HOST_ELF="$cand" + break + fi +done +[ -n "$HOST_ELF" ] || skip "no host ELF available to build a fixture" + +# Source the target so we can call its functions in isolation. The +# sourced-vs-executed guard at the bottom of the script keeps main() from +# running here. +# shellcheck source=/dev/null +source "$TARGET" + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +fail() { echo "[test-rpaths] FAIL: $1" >&2; exit 1; } + +# --- Case 1: sanitize_elf_rpaths strips a CI build-machine RPATH ------------ +APPDIR="$WORK/squashfs-root" +mkdir -p "$APPDIR/usr/lib" "$APPDIR/shared/lib" + +# An ELF that mimics libcef.so with an absolute build-runner RUNPATH plus a +# legitimate $ORIGIN entry — only the absolute one should be dropped. +FAKE_CEF="$APPDIR/usr/lib/libcef.so" +cp "$HOST_ELF" "$FAKE_CEF" +patchelf --set-rpath '/home/runner/.cache/tauri-cef/x/shared/lib:$ORIGIN/../shared/lib' "$FAKE_CEF" + +# A clean ELF whose RPATH must be left byte-for-byte untouched (idempotency). +CLEAN_SO="$APPDIR/shared/lib/libclean.so" +cp "$HOST_ELF" "$CLEAN_SO" +patchelf --set-rpath '$ORIGIN' "$CLEAN_SO" +clean_before="$(patchelf --print-rpath "$CLEAN_SO")" + +if ! sanitize_elf_rpaths "$APPDIR"; then + fail "sanitize_elf_rpaths reported no change but a CI RPATH was present" +fi + +cef_after="$(patchelf --print-rpath "$FAKE_CEF")" +case "$cef_after" in + *"/home/runner/"*|*"/__w/"*) fail "CI RPATH survived: '$cef_after'" ;; +esac +case "$cef_after" in + *'$ORIGIN'*) ;; + *) fail "expected an \$ORIGIN-relative RPATH after sanitize, got: '$cef_after'" ;; +esac +echo "[test-rpaths] ok: libcef.so RPATH rewritten to '$cef_after'" + +clean_after="$(patchelf --print-rpath "$CLEAN_SO")" +[ "$clean_before" = "$clean_after" ] || fail "clean ELF RPATH was mutated: '$clean_before' -> '$clean_after'" +echo "[test-rpaths] ok: clean ELF left untouched ('$clean_after')" + +# Idempotency: a second pass must find nothing to do. +if sanitize_elf_rpaths "$APPDIR"; then + fail "sanitize_elf_rpaths was not idempotent — rewrote on a clean second pass" +fi +echo "[test-rpaths] ok: second pass is a no-op (idempotent)" + +# --- Case 1b: pure-absolute CI RPATH → depth-aware fallback ------------------ +# This is the issue #3224 scenario: libcef.so carries ONLY an absolute +# build-machine RPATH, no surviving $ORIGIN entry. The fallback MUST reach the +# bundle's top-level shared/lib from usr/lib, i.e. `$ORIGIN/../../shared/lib` +# (two hops), NOT the naive `$ORIGIN/../shared/lib` which resolves to +# usr/shared/lib. +ABS_DIR="$WORK/abs" +mkdir -p "$ABS_DIR/usr/lib" +ABS_CEF="$ABS_DIR/usr/lib/libcef.so" +cp "$HOST_ELF" "$ABS_CEF" +patchelf --set-rpath '/home/runner/.cache/tauri-cef/x/shared/lib' "$ABS_CEF" +sanitize_elf_rpaths "$ABS_DIR" || fail "sanitize_elf_rpaths reported no change on a pure-absolute RPATH" +abs_after="$(patchelf --print-rpath "$ABS_CEF")" +[ "$abs_after" = '$ORIGIN:$ORIGIN/../../shared/lib' ] \ + || fail "usr/lib fallback wrong: expected '\$ORIGIN:\$ORIGIN/../../shared/lib', got '$abs_after'" +echo "[test-rpaths] ok: usr/lib pure-absolute RPATH → depth-aware fallback '$abs_after'" + +# A lib one level deep (lib/) needs only one hop. +mkdir -p "$ABS_DIR/lib" +ABS_LIB="$ABS_DIR/lib/libfoo.so" +cp "$HOST_ELF" "$ABS_LIB" +patchelf --set-rpath '/__w/openhuman/openhuman/shared/lib' "$ABS_LIB" +sanitize_elf_rpaths "$ABS_DIR" >/dev/null || true +lib_after="$(patchelf --print-rpath "$ABS_LIB")" +[ "$lib_after" = '$ORIGIN:$ORIGIN/../shared/lib' ] \ + || fail "lib/ fallback wrong: expected '\$ORIGIN:\$ORIGIN/../shared/lib', got '$lib_after'" +echo "[test-rpaths] ok: lib/ pure-absolute RPATH → depth-aware fallback '$lib_after'" + +# --- Case 2: validate_appimage_required_libs guards libxdo ------------------- +# Build a minimal sharun AppDir. uses_sharun_launcher only inspects *ELF* entry +# binaries (is_executable_elf) and greps them for the literal +# "Interpreter not found!" string, mirroring the real sharun launcher binary. +# So the fixture must be an executable ELF with that marker appended (a shell +# AppRun would be skipped as non-ELF and the guard would no-op). +GUARD_DIR="$WORK/guard" +mkdir -p "$GUARD_DIR/shared/lib" +cp "$HOST_ELF" "$GUARD_DIR/sharun" +printf 'Interpreter not found!' >> "$GUARD_DIR/sharun" +chmod +x "$GUARD_DIR/sharun" +# Sanity: the fixture must actually register as a sharun launcher, else the +# guard's early return would make this case vacuously pass. +uses_sharun_launcher "$GUARD_DIR" || fail "fixture did not register as sharun launcher" + +# 2a — libxdo absent → must exit non-zero. +if ( validate_appimage_required_libs "$GUARD_DIR" ) 2>/dev/null; then + fail "validate_appimage_required_libs passed despite missing libxdo.so.*" +fi +echo "[test-rpaths] ok: guard fails when libxdo.so.* is absent" + +# 2b — libxdo present → must pass. +: > "$GUARD_DIR/shared/lib/libxdo.so.3" +if ! ( validate_appimage_required_libs "$GUARD_DIR" ) 2>/dev/null; then + fail "validate_appimage_required_libs failed despite libxdo.so.3 present" +fi +echo "[test-rpaths] ok: guard passes when libxdo.so.3 is present" + +echo "[test-rpaths] PASS"