From 79e2fc25660221544d71ef488c845e63f69f85cb Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Sat, 25 Jul 2026 07:20:12 +0300 Subject: [PATCH] fix(appimage): anchor sharun library paths to AppDir (#5189) --- .github/workflows/build-desktop.yml | 25 +- .../2026-07-24-appimage-sharun-lib-path.md | 943 ++++++++++++++++++ ...6-07-24-appimage-sharun-lib-path-design.md | 215 ++++ .../release/strip-appimage-graphics-libs.sh | 293 ++++-- scripts/release/test-strip-appimage-rpaths.sh | 795 ++++++++++++++- scripts/release/validate-appimage-runtime.sh | 368 +++++++ 6 files changed, 2554 insertions(+), 85 deletions(-) create mode 100644 docs/plans/2026-07-24-appimage-sharun-lib-path.md create mode 100644 docs/specs/2026-07-24-appimage-sharun-lib-path-design.md create mode 100755 scripts/release/validate-appimage-runtime.sh diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 976a50fd2..fe5816aae 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -172,6 +172,8 @@ jobs: rustup target add aarch64-apple-darwin x86_64-apple-darwin - name: Install Tauri dependencies (ubuntu only) if: startsWith(matrix.settings.platform, 'ubuntu-') + env: + MATRIX_TARGET: ${{ matrix.settings.target }} run: | sudo apt-get update sudo apt-get install -y \ @@ -180,7 +182,12 @@ jobs: libevdev-dev libssl-dev libclang-dev desktop-file-utils \ libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \ libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \ - libgbm1 libpango-1.0-0 libcairo2 libatspi2.0-0 libxshmfence1 libu2f-udev + libgbm1 libpango-1.0-0 libcairo2 libatspi2.0-0 libxshmfence1 libu2f-udev \ + xvfb + if [ "$MATRIX_TARGET" = "x86_64-unknown-linux-gnu" ] \ + && ! command -v apparmor_parser >/dev/null 2>&1; then + sudo apt-get install -y apparmor + fi # NOTE: The post-build dump lives further down (after `cargo tauri build`) # so the binary actually exists when ldd runs. Running it here pre-build # silently no-op'd and gave us no signal during the @@ -528,19 +535,19 @@ jobs: done # Strip host graphics libraries (Mesa/libdrm/libva/libgbm) that lib4bin - # ldd-walks into the AppImage. Those drivers must come from the user's - # host system — even on a current Ubuntu runner the bundled Mesa can - # fail to initialize newer GPUs (RDNA3, Intel Arc, Lovelace) and - # crashes CEF on launch because the bundled libs shadow the host's - # working ones on LD_LIBRARY_PATH. CEF's own SwiftShader subdir is - # untouched. Re-signs the AppImage + updater tarball when signing - # is enabled. - - name: Strip host graphics libs from AppImage + # ldd-walks into the AppImage. Final-artifact validation runs before + # re-signing: amd64 also gets a bounded Xvfb launch from a foreign CWD, + # while arm64 receives static validation in this change. CEF's own + # SwiftShader subdir is untouched, and updater archives are rebuilt only + # after the validated image is final. + - name: Strip host graphics libs and validate final AppImage if: startsWith(matrix.settings.platform, 'ubuntu-') shell: bash env: MATRIX_TARGET: ${{ matrix.settings.target }} PROFILE: ${{ inputs.build_profile }} + APPIMAGE_RUNTIME_SMOKE: ${{ matrix.settings.target == 'x86_64-unknown-linux-gnu' && '1' || '0' }} + APPIMAGE_RUNTIME_APPARMOR_USERNS: ${{ matrix.settings.target == 'x86_64-unknown-linux-gnu' && '1' || '0' }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY || secrets.UPDATER_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD || secrets.UPDATER_PRIVATE_KEY_PASSWORD }} run: | diff --git a/docs/plans/2026-07-24-appimage-sharun-lib-path.md b/docs/plans/2026-07-24-appimage-sharun-lib-path.md new file mode 100644 index 000000000..dc2940700 --- /dev/null +++ b/docs/plans/2026-07-24-appimage-sharun-lib-path.md @@ -0,0 +1,943 @@ +# AppImage sharun library-path repair implementation plan + +> Approved design: +> `docs/specs/2026-07-24-appimage-sharun-lib-path-design.md` at +> `baae2b02c73e48826e156a111b7e8aff82f214b9`. + +## Goal + +Repair the Linux AppImage postprocessor so sharun's generated +`shared/lib/lib.path` contains only AppDir-anchored `+` entries, reject malformed +paths before release, and prove that the final repacked amd64 AppImage reaches +normal application startup when invoked from outside its extracted AppDir. + +The implementation changes only the AppImage release path: + +- `scripts/release/strip-appimage-graphics-libs.sh` +- `scripts/release/test-strip-appimage-rpaths.sh` +- a new final-artifact validator at + `scripts/release/validate-appimage-runtime.sh` +- the Linux portion of `.github/workflows/build-desktop.yml` + +The existing graphics-library removal, interpreter replacement, RPATH repair, +artifact signing, updater tarball rebuilding, x86_64/aarch64 build matrix, and +non-Linux workflow branches remain in place. + +## Scope guardrails + +This plan intentionally does **not** implement the broader generated triage plan +currently embedded in issue #5037: + +- The unbundled `openhuman-core`/AUR failure remains a downstream packaging and + optional-`enigo` follow-up. Do not change `Cargo.toml`, feature gates, voice + input, computer tools, `INSTALL.md`, or AUR metadata in this work. +- Do not change bundled `libssl.so.3`/`libcrypto.so.3`, NSS exclusions, or claim + to fix the later OpenSSL symbol-skew/segfault behavior in #3716. The smoke + catches an immediate loader failure, not every later CEF/OpenSSL crash. +- Do not pin the mutable `quick-sharun.sh` or uruntime downloads in the vendored + Tauri bundler. Supply-chain pinning is a separate follow-up. +- Do not add an Arch/Fedora/Tumbleweed matrix or make + `.github/workflows/pr-quality.yml`'s advisory AppImage guard blocking. The + release-builder smoke is the selected acceptance test. +- Do not replace sharun with a custom `LD_LIBRARY_PATH` wrapper. + +## Confirmed sharun contract + +Pin the tests and implementation to the actual sharun grammar: + +- `shared/lib/lib.path` is one entry per line. +- `+` represents the directory containing `lib.path`, which is the extracted + AppDir's canonical `shared/lib` directory. +- `+/subdir` represents a descendant of that directory. +- sharun trims the complete file, replaces newlines with `:`, and then replaces + every `+` with the absolute `shared/lib` path. +- `+` is therefore a marker inside an entry, **not** an entry delimiter. + +The current `tr '+:' '\n\n'` parser destroys this marker and must be removed. +Tests must use the accepted textual forms `+` and `+/subdir`, with newline +separation. They must not introduce an invented plus- or colon-separated input +format. + +## Sequencing decision + +`strip-appimage-graphics-libs.sh` currently performs four phases in one command: + +1. extract and postprocess an AppDir; +2. repack and replace the final `.AppImage`; +3. re-sign the `.AppImage`; +4. rebuild and re-sign its updater tarball. + +The final-artifact validator must run after phase 2 but before phase 3. Add the +validator call immediately after the rebuilt file is moved over the original +and before the image is appended to `MODIFIED_PATHS`. This satisfies both +requirements that the inspected bytes are the uploaded bytes and that a bad +launcher fails before signing. It also avoids a defer/finalize mode that could +accidentally validate one image and then repack another. + +Static validation runs for every Linux AppImage. The executable smoke is +enabled only for `x86_64-unknown-linux-gnu` in this issue because that is the +reported architecture and acceptance target. The helper remains +architecture-neutral: a later change can enable the same smoke on arm64 by +setting the same environment flag on an executable arm64 runner. + +--- + +## Task 1: Replace the destructive parser with sharun-native normalization + +**Files:** + +- Modify: `scripts/release/test-strip-appimage-rpaths.sh` +- Modify: `scripts/release/strip-appimage-graphics-libs.sh` + +### Step 1.1: Add failing normalization fixtures + +Extend `test-strip-appimage-rpaths.sh` after the host-ELF fixture is established +and the target script is sourced. Add a reusable released-style AppDir builder: + +```bash +make_sharun_appdir() { + local appdir="$1" + mkdir -p "$appdir/shared/bin" "$appdir/shared/lib/plugins" "$appdir/bin" + + cp "$HOST_ELF" "$appdir/sharun" + printf 'Interpreter not found!' >> "$appdir/sharun" + chmod +x "$appdir/sharun" + ln "$appdir/sharun" "$appdir/AppRun" + ln "$appdir/sharun" "$appdir/bin/OpenHuman" + + cp "$HOST_ELF" "$appdir/shared/bin/OpenHuman" + chmod +x "$appdir/shared/bin/OpenHuman" +} +``` + +Use separate fixture directories so one failure cannot contaminate the next. +Add these assertions: + +1. An input with one entry + `"$WORK/squashfs-root/shared/lib"` rewrites to exactly `+`. +2. An input rooted in the bundler staging layout + `"$WORK/appimage_deb/data/usr/lib"` rewrites to exactly `+`. +3. AppDir descendants map as follows: + - `"$WORK/squashfs-root/shared/lib/plugins"` becomes `+/plugins`; + - `"$WORK/appimage_deb/data/usr/lib/plugins"` becomes `+/plugins`. +4. A file containing these newline-delimited entries: + + ```text + + + +/plugins + + + /opt/unrelated/lib + ``` + + normalizes to exactly: + + ```text + + + +/plugins + ``` + + Stable first-seen order is preserved and the unrelated host path is + excluded. + +5. Running `rewrite_sharun_lib_path` a second time leaves the bytes unchanged + and returns the no-change status expected by `strip_one_appimage`. +6. An input containing only `/opt/unrelated/lib` fails normalization and is not + replaced with `shared/lib`. + +Capture expected failures in subshells because the release helpers are allowed +to terminate fail-closed: + +```bash +if ( rewrite_sharun_lib_path "$fixture" ) 2>"$WORK/rewrite.err"; then + fail "rewrite accepted a lib.path with no AppDir library root" +fi +grep -F "no valid AppDir library entries" "$WORK/rewrite.err" >/dev/null \ + || fail "rewrite failure did not explain why lib.path was rejected" +``` + +Run the focused test on Linux: + +```bash +bash scripts/release/test-strip-appimage-rpaths.sh +``` + +Expected RED: at least the canonical-marker case fails because the current +implementation splits on `+` and writes `shared/lib`; the no-valid-root case +also fails because the current fallback silently writes `shared/lib`. + +On macOS, first run: + +```bash +bash scripts/release/test-strip-appimage-rpaths.sh +``` + +Expected local result: a clean `SKIP` when `patchelf` or a host ELF is +unavailable. Do not treat that skip as execution evidence; the RED/GREEN result +for this task must come from the existing Ubuntu PR job or another Linux host. + +### Step 1.2: Implement newline-based normalization + +Refactor `rewrite_sharun_lib_path` without changing its public signature: + +```bash +rewrite_sharun_lib_path() { + local appdir="$1" + # Return 1 for non-sharun/no-file/no-change. + # Return 0 after writing a changed valid file. + # Exit non-zero with an ERROR for malformed input from which no valid + # AppDir-rooted entry can be derived. +} +``` + +Implement the following exact algorithm: + +1. Keep the `uses_sharun_launcher` and non-empty-file guards. +2. Read with `while IFS= read -r entry || [ -n "$entry" ]`; do not use `tr`, + word splitting, `eval`, or colon splitting. +3. Reject empty interior lines, carriage returns, embedded `:`, and surrounding + whitespace as malformed rather than letting the loader interpret them + differently from the validator. +4. Normalize each entry: + - `+` stays `+`; + - `+/suffix` stays `+/suffix` after the descendant checks in Task 2; + - an absolute path containing `/squashfs-root/shared/lib` maps that root to + `+` and its suffix to `+/suffix`; + - an absolute path containing `/appimage_deb/data/usr/lib` or `/data/usr/lib` + maps that root to `+` and its suffix to `+/suffix`; + - another absolute path is dropped; + - a bare relative path is malformed and causes normalization to fail. +5. For derived suffixes, remove one leading slash and no other content. A root + with an empty suffix becomes `+`. +6. Deduplicate using exact whole-entry equality while preserving first-seen + order. Use an indexed Bash array and a small equality loop so the script + remains syntax-compatible with macOS Bash 3.2; do not introduce associative + arrays. +7. If no valid AppDir-rooted entries remain, print: + + ```text + [strip-libs] ERROR: shared/lib/lib.path contains no valid AppDir library entries + ``` + + and return non-zero without modifying the file. + +8. Join entries with newline characters and write through a temporary file in + the same directory followed by `mv`. Preserve the final format as one entry + per line; a trailing newline is acceptable but must be deterministic. +9. Compare the normalized temporary file with the original using `cmp -s`. + Delete the temporary and return `1` on an idempotent no-op. On change, move + it into place, log the normalized entries, and return `0`. + +Remove the early grep that only invokes rewriting when `/home/runner` or `/__w` +is present. Valid marker files still need the idempotency path, and malformed +bare-relative files must not bypass normalization. + +### Step 1.3: Run the GREEN checks + +Linux: + +```bash +bash -n scripts/release/strip-appimage-graphics-libs.sh +bash -n scripts/release/test-strip-appimage-rpaths.sh +bash scripts/release/test-strip-appimage-rpaths.sh +``` + +Expected GREEN: all old RPATH/required-library tests and all new normalization +cases pass. + +macOS: + +```bash +bash -n scripts/release/strip-appimage-graphics-libs.sh +bash -n scripts/release/test-strip-appimage-rpaths.sh +bash scripts/release/test-strip-appimage-rpaths.sh +``` + +Expected: both syntax checks pass and the execution test skips cleanly if Linux +ELF tooling is absent. + +### Step 1.4: Commit the normalization + +```bash +atomic-commit "fix(appimage): emit sharun-native library paths" -- \ + scripts/release/test-strip-appimage-rpaths.sh \ + scripts/release/strip-appimage-graphics-libs.sh +``` + +--- + +## Task 2: Make `lib.path` validation fail closed and cover the shipped ELF launcher + +**Files:** + +- Modify: `scripts/release/test-strip-appimage-rpaths.sh` +- Modify: `scripts/release/strip-appimage-graphics-libs.sh` + +### Step 2.1: Add failing validator and launcher-layout cases + +Reuse `make_sharun_appdir` and add a table-driven helper: + +```bash +assert_lib_path_rejected() { + local label="$1" + local value="$2" + local expected_entry="$3" + local fixture="$WORK/reject-$label" + make_sharun_appdir "$fixture" + printf '%s\n' "$value" >"$fixture/shared/lib/lib.path" + + if ( validate_sharun_lib_path "$fixture" ) 2>"$fixture/error.log"; then + fail "validate_sharun_lib_path accepted $label" + fi + grep -F "$expected_entry" "$fixture/error.log" >/dev/null \ + || fail "$label error did not identify offending entry '$expected_entry'" +} +``` + +Add rejection cases for: + +- `shared/lib` (bare relative); +- `/home/runner/work/openhuman/squashfs-root/shared/lib` (CI absolute); +- `/__w/openhuman/openhuman/appimage_deb/data/usr/lib` (CI absolute); +- `+/../outside` (parent traversal); +- `+/plugins/../../outside` (nested traversal); +- `+/missing` (marker-relative directory absent from the fixture); +- `+/plugins:shared/lib` (embedded loader separator); +- ` +` and `+ ` (whitespace changes loader semantics). + +Each error assertion must match the complete offending entry in the diagnostic, +not only a generic error category. + +Add accepted cases: + +- `+`; +- `+` followed by `+/plugins` on a new line; +- a normalized file passed twice, proving validation is non-mutating. + +Add explicit released-layout assertions: + +```bash +[ "$fixture/AppRun" -ef "$fixture/sharun" ] \ + || fail "AppRun fixture is not hard-linked to sharun" +[ "$fixture/bin/OpenHuman" -ef "$fixture/sharun" ] \ + || fail "bin/OpenHuman fixture is not hard-linked to sharun" +is_executable_elf "$fixture/AppRun" \ + || fail "released-style AppRun fixture is not ELF" +uses_sharun_launcher "$fixture" \ + || fail "released-style hard-linked ELF launcher was not detected" +``` + +Write `+` to its `lib.path`, validate it, change to an unrelated directory, and +model sharun's pinned expansion: + +```bash +caller="$WORK/caller" +mkdir -p "$caller" +( + cd "$caller" + expanded="$( + sed "s|+|$fixture/shared/lib|g" "$fixture/shared/lib/lib.path" | + paste -sd ':' - + )" + [ "$expanded" = "$fixture/shared/lib" ] \ + || fail "sharun marker did not expand beneath the fixture AppDir" +) +``` + +Also assert that `patch_apprun_sharun_cwd "$fixture"` returns its no-change +status and does not mutate the ELF inode. This pins the compatibility helper as +shell-wrapper-only hardening, not the fix for released ELF AppRuns. + +Run on Linux: + +```bash +bash scripts/release/test-strip-appimage-rpaths.sh +``` + +Expected RED: the current validator accepts bare relatives, traversal, missing +directories, and an ELF AppRun without any meaningful library-path checks. + +### Step 2.2: Implement exact fail-closed validation + +Keep the `validate_sharun_lib_path "$appdir"` interface. Parse the file by +newline using the same helper/loop as normalization so rewrite and validation +cannot disagree. + +For every non-empty entry: + +1. Accept only `+` or a string beginning `+/`. +2. Reject `:`, carriage returns, leading/trailing whitespace, absolute paths, + and bare relative paths. +3. Split the marker-relative suffix on `/` and reject empty, `.`, or `..` + components. This rejects traversal before filesystem canonicalization. +4. Resolve `+` to `$appdir/shared/lib`; resolve `+/suffix` to + `$appdir/shared/lib/suffix`. +5. Require the resolved path to be a directory. +6. Canonicalize both the library root and resolved directory with `realpath`, + then require the result to equal the root or start with + `"$canonical_root/"`. This prevents an in-tree symlink from escaping the + AppDir even when the textual path contains no `..`. +7. On failure, print one line with the offending entry: + + ```text + [strip-libs] ERROR: invalid sharun lib.path entry '': + ``` + + and return/exit non-zero before repacking. + +Require at least one entry and retain the existing missing-file diagnostic. +Do not mutate the file in validation. + +Update the comment above `patch_apprun_sharun_cwd` and its success/warning logs: + +- state that valid `+` paths are the primary released-layout fix; +- state that the helper remains for historical shell `AppRun` layouts; +- remove claims that changing CWD is required for an ELF sharun launcher; +- preserve the current shell patch behavior and return convention. + +### Step 2.3: Run GREEN checks + +Linux: + +```bash +bash -n scripts/release/strip-appimage-graphics-libs.sh +bash scripts/release/test-strip-appimage-rpaths.sh +``` + +Expected GREEN: accepted marker paths pass; every malformed entry fails with +the exact entry in stderr; the ELF hard-link fixture is detected and remains +unchanged by the shell compatibility patch. + +macOS: + +```bash +bash -n scripts/release/strip-appimage-graphics-libs.sh +bash -n scripts/release/test-strip-appimage-rpaths.sh +bash scripts/release/test-strip-appimage-rpaths.sh +``` + +Expected: syntax passes and the execution test cleanly skips if required. + +### Step 2.4: Commit fail-closed validation + +```bash +atomic-commit "test(appimage): reject malformed sharun library paths" -- \ + scripts/release/test-strip-appimage-rpaths.sh \ + scripts/release/strip-appimage-graphics-libs.sh +``` + +--- + +## Task 3: Inspect and smoke-test the final repacked AppImage before signing + +**Files:** + +- Create: `scripts/release/validate-appimage-runtime.sh` +- Modify: `scripts/release/test-strip-appimage-rpaths.sh` +- Modify: `scripts/release/strip-appimage-graphics-libs.sh` + +### Step 3.1: Add failing tests for extracted-layout validation + +The new script must be sourceable for fixture tests and executable for a real +AppImage. Before implementing it, extend +`test-strip-appimage-rpaths.sh` to source it after sourcing the postprocessor: + +```bash +RUNTIME_VALIDATOR="$SCRIPT_DIR/validate-appimage-runtime.sh" +[ -f "$RUNTIME_VALIDATOR" ] \ + || fail "$RUNTIME_VALIDATOR not found" +# shellcheck source=/dev/null +source "$RUNTIME_VALIDATOR" +``` + +Add fixture tests for the function interface: + +```bash +validate_extracted_appdir "$appdir" +validate_final_appimage "$appimage" +smoke_extracted_apprun "$appdir" "$foreign_cwd" "$log_file" +``` + +`validate_extracted_appdir` is the unit-test seam; it must not extract or launch +anything. Build a complete fixture with: + +- hard-linked ELF `AppRun`, `sharun`, and `bin/OpenHuman`; +- executable ELF `shared/bin/OpenHuman`; +- `shared/lib/lib.path` containing `+`; +- `shared/lib/anylinux.so`; +- `shared/lib/libxdo.so.3`; +- `shared/lib/libcef.so`; +- clean RPATHs on all copied ELFs. + +Because `/bin/true` does not naturally declare the production `NEEDED` +libraries, make the needed-name check injectable only in fixture mode: + +```bash +APPIMAGE_EXPECTED_NEEDED="${APPIMAGE_EXPECTED_NEEDED:-libxdo.so.3 libcef.so}" +``` + +Set `APPIMAGE_EXPECTED_NEEDED=""` only around unit fixtures. The executable CLI +and release workflow must leave the production default intact. + +Assert the complete fixture passes. Then clone it into isolated cases and +assert rejection when: + +- `anylinux.so` is absent; +- `libxdo.so.3` is absent; +- `libcef.so` is absent; +- `shared/bin/OpenHuman` is absent or is not ELF; +- `AppRun` is neither the same inode nor byte-equivalent to `sharun`; +- `bin/OpenHuman` is neither the same inode nor byte-equivalent to `sharun`; +- an ELF RPATH contains + `/home/runner/work/openhuman/openhuman/shared/lib`; +- an ELF RPATH contains `/__w/openhuman/openhuman/shared/lib`; +- the real app's `NEEDED` list lacks `libxdo.so.3`; +- the real app's `NEEDED` list lacks `libcef.so`. + +For equivalent launcher support, copy rather than hard-link `sharun` to +`AppRun` and `bin/OpenHuman`; byte-identical executable copies must pass. This +covers a repacker that preserves launcher contents but not hard-link metadata. + +Add a source-order regression that invokes a stub validator from +`strip_one_appimage` and proves it runs after the final `mv` but before the path +is appended to `MODIFIED_PATHS`. Do this through an overridable command: + +```bash +APPIMAGE_RUNTIME_VALIDATOR="${APPIMAGE_RUNTIME_VALIDATOR:-$SCRIPT_DIR/validate-appimage-runtime.sh}" +``` + +The test stub must record that the supplied final path exists and that +`MODIFIED_PATHS` is still empty. Do not execute or repack a real AppImage in +this unit test; factor the post-repack handoff into: + +```bash +validate_rebuilt_appimage() { + local rebuilt_path="$1" + "$APPIMAGE_RUNTIME_VALIDATOR" "$rebuilt_path" +} +``` + +Call this seam directly with a fixture file to pin its command/argument +contract, and use a source-text order assertion for the three adjacent +statements: + +```text +mv "$rebuilt" "$original" +validate_rebuilt_appimage "$original" +MODIFIED_PATHS+=("$original") +``` + +Run on Linux: + +```bash +bash scripts/release/test-strip-appimage-rpaths.sh +``` + +Expected RED: the validator file/functions and the post-repack handoff do not +exist. + +### Step 3.2: Implement the sourceable final-artifact validator + +Create `scripts/release/validate-appimage-runtime.sh` with `set -euo pipefail`, +three shell functions, and a direct-execution guard. The exact interfaces are: + +- `validate_extracted_appdir "$appdir"` performs the eight ordered static checks + immediately below. +- `smoke_extracted_apprun "$appdir" "$foreign_cwd" "$log_file"` performs the + eight ordered launch checks immediately below. +- `validate_final_appimage "$image"` performs the five ordered extraction + checks immediately below. + +Use this direct-execution guard: + +```bash +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + [ "$#" -eq 1 ] || { + echo "Usage: $0 " >&2 + exit 2 + } + validate_final_appimage "$1" +fi +``` + +Do not duplicate `lib.path` grammar. Resolve the script directory and source +`strip-appimage-graphics-libs.sh`, whose direct-execution guard prevents +`main()` from running. Reuse: + +- `is_elf`; +- `is_executable_elf`; +- `uses_sharun_launcher`; +- `validate_sharun_lib_path`; +- `validate_appimage_required_libs`. + +Implement `validate_extracted_appdir "$appdir"` as follows: + +1. Print the extracted path and the normalized `lib.path` contents with each + line prefixed for readable CI logs. +2. Require `uses_sharun_launcher "$appdir"`. +3. Require ELF executables at `AppRun`, `sharun`, `bin/OpenHuman`, and + `shared/bin/OpenHuman`. +4. For both launcher aliases, accept either `[ alias -ef sharun ]` or + `cmp -s alias sharun`; reject anything else. +5. Call `validate_sharun_lib_path` and + `validate_appimage_required_libs`. +6. Add `anylinux.so` to the required-library contract in + `validate_appimage_required_libs`; its missing diagnostic must identify the + preload library separately from the existing libxdo/CEF diagnostics. +7. Run `patchelf --print-needed "$appdir/shared/bin/OpenHuman"` and require each + whitespace-delimited name in + `${APPIMAGE_EXPECTED_NEEDED:-libxdo.so.3 libcef.so}` as an exact full line. + Print the NEEDED list before checking it. +8. Walk ELF files under `shared/bin`, `shared/lib`, `bin`, `lib`, `usr/bin`, and + `usr/lib`. For each, read RPATH with `patchelf --print-rpath`; reject + `/home/runner/` and `/__w/`. This is validation only: never patch the final + artifact. + +`validate_final_appimage "$image"` must: + +1. Resolve the input with `realpath`, require an executable regular file, and + require `patchelf`. +2. Create one temporary root and a distinct `foreign-cwd` below it; install a + trap that removes only that exact temporary root. +3. Change into the foreign CWD before invoking + `"$image" --appimage-extract`, redirecting extraction output to a log. This + ensures extraction itself does not rely on the artifact directory. +4. Require `$foreign_cwd/squashfs-root`, then call + `validate_extracted_appdir` on it. +5. If `APPIMAGE_RUNTIME_SMOKE=1`, call `smoke_extracted_apprun`; otherwise print + an explicit architecture/static-only message. + +`smoke_extracted_apprun "$appdir" "$foreign_cwd" "$log_file"` must: + +1. Require `timeout` and `xvfb-run`. +2. Print the AppDir and caller CWD. +3. Create isolated `home`, `config`, `data`, and `cache` directories beneath + the temporary root so the release smoke cannot read or mutate the runner's + real OpenHuman state. +4. Launch from `$foreign_cwd` with: + + ```bash + timeout --signal=TERM --kill-after=5s 15s \ + xvfb-run -a --server-args="-screen 0 1280x960x24" \ + env \ + HOME="$smoke_home" \ + XDG_CONFIG_HOME="$smoke_config" \ + XDG_DATA_HOME="$smoke_data" \ + XDG_CACHE_HOME="$smoke_cache" \ + OPENHUMAN_CEF_PREWARM=0 \ + OPENHUMAN_DISABLE_GPU=1 \ + "$appdir/AppRun" + ``` + + Before `env`, unset `GITHUB_TOKEN`, `GH_TOKEN`, + `TAURI_SIGNING_PRIVATE_KEY`, `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, + `SENTRY_AUTH_TOKEN`, and all `OPENAI_*` variables present in the environment. + Build the `env -u NAME` arguments from variable **names only**; never print + values. + +5. Capture stdout/stderr in `$log_file` and preserve the timeout status despite + `set -e`. +6. Treat status `124` as success: the GUI remained alive through the bounded + startup window. Treat any other status as failure, including an immediate + clean exit, because it did not prove a live application boundary. +7. Regardless of status, search the log case-insensitively for: + - `anylinux.so` combined with `cannot be preloaded`; + - `libxdo.so.3` combined with `cannot open shared object file`; + - `libcef.so` combined with `cannot open shared object file`; + - `error while loading shared libraries`. +8. On a forbidden diagnostic or unexpected exit, print the complete smoke log + and fail. On success, print only lines matching loader/CEF/startup keywords + plus a message that the application remained alive for the startup window. + This keeps logs useful without dumping inherited environment data. + +### Step 3.3: Call the validator after repack and before signing + +In `strip-appimage-graphics-libs.sh`: + +1. Define `SCRIPT_DIR` once from `BASH_SOURCE[0]`. +2. Define + `APPIMAGE_RUNTIME_VALIDATOR="${APPIMAGE_RUNTIME_VALIDATOR:-$SCRIPT_DIR/validate-appimage-runtime.sh}"`. +3. Add `validate_rebuilt_appimage` with the exact one-argument forwarding + contract above. +4. In `strip_one_appimage`, change the final order to: + + ```bash + mv "$rebuilt" "$original" + validate_rebuilt_appimage "$original" + rm -rf "$workdir" + MODIFIED_PATHS+=("$original") + ``` + +Because `set -e` is active, any static or launch failure stops before +`MODIFIED_PATHS` is populated and before `main` reaches `resign_artifact` or +updater tarball rebuilding. + +Keep the intermediate AppDir validation at its current pre-repack location. +It provides a faster diagnostic; the new call proves the repacked bytes. + +### Step 3.4: Run GREEN checks + +Linux unit/fixture lane: + +```bash +bash -n scripts/release/strip-appimage-graphics-libs.sh +bash -n scripts/release/validate-appimage-runtime.sh +bash -n scripts/release/test-strip-appimage-rpaths.sh +bash scripts/release/test-strip-appimage-rpaths.sh +``` + +Expected GREEN: extracted-layout fixtures cover required libraries, NEEDED +names, RPATHs, equivalent/hard-linked launchers, and post-repack ordering. + +Linux real-artifact check, after building an amd64 AppImage: + +```bash +APPIMAGE_RUNTIME_SMOKE=1 \ + bash scripts/release/validate-appimage-runtime.sh \ + app/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage +``` + +If the artifact exists under the root target tree instead, use the one exact +matching file under: + +```text +target/x86_64-unknown-linux-gnu/release/bundle/appimage/ +``` + +Do not pass multiple glob matches to the one-image CLI; fail the build if the +bundle root unexpectedly contains zero or multiple amd64 AppImages. + +macOS: + +```bash +bash -n scripts/release/strip-appimage-graphics-libs.sh +bash -n scripts/release/validate-appimage-runtime.sh +bash -n scripts/release/test-strip-appimage-rpaths.sh +bash scripts/release/test-strip-appimage-rpaths.sh +``` + +Expected: syntax checks pass and the fixture suite skips cleanly without Linux +ELF tooling. Do not attempt to execute the AppImage or claim runtime validation +from macOS. + +### Step 3.5: Commit final-artifact validation + +```bash +atomic-commit "fix(appimage): validate the repacked runtime before signing" -- \ + scripts/release/validate-appimage-runtime.sh \ + scripts/release/test-strip-appimage-rpaths.sh \ + scripts/release/strip-appimage-graphics-libs.sh +``` + +--- + +## Task 4: Enable the amd64 foreign-CWD smoke in the Linux desktop build + +**Files:** + +- Modify: `.github/workflows/build-desktop.yml` + +### Step 4.1: Establish the wiring gap + +Run: + +```bash +rg -n 'APPIMAGE_RUNTIME_SMOKE|xvfb' .github/workflows/build-desktop.yml +``` + +Expected RED: the workflow neither installs Xvfb nor enables the final +AppImage smoke. + +The behavior itself is already fixture-tested in Task 3. This task is declarative +workflow wiring; validate it with YAML/action checks rather than duplicating the +shell behavior in a YAML text test. + +### Step 4.2: Add Linux runtime prerequisites + +In `Install Tauri dependencies (ubuntu only)`, add `xvfb` to the existing +`apt-get install -y` list. `timeout` is supplied by Ubuntu's existing +`coreutils`; do not add another timeout package. + +Do not add these dependencies to macOS or Windows. + +### Step 4.3: Enable smoke only on the reported architecture + +Rename the existing step to: + +```text +Strip host graphics libs and validate final AppImage +``` + +Keep its Linux-only `if:` expression, bundle-root arguments, target/profile +variables, and signing secrets unchanged. Add: + +```yaml +APPIMAGE_RUNTIME_SMOKE: ${{ matrix.settings.target == 'x86_64-unknown-linux-gnu' && '1' || '0' }} +``` + +The postprocessor now performs, in order: + +1. graphics stripping/interpreter/RPATH/lib.path repair; +2. repack; +3. final artifact extraction and static validation on both Linux architectures; +4. foreign-CWD Xvfb smoke on amd64; +5. signing and updater archive rebuilding. + +Do not use `continue-on-error`. Do not change the advisory status of +`pr-quality.yml`; release/staging desktop callers already consume this reusable +workflow and will fail before upload if the validator fails. + +Update the step comment to state that: + +- final-artifact validation occurs before re-signing; +- amd64 launch is bounded and runs from a foreign CWD; +- arm64 receives static validation in this change; +- updater archives are still rebuilt only after the validated image is final. + +### Step 4.4: Validate workflow syntax and formatting + +Run whichever actionlint path the repository provides: + +```bash +if command -v actionlint >/dev/null 2>&1; then + actionlint .github/workflows/build-desktop.yml +else + echo "actionlint unavailable locally; rely on GitHub workflow validation" +fi +``` + +Then run: + +```bash +pnpm exec prettier --check .github/workflows/build-desktop.yml +git diff --check +``` + +Expected GREEN: actionlint (when installed), Prettier, and whitespace checks +pass. + +### Step 4.5: Commit workflow wiring + +```bash +atomic-commit "ci(appimage): smoke-test the final amd64 launcher" -- \ + .github/workflows/build-desktop.yml +``` + +--- + +## Task 5: Run local checks and obtain Linux evidence + +**Files:** + +- No new files; verification only. + +### Step 5.1: Run the complete macOS-safe local gate + +From the worktree root: + +```bash +bash -n scripts/release/strip-appimage-graphics-libs.sh +bash -n scripts/release/validate-appimage-runtime.sh +bash -n scripts/release/test-strip-appimage-rpaths.sh +bash scripts/release/test-strip-appimage-rpaths.sh +pnpm exec prettier --check \ + docs/plans/2026-07-24-appimage-sharun-lib-path.md \ + .github/workflows/build-desktop.yml +git diff --check +git status --short +``` + +Expected on macOS: + +- all three shell syntax checks pass; +- the Linux fixture script reports `SKIP`, not `FAIL`, if `patchelf`/ELF is + unavailable; +- Prettier and `git diff --check` pass; +- status contains no unrelated files. + +Do not run `pnpm format`, because it writes across the repository and can mix +unrelated formatting into these atomic commits. + +### Step 5.2: Require Linux unit evidence + +The existing advisory PR-quality job must run: + +```bash +bash -n scripts/release/strip-appimage-graphics-libs.sh +bash scripts/release/test-strip-appimage-rpaths.sh +``` + +Confirm its log includes passing cases for: + +- CI/build path to `+`; +- `+/subdir`, order, deduplication, and idempotency; +- bare relative, absolute CI, traversal, missing-target, and symlink-escape + rejection; +- hard-linked/equivalent ELF launcher layout; +- anylinux/libxdo/libcef presence; +- NEEDED and RPATH validation. + +The job remains advisory by design, but a failure is still a blocker for this +change. + +### Step 5.3: Require a real amd64 artifact run + +Run the reusable desktop build on an amd64 Ubuntu runner. The log from +`Strip host graphics libs and validate final AppImage` must show: + +- final extracted AppDir path; +- caller working directory outside that AppDir; +- normalized `lib.path` containing `+` entries only; +- `shared/bin/OpenHuman` NEEDED entries; +- no CI/build-machine RPATHs; +- presence of `anylinux.so`, `libxdo.so.3`, and `libcef.so`; +- timeout status `124`, documented as the expected live-GUI boundary; +- no preload or loader error for those three libraries; +- signing/updater archive messages only after validation succeeds. + +Inspect the later upload step and confirm it consumes the same AppImage path +that was validated. Do not substitute an intermediate `squashfs-root` run for +this evidence. + +### Step 5.4: Final history and scope review + +Run: + +```bash +git status --short +git diff --check HEAD~4..HEAD +git log --oneline -4 +git diff --stat baae2b02c..HEAD +git diff --name-only baae2b02c..HEAD +``` + +Expected: + +- four atomic implementation commits after the approved spec; +- only the four implementation paths named at the top of this plan changed; +- no Cargo, AUR, OpenSSL, vendor pin, or PR-quality blocking changes; +- no submodule pointer changes. + +If implementation discovers that sharun's bundled version uses a grammar +different from the confirmed newline-plus contract, stop and amend the design +with artifact/source evidence before changing the parser. Do not guess at a +second delimiter grammar in release code. + +## Acceptance checklist + +- [ ] CI/build paths normalize to `+` or `+/descendant`. +- [ ] `+` is never treated as a delimiter. +- [ ] Output ordering is stable, deduplicated, deterministic, and idempotent. +- [ ] Bare relatives, absolute CI paths, traversal, missing directories, + separator injection, whitespace ambiguity, and symlink escape fail closed. +- [ ] The released ELF hard-link launcher layout is covered; the shell CWD patch + remains compatibility-only. +- [ ] Intermediate and final repacked AppDirs both pass library-path, + required-library, NEEDED, and RPATH validation. +- [ ] The final amd64 AppImage is launched from a foreign CWD under Xvfb and + remains alive through the bounded startup window. +- [ ] Loader logs contain no `anylinux.so`, `libxdo.so.3`, or `libcef.so` + resolution error. +- [ ] Validation completes before signing/updater archive rebuilding/upload. +- [ ] Arm64 static validation and all non-Linux workflow behavior remain intact. +- [ ] #3716/OpenSSL, unbundled AUR/core, optional `enigo`, rolling-distro + matrices, and quick-sharun pinning remain explicit follow-ups. diff --git a/docs/specs/2026-07-24-appimage-sharun-lib-path-design.md b/docs/specs/2026-07-24-appimage-sharun-lib-path-design.md new file mode 100644 index 000000000..0b50a0f19 --- /dev/null +++ b/docs/specs/2026-07-24-appimage-sharun-lib-path-design.md @@ -0,0 +1,215 @@ +# AppImage sharun library-path repair + +## Context + +Issue #5037 reports that Linux AppImages fail when launched outside their +extracted AppDir. The launcher logs that `anylinux.so` cannot be preloaded and +then fails to resolve `libxdo.so.3`, even though both libraries are bundled. +Running the extracted application with an explicit AppDir-rooted +`LD_LIBRARY_PATH` succeeds. + +Read-only analysis verified the final v0.61.2 and v0.63.1 amd64 AppImages: + +- `AppRun`, `sharun`, and `bin/OpenHuman` are hard links to the sharun ELF + launcher. +- The real application is `shared/bin/OpenHuman`. +- `lib/` contains `anylinux.so`, `libxdo.so.3`, `libcef.so`, `libssl.so.3`, and + `libcrypto.so.3`; `shared/lib` is a symlink to `../lib`. +- `shared/lib/lib.path` contains the bare relative entry `shared/lib`. +- sharun 2.2.4 and 2.2.5 expand the canonical `+` marker to an absolute path + rooted at the AppDir's `lib/` directory. They do not anchor a bare + `shared/lib` entry. + +The release postprocessor causes the malformed entry. +`rewrite_sharun_lib_path` converts absolute CI paths beneath +`squashfs-root/shared/lib` into `shared/lib`. The intended compensation, +`patch_apprun_sharun_cwd`, only edits a shell `AppRun` and intentionally skips +the shipped ELF `AppRun`. + +Consequently, `shared/lib` resolves from the caller's current directory. It +works when the caller happens to be the AppDir and fails from locations such as +`~/Downloads` or `/tmp`. + +## Evidence boundary + +Artifact hashes, SquashFS layouts, ELF metadata, release-script behavior, sharun +path expansion, and caller-directory resolution were verified directly. + +This development host is macOS, and its Docker daemon is unavailable, so it +cannot execute the Linux ELF launcher. An identical v0.63.1 process failure is a +strong inference from the verified path semantics and must be confirmed by a +Linux CI smoke test. + +## Goals + +- Write sharun-native, AppDir-anchored entries to `shared/lib/lib.path`. +- Reject malformed bare-relative or CI-runner paths before an AppImage ships. +- Cover the actual ELF `AppRun` layout used by released artifacts. +- Validate the final repacked AppImage, rather than only the intermediate + AppDir. +- Exercise the final launcher from a working directory outside the AppDir on + Linux. +- Preserve the existing graphics-library stripping, interpreter repair, + signing, updater archive, and multi-architecture behavior. + +## Non-goals + +- Making the unbundled AUR/core binary independent of system `libxdo`. +- Changing the unconditional `enigo` dependency or voice input behavior. +- Replacing bundled OpenSSL or claiming to resolve issue #3716's later-stage + symbol-skew/segfault report. +- Bypassing sharun with a custom `LD_LIBRARY_PATH` launcher. +- Pinning mutable quick-sharun downloads; that is a separate supply-chain + follow-up. +- Converting the existing PR-quality AppImage job from advisory to blocking. + +## Considered approaches + +### 1. Emit sharun-native `+` paths and validate them + +Rewrite AppDir library roots to sharun's canonical `+` representation. A root +library directory becomes `+`; any retained directory beneath it becomes a +`+/subdir` entry. Preserve the format's entry separator without treating the +marker itself as a separator. + +Validation rejects: + +- absolute CI runner paths; +- bare relative entries such as `shared/lib`; +- traversal components; +- AppDir-relative targets that do not exist. + +This directly restores sharun's intended absolute path expansion and is the +selected approach. + +### 2. Change the working directory in `AppRun.sh` + +Patching the shipped shell wrapper or setting a sharun working-directory +variable could make `shared/lib` resolve by accident. It leaves malformed +library-path semantics in place, depends on launcher shape, and does not explain +the existing ELF `AppRun` skip clearly enough to be the primary fix. + +### 3. Bypass sharun + +A wrapper could execute `shared/bin/OpenHuman` with an explicit absolute +`LD_LIBRARY_PATH`. This duplicates sharun's loader and portability behavior and +risks losing its environment and compatibility hooks. + +## Design + +### Library-path normalization + +Refactor `rewrite_sharun_lib_path` in +`scripts/release/strip-appimage-graphics-libs.sh` so CI-origin entries are +normalized into sharun-native AppDir library entries. + +The normalizer must: + +1. Parse entries without destroying the `+` marker. +2. Recognize CI paths rooted under an extracted `squashfs-root` or the existing + build `data` layout. +3. Map the AppDir's canonical library root to `+`, and valid descendants to + `+/relative/subdir`. +4. Deduplicate entries while preserving stable order. +5. Exclude unrelated absolute host paths. +6. Fail instead of falling back to a bare `shared/lib` entry when no valid + AppDir library root can be derived. +7. Be idempotent when the file already contains valid sharun-native entries. + +The implementation must follow sharun's actual `lib.path` delimiter grammar as +confirmed from the bundled sharun version. Tests must pin the accepted textual +forms rather than rely on an informal reconstruction. + +### Fail-closed validation + +Extend `validate_sharun_lib_path` to reject any non-empty entry that is: + +- a CI/build-machine absolute path; +- a bare relative path not beginning with the sharun marker; +- a traversal outside the bundled library root; +- a marker-relative directory absent from the AppDir. + +The validation error must identify the offending entry and refuse to repack or +publish the artifact. + +`patch_apprun_sharun_cwd` may remain as compatibility hardening for historical +shell AppRun layouts, but the new tests must establish that an ELF AppRun does +not need this patch once `lib.path` is valid. Comments that describe changing +the working directory as the primary fix must be updated to reflect that role. + +### Regression tests + +Extend `scripts/release/test-strip-appimage-rpaths.sh` with isolated cases for: + +- a CI absolute `.../squashfs-root/shared/lib` entry rewriting to the canonical + marker; +- valid marker/subdirectory entries and stable ordering; +- deduplication and idempotency; +- rejection of bare `shared/lib`, CI paths, traversal, and missing targets; +- the released launcher layout where ELF `AppRun`, `sharun`, and + `bin/OpenHuman` are hard-linked or equivalent launcher entries; +- expansion of the rewritten value according to sharun's algorithm, proving it + resolves beneath the fixture AppDir regardless of the caller's directory. + +The test remains Linux-only because it needs ELF fixtures and `patchelf`; it +continues to skip cleanly on unsupported development hosts. + +### Final-artifact validation and smoke + +After postprocessing and repacking, Linux CI must inspect the final `.AppImage` +that will be uploaded: + +1. Extract it into a temporary directory. +2. Re-run the sharun `lib.path`, required-library, ELF `NEEDED`, and RPATH + validations against the extracted final layout. +3. Change to an unrelated temporary working directory. +4. Launch the extracted `AppRun` under a bounded timeout and the existing + headless/Xvfb facilities. +5. Fail on preload or loader diagnostics for `anylinux.so`, `libxdo.so.3`, or + `libcef.so`. +6. Treat reaching the normal application initialization boundary as success; + the smoke does not require an interactive desktop session. + +The smoke belongs in the Linux desktop build path after +`Strip host graphics libs from AppImage`, so it exercises the same final +artifact that later signing/upload steps consume. It must cover amd64 first, +where #5037 was reproduced, while keeping the validator architecture-neutral so +arm64 can use it when an executable runner is available. + +## Failure behavior + +An invalid `lib.path`, missing bundled runtime library, failed final extraction, +or foreign-CWD loader error fails the Linux artifact build before signing or +upload. The release must not continue with a warning-only malformed launcher. + +The smoke must print the extracted path, caller working directory, normalized +`lib.path`, and relevant loader diagnostics without leaking credentials. + +## Validation + +Local/macOS: + +- `bash -n scripts/release/strip-appimage-graphics-libs.sh` +- the regression script skips cleanly when Linux ELF tooling is unavailable; +- `git diff --check` and repository formatting checks. + +Linux: + +- run `scripts/release/test-strip-appimage-rpaths.sh` with `patchelf`; +- run the final-artifact validator against a freshly built amd64 AppImage; +- launch from a foreign working directory under timeout/Xvfb; +- confirm no `anylinux.so`, `libxdo.so.3`, or `libcef.so` loader errors; +- confirm the updater archive/signing flow still consumes the validated + postprocessed artifact. + +## Acceptance criteria + +- Final `lib.path` uses only valid sharun-native AppDir entries. +- Bare `shared/lib` and CI absolute entries fail validation. +- Rewriting is deterministic and idempotent. +- Released-style ELF AppRun fixtures are covered. +- The final repacked amd64 AppImage launches from outside its AppDir without the + #5037 preload/library-resolution errors. +- Required bundled libraries, ELF dependencies, and RPATHs remain valid. +- Existing graphics stripping, signing, updater archive, and non-Linux build + behavior remain unchanged. diff --git a/scripts/release/strip-appimage-graphics-libs.sh b/scripts/release/strip-appimage-graphics-libs.sh index 4e5af2f94..a8ace0015 100755 --- a/scripts/release/strip-appimage-graphics-libs.sh +++ b/scripts/release/strip-appimage-graphics-libs.sh @@ -23,9 +23,16 @@ # APPIMAGETOOL_SHA256 — expected SHA256 of the download # (verified before use when set; rotate # alongside APPIMAGETOOL_URL) +# APPIMAGE_RUNTIME_SMOKE — set to 1 to run the final AppRun +# startup smoke after static validation +# APPIMAGE_RUNTIME_VALIDATOR — override the final-artifact validator +# command (intended for regression tests) set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APPIMAGE_RUNTIME_VALIDATOR="${APPIMAGE_RUNTIME_VALIDATOR:-$SCRIPT_DIR/validate-appimage-runtime.sh}" + EXCLUDE_PATTERNS=( 'libGL.so.*' 'libGLX.so.*' @@ -293,58 +300,111 @@ rewrite_sharun_lib_path() { local lib_path="$appdir/shared/lib/lib.path" [ -s "$lib_path" ] || return 1 - if ! grep -E '(^|[+:])/home/runner/|(^|[+:])/__w/' "$lib_path" >/dev/null; then + local -a normalized_entries=() + local entry normalized suffix existing duplicate index + local normalized_count=0 + while IFS= read -r entry || [ -n "$entry" ]; do + if [ -z "$entry" ]; then + echo "[strip-libs] ERROR: shared/lib/lib.path contains an empty entry" >&2 + return 1 + fi + case "$entry" in + *$'\r'*|*:*) + echo "[strip-libs] ERROR: shared/lib/lib.path contains a malformed entry: '$entry'" >&2 + return 1 + ;; + [[:space:]]*|*[[:space:]]) + echo "[strip-libs] ERROR: shared/lib/lib.path contains a malformed entry: '$entry'" >&2 + return 1 + ;; + esac + + case "$entry" in + +) + normalized="+" + ;; + +/*) + normalized="$entry" + ;; + /*) + case "$entry" in + */squashfs-root/shared/lib) + normalized="+" + ;; + */squashfs-root/shared/lib/*) + suffix="${entry#*/squashfs-root/shared/lib/}" + normalized="+/$suffix" + ;; + */appimage_deb/data/usr/lib) + normalized="+" + ;; + */appimage_deb/data/usr/lib/*) + suffix="${entry#*/appimage_deb/data/usr/lib/}" + normalized="+/$suffix" + ;; + # lib4bin/quick-sharun staging roots vary by runner and tool + # version; older artifacts use an arbitrary absolute prefix ending + # in data/usr/lib rather than the appimage_deb directory above. + # Match that stable staged-layout tail here. The derived marker is + # validated below against the extracted AppDir's shared/lib tree. + */data/usr/lib) + normalized="+" + ;; + */data/usr/lib/*) + suffix="${entry#*/data/usr/lib/}" + normalized="+/$suffix" + ;; + *) + continue + ;; + esac + ;; + *) + echo "[strip-libs] ERROR: shared/lib/lib.path contains a malformed entry: '$entry'" >&2 + return 1 + ;; + esac + + case "${normalized#+}" in + *+*) + echo "[strip-libs] ERROR: shared/lib/lib.path contains a malformed entry: '$entry'" >&2 + return 1 + ;; + esac + + duplicate=0 + index=0 + while [ "$index" -lt "$normalized_count" ]; do + existing="${normalized_entries[$index]}" + if [ "$existing" = "$normalized" ]; then + duplicate=1 + break + fi + index=$((index + 1)) + done + if [ "$duplicate" -eq 0 ]; then + normalized_entries[$normalized_count]="$normalized" + normalized_count=$((normalized_count + 1)) + fi + done <"$lib_path" + + if [ "$normalized_count" -eq 0 ]; then + echo "[strip-libs] ERROR: shared/lib/lib.path contains no valid AppDir library entries" >&2 return 1 fi - echo "[strip-libs] rewriting CI runner paths in shared/lib/lib.path" - - local raw - raw="$(cat "$lib_path")" - - local -a entries=() - local entry - while IFS= read -r entry; do - [ -n "$entry" ] || continue - entries+=("$entry") - done < <(printf '%s' "$raw" | tr '+:' '\n\n') - - local -a cleaned=() - local rel seen_set="" - for entry in "${entries[@]}"; do - case "$entry" in - /home/runner/*|/__w/*) - rel="${entry##*/squashfs-root/}" - if [ "$rel" = "$entry" ]; then - rel="${entry##*/data/}" - [ "$rel" != "$entry" ] || continue - fi - ;; - /*) - continue - ;; - *) - rel="$entry" - ;; - esac - - [ -d "$appdir/$rel" ] || continue - - case "+${seen_set}+" in - *"+${rel}+"*) continue ;; - esac - seen_set="${seen_set}+${rel}" - cleaned+=("$rel") - done - - if [ "${#cleaned[@]}" -eq 0 ]; then - cleaned=("shared/lib") + local normalized_file + normalized_file="$(mktemp "$lib_path.tmp.XXXXXX")" + printf '%s\n' "${normalized_entries[@]}" >"$normalized_file" + if cmp -s "$normalized_file" "$lib_path"; then + rm -f "$normalized_file" + return 1 fi - local joined - joined="$(IFS='+'; echo "${cleaned[*]}")" - printf '%s' "$joined" > "$lib_path" - echo "[strip-libs] lib.path rewritten to: $joined" + mv "$normalized_file" "$lib_path" + echo "[strip-libs] normalized shared/lib/lib.path entries:" + printf '[strip-libs] %s\n' "${normalized_entries[@]}" + return 0 } validate_sharun_lib_path() { @@ -356,13 +416,89 @@ validate_sharun_lib_path() { local lib_path="$appdir/shared/lib/lib.path" if [ ! -s "$lib_path" ]; then echo "[strip-libs] ERROR: sharun AppImage is missing shared/lib/lib.path; refusing to ship an AppImage that exits with 'Interpreter not found!'" >&2 - exit 1 + return 1 fi - if grep -E '(^|[+:])/home/runner/|(^|[+:])/__w/' "$lib_path" >/dev/null; then - echo "[strip-libs] ERROR: shared/lib/lib.path contains CI runner paths; regenerate it with bundle-relative entries before release." >&2 - exit 1 + local library_root="$appdir/shared/lib" + local canonical_root + if [ ! -d "$library_root" ] || ! canonical_root="$(realpath "$library_root")"; then + echo "[strip-libs] ERROR: sharun AppImage is missing shared/lib; refusing to validate library paths" >&2 + return 1 fi + + local entry suffix resolved canonical_resolved + local entry_count=0 + while IFS= read -r entry || [ -n "$entry" ]; do + entry_count=$((entry_count + 1)) + + if [ -z "$entry" ]; then + echo "[strip-libs] ERROR: invalid sharun lib.path entry '': empty entries are not allowed" >&2 + return 1 + fi + case "$entry" in + *$'\r'*) + echo "[strip-libs] ERROR: invalid sharun lib.path entry '$entry': carriage returns are not allowed" >&2 + return 1 + ;; + *:*) + echo "[strip-libs] ERROR: invalid sharun lib.path entry '$entry': loader separators are not allowed" >&2 + return 1 + ;; + [[:space:]]*|*[[:space:]]) + echo "[strip-libs] ERROR: invalid sharun lib.path entry '$entry': surrounding whitespace is not allowed" >&2 + return 1 + ;; + esac + + case "$entry" in + +) + suffix="" + resolved="$library_root" + ;; + +/*) + suffix="${entry#+/}" + case "$suffix" in + *+*) + echo "[strip-libs] ERROR: invalid sharun lib.path entry '$entry': extra '+' markers are not allowed" >&2 + return 1 + ;; + ""|/*|*/|*//*|.|..|./*|../*|*/./*|*/../*|*/.|*/..) + echo "[strip-libs] ERROR: invalid sharun lib.path entry '$entry': path components must be non-empty descendants" >&2 + return 1 + ;; + esac + resolved="$library_root/$suffix" + ;; + *) + echo "[strip-libs] ERROR: invalid sharun lib.path entry '$entry': expected '+' or '+/suffix'" >&2 + return 1 + ;; + esac + + if [ ! -d "$resolved" ]; then + echo "[strip-libs] ERROR: invalid sharun lib.path entry '$entry': resolved directory does not exist" >&2 + return 1 + fi + if ! canonical_resolved="$(realpath "$resolved")"; then + echo "[strip-libs] ERROR: invalid sharun lib.path entry '$entry': could not canonicalize resolved directory" >&2 + return 1 + fi + case "$canonical_resolved" in + "$canonical_root"|"$canonical_root"/*) + ;; + *) + echo "[strip-libs] ERROR: invalid sharun lib.path entry '$entry': resolved directory escapes shared/lib" >&2 + return 1 + ;; + esac + done <"$lib_path" + + if [ "$entry_count" -eq 0 ]; then + echo "[strip-libs] ERROR: invalid sharun lib.path entry '': at least one entry is required" >&2 + return 1 + fi + + return 0 } # is_elf — true if the file begins with the ELF magic, regardless of the +x @@ -476,9 +612,9 @@ sanitize_elf_rpaths() { 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. +# validate_appimage_required_libs — fail the build loudly if the sharun preload +# library or a runtime library the app hard-links (NEEDED) is absent from the +# bundle. # # Problem (issues #3224 and #4020): the app links libxdo.so.3 via enigo # (`#[link(name = "xdo")]`, used by src/openhuman/tools/impl/computer for Linux @@ -490,6 +626,8 @@ sanitize_elf_rpaths() { # linux_cef_deb_runtime_e2e; the AppImage path had no equivalent. This turns a # silent runtime segfault into a loud build failure. CEF is staged separately # from the ldd walk, so verify its runtime library survived bundling as well. +# anylinux.so establishes sharun's portable runtime before either dependency +# can load, so it is part of the same release contract. validate_appimage_required_libs() { local appdir="$1" if ! uses_sharun_launcher "$appdir"; then @@ -497,7 +635,7 @@ validate_appimage_required_libs() { fi local root pattern found - for pattern in 'libxdo.so*' 'libcef.so*'; do + for pattern in 'anylinux.so' 'libxdo.so*' 'libcef.so*'; do found=0 for root in "$appdir/shared/lib" "$appdir/usr/lib" "$appdir/lib"; do [ -d "$root" ] || continue @@ -509,6 +647,9 @@ validate_appimage_required_libs() { [ "$found" -eq 1 ] && continue case "$pattern" in + anylinux.so) + echo "[strip-libs] ERROR: AppImage is missing anylinux.so — the sharun preload library was not copied into the final bundle. The AppImage launcher cannot establish its portable runtime without it." >&2 + ;; libxdo.so\*) 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 ;; @@ -520,21 +661,15 @@ validate_appimage_required_libs() { done } -# 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. +# patch_apprun_sharun_cwd — retain historical shell-AppRun CWD hardening. # -# Problem (issue #2822): sharun reads its `.preload` entry and library search -# paths relative to the process CWD. When a user launches the AppImage from -# any directory other than the AppDir itself (e.g. double-click from ~/Downloads) -# CWD != AppDir, so ld.so can't find `anylinux.so` (preload) or `libcef.so` -# (LD_LIBRARY_PATH entry). SHARUN_DIR is set correctly — the AppDir IS known — -# but sharun doesn't use it to anchor the preload/library arguments it hands to -# ld.so. +# Canonical `+` entries in shared/lib/lib.path are the primary fix for released +# layouts, where AppRun is the sharun ELF launcher itself. sharun expands `+` +# relative to the AppDir regardless of the caller's CWD. # -# Fix: prepend `cd "$APPDIR"` to the exec line in AppRun so the working -# directory is always the AppDir by the time sharun/the binary runs. This -# mirrors the verified manual workaround from the bug report. +# Older layouts may instead have a shell AppRun that ultimately execs sharun. +# Keep prepending `cd "$APPDIR"` there as compatibility hardening, without +# treating it as the released ELF launcher's library-path repair. # # Returns 0 (true) if the AppRun was modified, 1 if no change was needed. patch_apprun_sharun_cwd() { @@ -585,15 +720,20 @@ patch_apprun_sharun_cwd() { && grep -Eq "$patched_line_re" "$tmp_apprun"; then chmod --reference="$apprun" "$tmp_apprun" mv "$tmp_apprun" "$apprun" - echo "[strip-libs] patched AppRun: added 'cd \"\$APPDIR\"' before exec to fix sharun CWD preload resolution (issue #2822)" + echo "[strip-libs] patched historical shell AppRun: added 'cd \"\$APPDIR\"' before exec" return 0 else rm -f "$tmp_apprun" - echo "[strip-libs] WARNING: could not locate 'exec \"\$@\"' in AppRun — sharun CWD fix not applied; AppImage may fail to launch from non-AppDir CWDs" >&2 + echo "[strip-libs] WARNING: could not locate 'exec \"\$@\"' in historical shell AppRun; canonical '+' lib.path entries remain the released-layout fix" >&2 return 1 fi } +validate_rebuilt_appimage() { + local rebuilt_path="$1" + "$APPIMAGE_RUNTIME_VALIDATOR" "$rebuilt_path" || return 1 +} + strip_one_appimage() { local img="$1" local original @@ -659,7 +799,10 @@ strip_one_appimage() { if sanitize_elf_rpaths "$appdir"; then rewrote_rpaths=1 fi - validate_sharun_lib_path "$appdir" + if ! validate_sharun_lib_path "$appdir"; then + rm -rf "$workdir" + return 1 + fi if ! validate_appimage_required_libs "$appdir"; then rm -rf "$workdir" return 1 @@ -667,6 +810,10 @@ strip_one_appimage() { 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." + if ! validate_rebuilt_appimage "$original"; then + rm -rf "$workdir" + return 1 + fi rm -rf "$workdir" return fi @@ -681,6 +828,10 @@ strip_one_appimage() { --no-appstream squashfs-root "$rebuilt" >/dev/null ) mv "$rebuilt" "$original" + if ! validate_rebuilt_appimage "$original"; then + rm -rf "$workdir" + return 1 + fi rm -rf "$workdir" MODIFIED_PATHS+=("$original") } diff --git a/scripts/release/test-strip-appimage-rpaths.sh b/scripts/release/test-strip-appimage-rpaths.sh index 678c0f9c2..7c24b612e 100644 --- a/scripts/release/test-strip-appimage-rpaths.sh +++ b/scripts/release/test-strip-appimage-rpaths.sh @@ -4,8 +4,11 @@ # 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.* or -# libcef.so is missing from a sharun AppDir. +# 2. validate_appimage_required_libs — hard-fails when anylinux.so, +# libxdo.so.*, or libcef.so is missing from +# a sharun AppDir. +# 3. validate-appimage-runtime.sh — checks the final extracted sharun layout +# and the pre-signing validation seam. # # 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 @@ -42,11 +45,785 @@ done # shellcheck source=/dev/null source "$TARGET" +RUNTIME_VALIDATOR="$SCRIPT_DIR/validate-appimage-runtime.sh" +[ -f "$RUNTIME_VALIDATOR" ] \ + || { echo "[test-rpaths] FAIL: $RUNTIME_VALIDATOR not found" >&2; exit 1; } +# shellcheck source=/dev/null +source "$RUNTIME_VALIDATOR" + WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT fail() { echo "[test-rpaths] FAIL: $1" >&2; exit 1; } +make_sharun_appdir() { + local appdir="$1" + mkdir -p "$appdir/shared/bin" "$appdir/shared/lib/plugins" "$appdir/bin" + + cp "$HOST_ELF" "$appdir/sharun" + printf 'Interpreter not found!' >> "$appdir/sharun" + chmod +x "$appdir/sharun" + ln "$appdir/sharun" "$appdir/AppRun" + ln "$appdir/sharun" "$appdir/bin/OpenHuman" + + cp "$HOST_ELF" "$appdir/shared/bin/OpenHuman" + chmod +x "$appdir/shared/bin/OpenHuman" +} + +assert_lib_path_contents() { + local fixture="$1" + local expected="$2" + local expected_file="$fixture/expected-lib.path" + printf '%s\n' "$expected" >"$expected_file" + cmp -s "$expected_file" "$fixture/shared/lib/lib.path" \ + || fail "unexpected normalized lib.path for ${fixture##*/}" +} + +# --- Case 0: rewrite_sharun_lib_path emits sharun-native marker entries ----- +REWRITE_SQUASHFS="$WORK/rewrite-squashfs" +make_sharun_appdir "$REWRITE_SQUASHFS" +printf '%s\n' "$WORK/squashfs-root/shared/lib" >"$REWRITE_SQUASHFS/shared/lib/lib.path" +rewrite_sharun_lib_path "$REWRITE_SQUASHFS" \ + || fail "squashfs-root shared/lib was not normalized" +assert_lib_path_contents "$REWRITE_SQUASHFS" '+' + +REWRITE_STAGING="$WORK/rewrite-staging" +make_sharun_appdir "$REWRITE_STAGING" +printf '%s\n' "$WORK/appimage_deb/data/usr/lib" >"$REWRITE_STAGING/shared/lib/lib.path" +rewrite_sharun_lib_path "$REWRITE_STAGING" \ + || fail "appimage_deb data/usr/lib was not normalized" +assert_lib_path_contents "$REWRITE_STAGING" '+' + +REWRITE_DESCENDANTS="$WORK/rewrite-descendants" +make_sharun_appdir "$REWRITE_DESCENDANTS" +printf '%s\n%s\n' \ + "$WORK/squashfs-root/shared/lib/plugins" \ + "$WORK/appimage_deb/data/usr/lib/plugins" \ + >"$REWRITE_DESCENDANTS/shared/lib/lib.path" +rewrite_sharun_lib_path "$REWRITE_DESCENDANTS" \ + || fail "AppDir library descendants were not normalized" +assert_lib_path_contents "$REWRITE_DESCENDANTS" '+/plugins' + +REWRITE_MIXED="$WORK/rewrite-mixed" +make_sharun_appdir "$REWRITE_MIXED" +printf '%s\n' '+' '+/plugins' '+' '/opt/unrelated/lib' \ + >"$REWRITE_MIXED/shared/lib/lib.path" +rewrite_sharun_lib_path "$REWRITE_MIXED" \ + || fail "mixed marker entries were not normalized" +assert_lib_path_contents "$REWRITE_MIXED" "$(printf '+\n+/plugins')" + +cp "$REWRITE_MIXED/shared/lib/lib.path" "$REWRITE_MIXED/lib.path.before" +if rewrite_sharun_lib_path "$REWRITE_MIXED"; then + fail "rewrite_sharun_lib_path was not idempotent" +fi +cmp -s "$REWRITE_MIXED/lib.path.before" "$REWRITE_MIXED/shared/lib/lib.path" \ + || fail "idempotent rewrite changed lib.path bytes" + +REWRITE_INVALID="$WORK/rewrite-invalid" +make_sharun_appdir "$REWRITE_INVALID" +printf '%s\n' '/opt/unrelated/lib' >"$REWRITE_INVALID/shared/lib/lib.path" +if ( rewrite_sharun_lib_path "$REWRITE_INVALID" ) 2>"$WORK/rewrite.err"; then + fail "rewrite accepted a lib.path with no AppDir library root" +fi +grep -F "no valid AppDir library entries" "$WORK/rewrite.err" >/dev/null \ + || fail "rewrite failure did not explain why lib.path was rejected" +assert_lib_path_contents "$REWRITE_INVALID" '/opt/unrelated/lib' + +REWRITE_BARE_RELATIVE="$WORK/rewrite-bare-relative" +make_sharun_appdir "$REWRITE_BARE_RELATIVE" +printf '%s\n' '+' 'shared/lib' \ + >"$REWRITE_BARE_RELATIVE/shared/lib/lib.path" +if ( rewrite_sharun_lib_path "$REWRITE_BARE_RELATIVE" ) \ + 2>"$REWRITE_BARE_RELATIVE/error.log"; then + fail "rewrite accepted a mixed lib.path containing a bare-relative entry" +fi +grep -F "malformed entry: 'shared/lib'" \ + "$REWRITE_BARE_RELATIVE/error.log" >/dev/null \ + || fail "rewrite bare-relative error did not identify 'shared/lib'" +assert_lib_path_contents "$REWRITE_BARE_RELATIVE" "$(printf '+\nshared/lib')" + +REWRITE_MARKER_INJECTION="$WORK/rewrite-marker-injection" +make_sharun_appdir "$REWRITE_MARKER_INJECTION" +mkdir -p "$REWRITE_MARKER_INJECTION/shared/lib/plugins+extra" +printf '%s\n' '+/plugins+extra' \ + >"$REWRITE_MARKER_INJECTION/shared/lib/lib.path" +if ( rewrite_sharun_lib_path "$REWRITE_MARKER_INJECTION" ) \ + 2>"$REWRITE_MARKER_INJECTION/error.log"; then + fail "rewrite accepted an extra marker in a canonical suffix" +fi +grep -F "malformed entry: '+/plugins+extra'" \ + "$REWRITE_MARKER_INJECTION/error.log" >/dev/null \ + || fail "rewrite marker-injection error did not identify '+/plugins+extra'" + +REWRITE_DERIVED_MARKER="$WORK/rewrite-derived-marker" +make_sharun_appdir "$REWRITE_DERIVED_MARKER" +derived_marker_path="$WORK/squashfs-root/shared/lib/plugins+extra" +printf '%s\n' "$derived_marker_path" \ + >"$REWRITE_DERIVED_MARKER/shared/lib/lib.path" +if ( rewrite_sharun_lib_path "$REWRITE_DERIVED_MARKER" ) \ + 2>"$REWRITE_DERIVED_MARKER/error.log"; then + fail "rewrite accepted an extra marker in a derived suffix" +fi +grep -F "malformed entry: '$derived_marker_path'" \ + "$REWRITE_DERIVED_MARKER/error.log" >/dev/null \ + || fail "derived marker-injection error did not identify '$derived_marker_path'" +echo "[test-rpaths] ok: sharun lib.path normalization is canonical and idempotent" + +# --- Case 0b: validate_sharun_lib_path rejects unsafe loader entries -------- +assert_lib_path_rejected() { + local label="$1" + local value="$2" + local expected_entry="$3" + local fixture="$WORK/reject-$label" + make_sharun_appdir "$fixture" + printf '%s\n' "$value" >"$fixture/shared/lib/lib.path" + + if ( validate_sharun_lib_path "$fixture" ) 2>"$fixture/error.log"; then + fail "validate_sharun_lib_path accepted $label" + fi + grep -F "invalid sharun lib.path entry '$expected_entry':" \ + "$fixture/error.log" >/dev/null \ + || fail "$label error did not identify offending entry '$expected_entry'" +} + +assert_lib_path_rejected bare-relative 'shared/lib' 'shared/lib' +assert_lib_path_rejected runner-absolute \ + '/home/runner/work/openhuman/squashfs-root/shared/lib' \ + '/home/runner/work/openhuman/squashfs-root/shared/lib' +assert_lib_path_rejected actions-absolute \ + '/__w/openhuman/openhuman/appimage_deb/data/usr/lib' \ + '/__w/openhuman/openhuman/appimage_deb/data/usr/lib' +assert_lib_path_rejected parent-traversal '+/../outside' '+/../outside' +assert_lib_path_rejected nested-traversal \ + '+/plugins/../../outside' '+/plugins/../../outside' +assert_lib_path_rejected missing-directory '+/missing' '+/missing' +assert_lib_path_rejected loader-separator \ + '+/plugins:shared/lib' '+/plugins:shared/lib' +assert_lib_path_rejected leading-whitespace ' +' ' +' +assert_lib_path_rejected trailing-whitespace '+ ' '+ ' + +VALID_ROOT="$WORK/valid-root" +make_sharun_appdir "$VALID_ROOT" +printf '%s\n' '+' >"$VALID_ROOT/shared/lib/lib.path" +validate_sharun_lib_path "$VALID_ROOT" \ + || fail "validate_sharun_lib_path rejected canonical root marker" + +VALID_DESCENDANT="$WORK/valid-descendant" +make_sharun_appdir "$VALID_DESCENDANT" +printf '%s\n' '+' '+/plugins' >"$VALID_DESCENDANT/shared/lib/lib.path" +cp "$VALID_DESCENDANT/shared/lib/lib.path" "$VALID_DESCENDANT/lib.path.before" +validate_sharun_lib_path "$VALID_DESCENDANT" \ + || fail "validate_sharun_lib_path rejected canonical descendant marker" +validate_sharun_lib_path "$VALID_DESCENDANT" \ + || fail "validate_sharun_lib_path rejected a second validation pass" +cmp -s \ + "$VALID_DESCENDANT/lib.path.before" \ + "$VALID_DESCENDANT/shared/lib/lib.path" \ + || fail "validate_sharun_lib_path mutated a normalized file" + +MARKER_INJECTION="$WORK/marker-injection" +make_sharun_appdir "$MARKER_INJECTION" +mkdir -p "$MARKER_INJECTION/shared/lib/plugins+extra" +printf '%s\n' '+/plugins+extra' >"$MARKER_INJECTION/shared/lib/lib.path" +if ( validate_sharun_lib_path "$MARKER_INJECTION" ) \ + 2>"$MARKER_INJECTION/error.log"; then + fail "validate_sharun_lib_path accepted an extra marker in a suffix" +fi +grep -F "invalid sharun lib.path entry '+/plugins+extra':" \ + "$MARKER_INJECTION/error.log" >/dev/null \ + || fail "marker-injection error did not identify '+/plugins+extra'" + +ESCAPE_DIR="$WORK/escape-symlink" +make_sharun_appdir "$ESCAPE_DIR" +mkdir -p "$WORK/outside" +ln -s "$WORK/outside" "$ESCAPE_DIR/shared/lib/escape" +printf '%s\n' '+/escape' >"$ESCAPE_DIR/shared/lib/lib.path" +if ( validate_sharun_lib_path "$ESCAPE_DIR" ) 2>"$ESCAPE_DIR/error.log"; then + fail "validate_sharun_lib_path accepted a symlink escaping shared/lib" +fi +grep -F "invalid sharun lib.path entry '+/escape':" \ + "$ESCAPE_DIR/error.log" >/dev/null \ + || fail "symlink escape error did not identify offending entry '+/escape'" + +RELEASED_LAYOUT="$WORK/released-layout" +make_sharun_appdir "$RELEASED_LAYOUT" +printf '%s\n' '+' >"$RELEASED_LAYOUT/shared/lib/lib.path" +[ "$RELEASED_LAYOUT/AppRun" -ef "$RELEASED_LAYOUT/sharun" ] \ + || fail "AppRun fixture is not hard-linked to sharun" +[ "$RELEASED_LAYOUT/bin/OpenHuman" -ef "$RELEASED_LAYOUT/sharun" ] \ + || fail "bin/OpenHuman fixture is not hard-linked to sharun" +is_executable_elf "$RELEASED_LAYOUT/AppRun" \ + || fail "released-style AppRun fixture is not ELF" +uses_sharun_launcher "$RELEASED_LAYOUT" \ + || fail "released-style hard-linked ELF launcher was not detected" +validate_sharun_lib_path "$RELEASED_LAYOUT" \ + || fail "released-style AppDir has an invalid canonical lib.path" + +CALLER="$WORK/caller" +mkdir -p "$CALLER" +( + cd "$CALLER" + expanded="$( + sed "s|+|$RELEASED_LAYOUT/shared/lib|g" \ + "$RELEASED_LAYOUT/shared/lib/lib.path" | + paste -sd ':' - + )" + [ "$expanded" = "$RELEASED_LAYOUT/shared/lib" ] \ + || fail "sharun marker did not expand beneath the fixture AppDir" +) + +released_apprun_inode="$(ls -di "$RELEASED_LAYOUT/AppRun")" +if patch_apprun_sharun_cwd "$RELEASED_LAYOUT"; then + fail "patch_apprun_sharun_cwd modified a released-style ELF AppRun" +fi +[ "$released_apprun_inode" = "$(ls -di "$RELEASED_LAYOUT/AppRun")" ] \ + || fail "patch_apprun_sharun_cwd changed the released-style ELF AppRun inode" +echo "[test-rpaths] ok: sharun lib.path validation is fail-closed for the released ELF launcher" + +# --- Case 0c: final extracted runtime layout is validated before signing ---- +make_runtime_appdir() { + local appdir="$1" + make_sharun_appdir "$appdir" + printf '%s\n' '+' >"$appdir/shared/lib/lib.path" + + cp "$HOST_ELF" "$appdir/shared/lib/anylinux.so" + cp "$HOST_ELF" "$appdir/shared/lib/libxdo.so.3" + cp "$HOST_ELF" "$appdir/shared/lib/libcef.so" + + local elf + for elf in \ + "$appdir/AppRun" \ + "$appdir/sharun" \ + "$appdir/bin/OpenHuman" \ + "$appdir/shared/bin/OpenHuman" \ + "$appdir/shared/lib/anylinux.so" \ + "$appdir/shared/lib/libxdo.so.3" \ + "$appdir/shared/lib/libcef.so"; do + patchelf --remove-rpath "$elf" + done +} + +assert_runtime_layout_rejected() { + local label="$1" + local expected="$2" + shift 2 + local fixture="$WORK/runtime-$label" + make_runtime_appdir "$fixture" + "$@" "$fixture" + if ( APPIMAGE_EXPECTED_NEEDED="" validate_extracted_appdir "$fixture" ) \ + >"$fixture/output.log" 2>&1; then + fail "validate_extracted_appdir accepted $label" + fi + grep -F "$expected" "$fixture/output.log" >/dev/null \ + || fail "$label failure did not report '$expected'" +} + +remove_anylinux() { rm -f "$1/shared/lib/anylinux.so"; } +remove_libxdo() { rm -f "$1/shared/lib/libxdo.so.3"; } +remove_libcef() { rm -f "$1/shared/lib/libcef.so"; } +remove_real_app() { rm -f "$1/shared/bin/OpenHuman"; } +replace_real_app_with_text() { + rm -f "$1/shared/bin/OpenHuman" + printf '%s\n' 'not an ELF' >"$1/shared/bin/OpenHuman" + chmod +x "$1/shared/bin/OpenHuman" +} +replace_apprun() { + rm -f "$1/AppRun" + cp "$HOST_ELF" "$1/AppRun" + chmod +x "$1/AppRun" +} +replace_bin_launcher() { + rm -f "$1/bin/OpenHuman" + cp "$HOST_ELF" "$1/bin/OpenHuman" + chmod +x "$1/bin/OpenHuman" +} +inject_runner_rpath() { + patchelf --set-rpath \ + '/home/runner/work/openhuman/openhuman/shared/lib' \ + "$1/shared/lib/libcef.so" +} +inject_actions_rpath() { + patchelf --set-rpath \ + '/__w/openhuman/openhuman/shared/lib' \ + "$1/shared/lib/libcef.so" +} + +RUNTIME_COMPLETE="$WORK/runtime-complete" +make_runtime_appdir "$RUNTIME_COMPLETE" +APPIMAGE_EXPECTED_NEEDED="" validate_extracted_appdir "$RUNTIME_COMPLETE" \ + || fail "validate_extracted_appdir rejected a complete runtime fixture" + +RUNTIME_EQUIVALENT="$WORK/runtime-equivalent-launchers" +make_runtime_appdir "$RUNTIME_EQUIVALENT" +rm "$RUNTIME_EQUIVALENT/AppRun" "$RUNTIME_EQUIVALENT/bin/OpenHuman" +cp "$RUNTIME_EQUIVALENT/sharun" "$RUNTIME_EQUIVALENT/AppRun" +cp "$RUNTIME_EQUIVALENT/sharun" "$RUNTIME_EQUIVALENT/bin/OpenHuman" +chmod +x "$RUNTIME_EQUIVALENT/AppRun" "$RUNTIME_EQUIVALENT/bin/OpenHuman" +APPIMAGE_EXPECTED_NEEDED="" validate_extracted_appdir "$RUNTIME_EQUIVALENT" \ + || fail "validate_extracted_appdir rejected byte-equivalent launchers" + +FAKE_APPIMAGE="$WORK/final-fixture.AppImage" +printf '%s\n' \ + '#!/usr/bin/env bash' \ + '[ "$1" = "--appimage-extract" ] || exit 9' \ + 'cp -R "$EXTRACT_SOURCE" squashfs-root' \ + >"$FAKE_APPIMAGE" +chmod +x "$FAKE_APPIMAGE" +export EXTRACT_SOURCE="$RUNTIME_COMPLETE" +APPIMAGE_EXPECTED_NEEDED="" validate_final_appimage "$FAKE_APPIMAGE" \ + || fail "validate_final_appimage rejected a complete extracted fixture" + +if APPIMAGE_EXPECTED_NEEDED="" \ + "$RUNTIME_VALIDATOR" "$FAKE_APPIMAGE" \ + >"$WORK/direct-validator.log" 2>&1; then + fail "direct validator allowed a fixture override to disable production NEEDED checks" +fi +grep -F "missing NEEDED entry 'libxdo.so.3'" \ + "$WORK/direct-validator.log" >/dev/null \ + || fail "direct validator did not enforce the production NEEDED contract" + +RUNTIME_BAD_LIBPATH="$WORK/runtime-bad-libpath" +cp -R "$RUNTIME_COMPLETE" "$RUNTIME_BAD_LIBPATH" +printf '%s\n' 'shared/lib' >"$RUNTIME_BAD_LIBPATH/shared/lib/lib.path" +if ( APPIMAGE_EXPECTED_NEEDED="" \ + validate_extracted_appdir "$RUNTIME_BAD_LIBPATH" ) \ + >"$RUNTIME_BAD_LIBPATH/conditional.log" 2>&1; then + fail "conditional validate_extracted_appdir suppressed lib.path validation failure" +fi + +RUNTIME_FINAL_INVALID="$WORK/runtime-final-invalid" +cp -R "$RUNTIME_COMPLETE" "$RUNTIME_FINAL_INVALID" +rm -f "$RUNTIME_FINAL_INVALID/shared/lib/anylinux.so" +if ( EXTRACT_SOURCE="$RUNTIME_FINAL_INVALID" APPIMAGE_EXPECTED_NEEDED="" \ + validate_final_appimage "$FAKE_APPIMAGE" ) \ + >"$WORK/conditional-final.log" 2>&1; then + fail "conditional validate_final_appimage suppressed extracted-layout failure" +fi + +REAL_PATCHELF="$(command -v patchelf)" +PATCHELF_FAIL_BIN="$WORK/patchelf-fail-bin" +mkdir -p "$PATCHELF_FAIL_BIN" +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'if [ "$1" = "--print-needed" ]; then exit 42; fi' \ + 'exec "$REAL_PATCHELF" "$@"' \ + >"$PATCHELF_FAIL_BIN/patchelf" +chmod +x "$PATCHELF_FAIL_BIN/patchelf" +export REAL_PATCHELF +if ( PATH="$PATCHELF_FAIL_BIN:$PATH" APPIMAGE_EXPECTED_NEEDED="" \ + validate_extracted_appdir "$RUNTIME_COMPLETE" ) \ + >"$WORK/patchelf-failure.log" 2>&1; then + fail "conditional validate_extracted_appdir suppressed patchelf failure" +fi + +SMOKE_BIN="$WORK/smoke-bin" +mkdir -p "$SMOKE_BIN" +SMOKE_RECORD="$WORK/smoke-command-record" +SMOKE_PROBE="$WORK/smoke-probe-record" +REAL_ENV="$(command -v env)" +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'printf "timeout-cwd=%s\n" "$PWD" >>"$SMOKE_RECORD"' \ + 'while [ "$#" -gt 0 ]; do' \ + ' case "$1" in' \ + ' --signal=*|--kill-after=*|15s) shift ;;' \ + ' *) break ;;' \ + ' esac' \ + 'done' \ + '"$@"' \ + '[ -n "${SMOKE_TIMEOUT_MESSAGE:-}" ] && printf "%s\n" "$SMOKE_TIMEOUT_MESSAGE" >&2' \ + 'exit "${SMOKE_TIMEOUT_STATUS:-124}"' \ + >"$SMOKE_BIN/timeout" +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'printf "xvfb-invoked\n" >>"$SMOKE_RECORD"' \ + 'while [ "$#" -gt 0 ]; do' \ + ' case "$1" in' \ + ' -a|--server-args=*) shift ;;' \ + ' *) break ;;' \ + ' esac' \ + 'done' \ + 'exec "$@"' \ + >"$SMOKE_BIN/xvfb-run" +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'args=("$@")' \ + 'index=0' \ + 'while [ "$index" -lt "${#args[@]}" ]; do' \ + ' arg="${args[$index]}"' \ + ' case "$arg" in' \ + ' -u)' \ + ' next=$((index + 1))' \ + ' printf "env-unset=%s\n" "${args[$next]}" >>"$SMOKE_RECORD"' \ + ' index=$((index + 2))' \ + ' ;;' \ + ' HOME=*|XDG_CONFIG_HOME=*|XDG_DATA_HOME=*|XDG_CACHE_HOME=*)' \ + ' printf "env-assignment=%s\n" "$arg" >>"$SMOKE_RECORD"' \ + ' index=$((index + 1))' \ + ' ;;' \ + ' OPENHUMAN_CEF_PREWARM=*|OPENHUMAN_DISABLE_GPU=*) index=$((index + 1)) ;;' \ + ' *) break ;;' \ + ' esac' \ + 'done' \ + 'exec "$REAL_ENV" "${args[@]}"' \ + >"$SMOKE_BIN/env" +chmod +x "$SMOKE_BIN/timeout" "$SMOKE_BIN/xvfb-run" "$SMOKE_BIN/env" +SMOKE_APPDIR="$WORK/smoke-appdir" +mkdir -p "$SMOKE_APPDIR" +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'printf "apprun-cwd=%s\n" "$PWD" >>"$SMOKE_PROBE"' \ + 'printf "home=%s\n" "$HOME" >>"$SMOKE_PROBE"' \ + 'printf "config=%s\n" "$XDG_CONFIG_HOME" >>"$SMOKE_PROBE"' \ + 'printf "data=%s\n" "$XDG_DATA_HOME" >>"$SMOKE_PROBE"' \ + 'printf "cache=%s\n" "$XDG_CACHE_HOME" >>"$SMOKE_PROBE"' \ + '[ -z "${GITHUB_TOKEN+x}" ] || exit 71' \ + '[ -z "${GH_TOKEN+x}" ] || exit 72' \ + '[ -z "${TAURI_SIGNING_PRIVATE_KEY+x}" ] || exit 73' \ + '[ -z "${TAURI_SIGNING_PRIVATE_KEY_PASSWORD+x}" ] || exit 74' \ + '[ -z "${SENTRY_AUTH_TOKEN+x}" ] || exit 75' \ + '[ -z "${OPENAI_API_KEY+x}" ] || exit 76' \ + 'printf "probe-executed\n" >>"$SMOKE_PROBE"' \ + >"$SMOKE_APPDIR/AppRun" +chmod +x "$SMOKE_APPDIR/AppRun" +export SMOKE_RECORD SMOKE_PROBE REAL_ENV +SMOKE_ROOT="$WORK/smoke-runtime" +mkdir -p "$SMOKE_ROOT/foreign-cwd" +GITHUB_TOKEN='secret-github-value' \ +GH_TOKEN='secret-gh-value' \ +TAURI_SIGNING_PRIVATE_KEY='secret-signing-value' \ +TAURI_SIGNING_PRIVATE_KEY_PASSWORD='secret-password-value' \ +SENTRY_AUTH_TOKEN='secret-sentry-value' \ +OPENAI_API_KEY='secret-openai-value' \ +PATH="$SMOKE_BIN:$PATH" smoke_extracted_apprun \ + "$SMOKE_APPDIR" \ + "$SMOKE_ROOT/foreign-cwd" \ + "$SMOKE_ROOT/success.log" \ + || fail "smoke_extracted_apprun did not accept timeout status 124" +grep -Fx "timeout-cwd=$SMOKE_ROOT/foreign-cwd" "$SMOKE_RECORD" >/dev/null \ + || fail "smoke did not invoke timeout from the foreign CWD" +grep -Fx "xvfb-invoked" "$SMOKE_RECORD" >/dev/null \ + || fail "smoke did not invoke xvfb-run" +for secret_name in \ + GITHUB_TOKEN \ + GH_TOKEN \ + OPENHUMAN_CEF_NO_SANDBOX \ + TAURI_SIGNING_PRIVATE_KEY \ + TAURI_SIGNING_PRIVATE_KEY_PASSWORD \ + SENTRY_AUTH_TOKEN \ + OPENAI_API_KEY; do + grep -Fx "env-unset=$secret_name" "$SMOKE_RECORD" >/dev/null \ + || fail "smoke env did not unset $secret_name" +done +grep -Fx "env-assignment=HOME=$SMOKE_ROOT/home" "$SMOKE_RECORD" >/dev/null \ + || fail "smoke HOME was not isolated" +grep -Fx "env-assignment=XDG_CONFIG_HOME=$SMOKE_ROOT/config" "$SMOKE_RECORD" >/dev/null \ + || fail "smoke XDG_CONFIG_HOME was not isolated" +grep -Fx "env-assignment=XDG_DATA_HOME=$SMOKE_ROOT/data" "$SMOKE_RECORD" >/dev/null \ + || fail "smoke XDG_DATA_HOME was not isolated" +grep -Fx "env-assignment=XDG_CACHE_HOME=$SMOKE_ROOT/cache" "$SMOKE_RECORD" >/dev/null \ + || fail "smoke XDG_CACHE_HOME was not isolated" +grep -Fx "apprun-cwd=$SMOKE_ROOT/foreign-cwd" "$SMOKE_PROBE" >/dev/null \ + || fail "smoke AppRun did not execute from the foreign CWD" +grep -Fx "probe-executed" "$SMOKE_PROBE" >/dev/null \ + || fail "smoke AppRun probe did not execute" +if grep -F 'secret-' "$SMOKE_RECORD" "$SMOKE_PROBE" >/dev/null; then + fail "smoke command records exposed a secret value" +fi +if ( PATH="$SMOKE_BIN:$PATH" SMOKE_TIMEOUT_STATUS=0 \ + smoke_extracted_apprun \ + "$SMOKE_APPDIR" \ + "$SMOKE_ROOT/foreign-cwd" \ + "$SMOKE_ROOT/early-exit.log" ) >/dev/null 2>&1; then + fail "smoke_extracted_apprun accepted an immediate clean exit" +fi +if ( PATH="$SMOKE_BIN:$PATH" \ + SMOKE_TIMEOUT_MESSAGE='libcef.so: cannot open shared object file' \ + smoke_extracted_apprun \ + "$SMOKE_APPDIR" \ + "$SMOKE_ROOT/foreign-cwd" \ + "$SMOKE_ROOT/loader-error.log" ) >/dev/null 2>&1; then + fail "smoke_extracted_apprun accepted a forbidden loader diagnostic" +fi +( + set +e + PATH="$SMOKE_BIN:$PATH" smoke_extracted_apprun \ + "$SMOKE_APPDIR" \ + "$SMOKE_ROOT/foreign-cwd" \ + "$SMOKE_ROOT/errexit-disabled.log" \ + || exit 81 + case "$-" in + *e*) exit 82 ;; + esac +) || fail "smoke_extracted_apprun changed a sourced caller's disabled errexit state" + +APPARMOR_BIN="$WORK/apparmor-bin" +APPARMOR_RECORD="$WORK/apparmor-command-record" +APPARMOR_PROFILE="$WORK/openhuman-appimage-smoke.profile" +APPARMOR_PROFILE_SNAPSHOT="$WORK/openhuman-appimage-smoke.profile.loaded" +mkdir -p "$APPARMOR_BIN" +printf '%s\n' '#!/usr/bin/env bash' 'exit 0' \ + >"$APPARMOR_BIN/apparmor_parser" +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'printf "%s\n" "$*" >>"$APPARMOR_RECORD"' \ + 'if [ "$2" = "apparmor_parser" ] && [ "$3" = "--replace" ]; then' \ + ' cp "$4" "$APPARMOR_PROFILE_SNAPSHOT"' \ + 'fi' \ + >"$APPARMOR_BIN/sudo" +chmod +x "$APPARMOR_BIN/apparmor_parser" "$APPARMOR_BIN/sudo" +export APPARMOR_RECORD APPARMOR_PROFILE_SNAPSHOT +PATH="$APPARMOR_BIN:$PATH" install_smoke_userns_profile \ + "$RUNTIME_COMPLETE" "$APPARMOR_PROFILE" \ + || fail "install_smoke_userns_profile rejected the fixture AppDir" +grep -F "\"$RUNTIME_COMPLETE/shared/bin/OpenHuman\"" \ + "$APPARMOR_PROFILE_SNAPSHOT" >/dev/null \ + || fail "AppArmor profile did not attach to the extracted real executable" +grep -Fx " userns," "$APPARMOR_PROFILE_SNAPSHOT" >/dev/null \ + || fail "AppArmor profile did not preserve Chromium's userns sandbox" +grep -Fx -- \ + "--non-interactive apparmor_parser --replace $APPARMOR_PROFILE" \ + "$APPARMOR_RECORD" >/dev/null \ + || fail "AppArmor profile was not loaded through non-interactive sudo" +PATH="$APPARMOR_BIN:$PATH" remove_smoke_userns_profile "$APPARMOR_PROFILE" \ + || fail "remove_smoke_userns_profile rejected the loaded profile" +grep -Fx -- \ + "--non-interactive apparmor_parser --remove $APPARMOR_PROFILE" \ + "$APPARMOR_RECORD" >/dev/null \ + || fail "AppArmor profile was not removed after the smoke" + +: >"$APPARMOR_RECORD" +set +e +( + smoke_extracted_apprun() { + printf '%s\n' "smoke" >>"$APPARMOR_RECORD" + return 37 + } + PATH="$APPARMOR_BIN:$PATH" smoke_extracted_apprun_with_userns \ + "$RUNTIME_COMPLETE" \ + "$SMOKE_ROOT/foreign-cwd" \ + "$SMOKE_ROOT/apparmor-failure.log" \ + "$APPARMOR_PROFILE" +) +apparmor_smoke_status=$? +set -e +[ "$apparmor_smoke_status" -eq 37 ] \ + || fail "AppArmor wrapper did not preserve smoke failure status 37" +printf '%s\n' \ + "--non-interactive apparmor_parser --replace $APPARMOR_PROFILE" \ + "smoke" \ + "--non-interactive apparmor_parser --remove $APPARMOR_PROFILE" \ + >"$WORK/apparmor-expected-order" +cmp -s "$WORK/apparmor-expected-order" "$APPARMOR_RECORD" \ + || fail "AppArmor profile was not loaded before and removed after a failing smoke" + +APPARMOR_TERM_RECORD="$WORK/apparmor-term-command-record" +APPARMOR_TERM_MARKER="$WORK/apparmor-term-smoke-started" +APPARMOR_TERM_RUNNER="$WORK/apparmor-term-runner.sh" +printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'set -euo pipefail' \ + '# shellcheck source=/dev/null' \ + 'source "$RUNTIME_VALIDATOR"' \ + 'smoke_extracted_apprun() {' \ + ' printf "%s\n" "smoke-start" >>"$APPARMOR_RECORD"' \ + ' : >"$APPARMOR_TERM_MARKER"' \ + ' while :; do sleep 1; done' \ + '}' \ + 'smoke_extracted_apprun_with_userns \' \ + ' "$RUNTIME_COMPLETE" \' \ + ' "$SMOKE_ROOT/foreign-cwd" \' \ + ' "$SMOKE_ROOT/apparmor-term.log" \' \ + ' "$APPARMOR_PROFILE"' \ + >"$APPARMOR_TERM_RUNNER" +chmod +x "$APPARMOR_TERM_RUNNER" +export \ + APPARMOR_TERM_MARKER \ + RUNTIME_COMPLETE \ + RUNTIME_VALIDATOR \ + SMOKE_ROOT \ + APPARMOR_PROFILE +APPARMOR_RECORD="$APPARMOR_TERM_RECORD" \ +PATH="$APPARMOR_BIN:$PATH" \ + bash "$APPARMOR_TERM_RUNNER" & +apparmor_term_pid=$! +for _ in $(seq 1 100); do + if [ -s "$APPARMOR_TERM_RECORD" ] && [ -f "$APPARMOR_TERM_MARKER" ]; then + break + fi + sleep 0.05 +done +[ -s "$APPARMOR_TERM_RECORD" ] && [ -f "$APPARMOR_TERM_MARKER" ] || { + kill -TERM "$apparmor_term_pid" 2>/dev/null || true + fail "AppArmor TERM fixture did not reach the smoke" +} +kill -TERM "$apparmor_term_pid" +set +e +wait "$apparmor_term_pid" +apparmor_term_status=$? +set -e +[ "$apparmor_term_status" -eq 143 ] \ + || fail "AppArmor TERM fixture exited $apparmor_term_status instead of 143" +printf '%s\n' \ + "--non-interactive apparmor_parser --replace $APPARMOR_PROFILE" \ + "smoke-start" \ + "--non-interactive apparmor_parser --remove $APPARMOR_PROFILE" \ + >"$WORK/apparmor-term-expected-order" +cmp -s "$WORK/apparmor-term-expected-order" "$APPARMOR_TERM_RECORD" \ + || fail "AppArmor profile was not removed exactly once after TERM" +echo "[test-rpaths] ok: CI smoke grants userns only to the extracted executable" + +assert_runtime_layout_rejected missing-anylinux \ + "missing anylinux.so" remove_anylinux +assert_runtime_layout_rejected missing-libxdo \ + "missing libxdo.so.*" remove_libxdo +assert_runtime_layout_rejected missing-libcef \ + "missing libcef.so" remove_libcef +assert_runtime_layout_rejected missing-real-app \ + "shared/bin/OpenHuman is not an executable ELF" remove_real_app +assert_runtime_layout_rejected text-real-app \ + "shared/bin/OpenHuman is not an executable ELF" replace_real_app_with_text +assert_runtime_layout_rejected mismatched-apprun \ + "AppRun does not match sharun" replace_apprun +assert_runtime_layout_rejected mismatched-bin-launcher \ + "bin/OpenHuman does not match sharun" replace_bin_launcher +assert_runtime_layout_rejected runner-rpath \ + "/home/runner/work/openhuman/openhuman/shared/lib" inject_runner_rpath +assert_runtime_layout_rejected actions-rpath \ + "/__w/openhuman/openhuman/shared/lib" inject_actions_rpath + +RUNTIME_NEEDED="$WORK/runtime-needed" +make_runtime_appdir "$RUNTIME_NEEDED" +if ( APPIMAGE_EXPECTED_NEEDED="libxdo.so.3" \ + validate_extracted_appdir "$RUNTIME_NEEDED" ) \ + >"$RUNTIME_NEEDED/missing-xdo.log" 2>&1; then + fail "validate_extracted_appdir accepted a missing libxdo NEEDED entry" +fi +grep -F "missing NEEDED entry 'libxdo.so.3'" \ + "$RUNTIME_NEEDED/missing-xdo.log" >/dev/null \ + || fail "missing libxdo NEEDED diagnostic was not specific" + +patchelf --add-needed libxdo.so.3 "$RUNTIME_NEEDED/shared/bin/OpenHuman" +if ( APPIMAGE_EXPECTED_NEEDED="libcef.so" \ + validate_extracted_appdir "$RUNTIME_NEEDED" ) \ + >"$RUNTIME_NEEDED/missing-cef.log" 2>&1; then + fail "validate_extracted_appdir accepted a missing libcef NEEDED entry" +fi +grep -F "missing NEEDED entry 'libcef.so'" \ + "$RUNTIME_NEEDED/missing-cef.log" >/dev/null \ + || fail "missing libcef NEEDED diagnostic was not specific" + +patchelf --add-needed libcef.so "$RUNTIME_NEEDED/shared/bin/OpenHuman" +APPIMAGE_EXPECTED_NEEDED="libxdo.so.3 libcef.so" \ + validate_extracted_appdir "$RUNTIME_NEEDED" \ + || fail "validate_extracted_appdir rejected complete NEEDED entries" + +VALIDATOR_RECORD="$WORK/runtime-validator-record" +runtime_validator_stub() { + [ -f "$1" ] || return 10 + [ "${#MODIFIED_PATHS[@]}" -eq 0 ] || return 11 + printf '%s\n' "$1" >"$VALIDATOR_RECORD" +} +REBUILT_FIXTURE="$WORK/rebuilt.AppImage" +: >"$REBUILT_FIXTURE" +MODIFIED_PATHS=() +APPIMAGE_RUNTIME_VALIDATOR=runtime_validator_stub \ + validate_rebuilt_appimage "$REBUILT_FIXTURE" \ + || fail "validate_rebuilt_appimage did not forward to its configured command" +[ "$(cat "$VALIDATOR_RECORD")" = "$REBUILT_FIXTURE" ] \ + || fail "validate_rebuilt_appimage forwarded the wrong artifact path" +runtime_validator_failure_stub() { return 37; } +if APPIMAGE_RUNTIME_VALIDATOR=runtime_validator_failure_stub \ + validate_rebuilt_appimage "$REBUILT_FIXTURE"; then + fail "conditional validate_rebuilt_appimage suppressed validator failure" +fi + +CLEANUP_IMAGE="$WORK/cleanup-invalid.AppImage" +printf '%s\n' \ + '#!/usr/bin/env bash' \ + '[ "$1" = "--appimage-extract" ] || exit 9' \ + 'mkdir -p squashfs-root/shared/lib' \ + >"$CLEANUP_IMAGE" +chmod +x "$CLEANUP_IMAGE" +CLEANUP_WORKDIR="$WORK/cleanup-invalid-workdir" +CLEANUP_LOG="$WORK/cleanup-invalid.log" +set +e +TARGET="$TARGET" \ +CLEANUP_IMAGE="$CLEANUP_IMAGE" \ +CLEANUP_WORKDIR="$CLEANUP_WORKDIR" \ + bash -c ' + set -euo pipefail + # shellcheck source=/dev/null + source "$TARGET" + mktemp() { + if [ "${1:-}" = "-d" ]; then + mkdir -p "$CLEANUP_WORKDIR" + printf "%s\n" "$CLEANUP_WORKDIR" + else + command mktemp "$@" + fi + } + ensure_sharun_interpreter() { return 1; } + rewrite_sharun_lib_path() { return 1; } + patch_apprun_sharun_cwd() { return 1; } + sanitize_elf_rpaths() { return 1; } + validate_sharun_lib_path() { + echo "intentional lib.path validation failure" >&2 + return 47 + } + validate_appimage_required_libs() { return 0; } + validate_rebuilt_appimage() { return 0; } + strip_one_appimage "$CLEANUP_IMAGE" + ' >"$CLEANUP_LOG" 2>&1 +cleanup_status=$? +set -e +[ "$cleanup_status" -ne 0 ] \ + || fail "strip_one_appimage accepted a failing lib.path validation" +grep -F "intentional lib.path validation failure" "$CLEANUP_LOG" >/dev/null \ + || fail "strip_one_appimage hid the original lib.path validation error" +[ ! -e "$CLEANUP_WORKDIR" ] \ + || fail "strip_one_appimage leaked its workdir after lib.path validation failed" + +NOOP_IMAGE="$WORK/noop-final.AppImage" +printf '%s\n' \ + '#!/usr/bin/env bash' \ + '[ "$1" = "--appimage-extract" ] || exit 9' \ + 'mkdir -p squashfs-root' \ + >"$NOOP_IMAGE" +chmod +x "$NOOP_IMAGE" +NOOP_IMAGE_BEFORE="$(sha256sum "$NOOP_IMAGE" | awk '{print $1}')" +NOOP_VALIDATOR_RECORD="$WORK/noop-validator-record" +noop_runtime_validator_stub() { + [ -f "$1" ] || return 20 + [ "${#MODIFIED_PATHS[@]}" -eq 0 ] || return 21 + printf '%s\n' "$1" >>"$NOOP_VALIDATOR_RECORD" +} +MODIFIED_PATHS=() +APPIMAGE_RUNTIME_VALIDATOR=noop_runtime_validator_stub \ + strip_one_appimage "$NOOP_IMAGE" \ + || fail "strip_one_appimage rejected a valid no-change final artifact" +[ "$(wc -l <"$NOOP_VALIDATOR_RECORD" | tr -d ' ')" -eq 1 ] \ + || fail "no-change AppImage was not validated exactly once" +[ "$(cat "$NOOP_VALIDATOR_RECORD")" = "$(realpath "$NOOP_IMAGE")" ] \ + || fail "no-change validation did not inspect the original final AppImage" +[ "${#MODIFIED_PATHS[@]}" -eq 0 ] \ + || fail "no-change AppImage was incorrectly registered for re-signing" +[ "$NOOP_IMAGE_BEFORE" = "$(sha256sum "$NOOP_IMAGE" | awk '{print $1}')" ] \ + || fail "no-change AppImage bytes were unexpectedly replaced" + +mv_line="$(grep -nF 'mv "$rebuilt" "$original"' "$TARGET" | cut -d: -f1)" +validate_line="$( + grep -nF 'validate_rebuilt_appimage "$original"' "$TARGET" \ + | tail -n 1 \ + | cut -d: -f1 +)" +modified_line="$(grep -nF 'MODIFIED_PATHS+=("$original")' "$TARGET" | cut -d: -f1)" +[ -n "$mv_line" ] && [ -n "$validate_line" ] && [ -n "$modified_line" ] \ + || fail "post-repack validation ordering statements are missing" +[ "$mv_line" -lt "$validate_line" ] && [ "$validate_line" -lt "$modified_line" ] \ + || fail "final artifact validation does not run after mv and before signing registration" +echo "[test-rpaths] ok: final runtime layout and pre-signing handoff are fail-closed" + # --- Case 1: sanitize_elf_rpaths strips a CI build-machine RPATH ------------ APPDIR="$WORK/squashfs-root" mkdir -p "$APPDIR/usr/lib" "$APPDIR/shared/lib" @@ -130,20 +907,28 @@ chmod +x "$GUARD_DIR/sharun" # 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. +# 2a — the sharun preload library absent → must exit non-zero. +if ( validate_appimage_required_libs "$GUARD_DIR" ) 2>/dev/null; then + fail "validate_appimage_required_libs passed despite missing anylinux.so" +fi +echo "[test-rpaths] ok: guard fails when anylinux.so is absent" + +: > "$GUARD_DIR/shared/lib/anylinux.so" + +# 2b — 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 but libcef absent → must still fail. +# 2c — libxdo present but libcef absent → must still fail. : > "$GUARD_DIR/shared/lib/libxdo.so.3" if ( validate_appimage_required_libs "$GUARD_DIR" ) 2>/dev/null; then fail "validate_appimage_required_libs passed despite missing libcef.so" fi echo "[test-rpaths] ok: guard fails when libcef.so is absent" -# 2c — both required runtime libraries present → must pass. +# 2d — all required runtime libraries present → must pass. mkdir -p "$GUARD_DIR/usr/lib" : > "$GUARD_DIR/shared/lib/libxdo.so.3" : > "$GUARD_DIR/usr/lib/libcef.so" diff --git a/scripts/release/validate-appimage-runtime.sh b/scripts/release/validate-appimage-runtime.sh new file mode 100755 index 000000000..e20c96f65 --- /dev/null +++ b/scripts/release/validate-appimage-runtime.sh @@ -0,0 +1,368 @@ +#!/usr/bin/env bash +# Validate the final, repacked AppImage bytes before release signing. +# +# This file is sourceable for fixture tests. When executed directly it extracts +# exactly one AppImage from a foreign working directory, validates the released +# sharun layout, and optionally performs a bounded Xvfb startup smoke. +# +# Usage: validate-appimage-runtime.sh +# +# Env: +# APPIMAGE_RUNTIME_SMOKE — set to 1 to require the bounded Xvfb startup smoke; +# otherwise only static final-artifact checks run +# APPIMAGE_RUNTIME_APPARMOR_USERNS — set to 1 on Ubuntu 24.04 CI to load a +# temporary, per-executable AppArmor profile granting +# Chromium's sandbox access to user namespaces + +set -euo pipefail + +RUNTIME_VALIDATOR_SCRIPT_DIR="$( + cd "$(dirname "${BASH_SOURCE[0]}")" && pwd +)" +# shellcheck source=strip-appimage-graphics-libs.sh +source "$RUNTIME_VALIDATOR_SCRIPT_DIR/strip-appimage-graphics-libs.sh" + +runtime_validation_error() { + echo "[appimage-runtime] ERROR: $*" >&2 + return 1 +} + +validate_extracted_appdir() { + local appdir="$1" + local lib_path="$appdir/shared/lib/lib.path" + + echo "[appimage-runtime] Validating extracted AppDir: $appdir" + if [ -f "$lib_path" ]; then + echo "[appimage-runtime] shared/lib/lib.path:" + sed 's/^/[appimage-runtime] /' "$lib_path" + else + echo "[appimage-runtime] shared/lib/lib.path: " + fi + + if ! uses_sharun_launcher "$appdir"; then + runtime_validation_error "released AppDir does not use a detectable sharun launcher" + return 1 + fi + + local executable + for executable in \ + "$appdir/AppRun" \ + "$appdir/sharun" \ + "$appdir/bin/OpenHuman" \ + "$appdir/shared/bin/OpenHuman"; do + if ! is_executable_elf "$executable"; then + runtime_validation_error "${executable#"$appdir"/} is not an executable ELF" + return 1 + fi + done + + local alias + for alias in "$appdir/AppRun" "$appdir/bin/OpenHuman"; do + if [ "$alias" -ef "$appdir/sharun" ] || cmp -s "$alias" "$appdir/sharun"; then + continue + fi + runtime_validation_error "${alias#"$appdir"/} does not match sharun" + return 1 + done + + validate_sharun_lib_path "$appdir" || return 1 + validate_appimage_required_libs "$appdir" || return 1 + + local real_app="$appdir/shared/bin/OpenHuman" + local needed + if ! needed="$(patchelf --print-needed "$real_app")"; then + runtime_validation_error \ + "could not read NEEDED entries from shared/bin/OpenHuman" + return 1 + fi + echo "[appimage-runtime] shared/bin/OpenHuman NEEDED entries:" + if [ -n "$needed" ]; then + printf '%s\n' "$needed" | sed 's/^/[appimage-runtime] /' + else + echo "[appimage-runtime] " + fi + + local expected_needed="${APPIMAGE_EXPECTED_NEEDED-libxdo.so.3 libcef.so}" + local needed_name + for needed_name in $expected_needed; do + if ! printf '%s\n' "$needed" | grep -Fx "$needed_name" >/dev/null; then + runtime_validation_error "shared/bin/OpenHuman is missing NEEDED entry '$needed_name'" + return 1 + fi + done + + local root elf rpath + for root in \ + "$appdir/shared/bin" \ + "$appdir/shared/lib" \ + "$appdir/bin" \ + "$appdir/lib" \ + "$appdir/usr/bin" \ + "$appdir/usr/lib"; do + [ -d "$root" ] || continue + while IFS= read -r -d '' elf; do + is_elf "$elf" || continue + rpath="$(patchelf --print-rpath "$elf" 2>/dev/null || true)" + case "$rpath" in + *"/home/runner/"*|*"/__w/"*) + runtime_validation_error \ + "${elf#"$appdir"/} retains forbidden build-runner RPATH '$rpath'" + return 1 + ;; + esac + done < <(find "$root" -type f -print0) + done +} + +smoke_extracted_apprun() { + local appdir="$1" + local foreign_cwd="$2" + local log_file="$3" + + command -v timeout >/dev/null 2>&1 \ + || { runtime_validation_error "timeout is required for the AppImage smoke"; return 1; } + command -v xvfb-run >/dev/null 2>&1 \ + || { runtime_validation_error "xvfb-run is required for the AppImage smoke"; return 1; } + + echo "[appimage-runtime] Smoking AppRun from AppDir: $appdir" + echo "[appimage-runtime] Smoke caller CWD: $foreign_cwd" + + local temp_root + temp_root="$(dirname "$foreign_cwd")" + local smoke_home="$temp_root/home" + local smoke_config="$temp_root/config" + local smoke_data="$temp_root/data" + local smoke_cache="$temp_root/cache" + mkdir -p \ + "$foreign_cwd" \ + "$smoke_home" \ + "$smoke_config" \ + "$smoke_data" \ + "$smoke_cache" + + local -a unset_args=() + local secret_name + for secret_name in \ + GITHUB_TOKEN \ + GH_TOKEN \ + OPENHUMAN_CEF_NO_SANDBOX \ + TAURI_SIGNING_PRIVATE_KEY \ + TAURI_SIGNING_PRIVATE_KEY_PASSWORD \ + SENTRY_AUTH_TOKEN; do + unset_args+=(-u "$secret_name") + done + while IFS= read -r secret_name; do + [ -n "$secret_name" ] && unset_args+=(-u "$secret_name") + done < <(compgen -v OPENAI_ || true) + + local status + if ( + cd "$foreign_cwd" + timeout --signal=TERM --kill-after=5s 15s \ + xvfb-run -a --server-args="-screen 0 1280x960x24" \ + env "${unset_args[@]}" \ + HOME="$smoke_home" \ + XDG_CONFIG_HOME="$smoke_config" \ + XDG_DATA_HOME="$smoke_data" \ + XDG_CACHE_HOME="$smoke_cache" \ + OPENHUMAN_CEF_PREWARM=0 \ + OPENHUMAN_DISABLE_GPU=1 \ + "$appdir/AppRun" + ) >"$log_file" 2>&1; then + status=0 + else + status=$? + fi + + local forbidden=0 + if grep -Eiq \ + 'anylinux\.so.*cannot be preloaded|cannot be preloaded.*anylinux\.so' \ + "$log_file"; then + forbidden=1 + fi + if grep -Eiq \ + 'libxdo\.so\.3.*cannot open shared object file|cannot open shared object file.*libxdo\.so\.3' \ + "$log_file"; then + forbidden=1 + fi + if grep -Eiq \ + 'libcef\.so.*cannot open shared object file|cannot open shared object file.*libcef\.so' \ + "$log_file"; then + forbidden=1 + fi + if grep -Eiq 'error while loading shared libraries' "$log_file"; then + forbidden=1 + fi + + # The desktop process is expected to remain alive until timeout ends the + # startup window. An earlier clean exit is a failure as well as loader output. + if [ "$forbidden" -ne 0 ] || [ "$status" -ne 124 ]; then + echo "[appimage-runtime] AppImage startup smoke failed (status $status):" >&2 + sed 's/^/[appimage-runtime] /' "$log_file" >&2 + return 1 + fi + + grep -Ei 'loader|loading|cef|startup|started|ready' "$log_file" \ + | sed 's/^/[appimage-runtime] /' \ + || true + echo "[appimage-runtime] Application remained alive for the 15-second startup window" +} + +install_smoke_userns_profile() { + local appdir="$1" + local profile_file="$2" + local executable="$appdir/shared/bin/OpenHuman" + + [ -x "$executable" ] \ + || { runtime_validation_error "AppArmor target is not executable: $executable"; return 1; } + command -v sudo >/dev/null 2>&1 \ + || { runtime_validation_error "sudo is required for the AppImage smoke AppArmor profile"; return 1; } + command -v apparmor_parser >/dev/null 2>&1 \ + || { runtime_validation_error "apparmor_parser is required for the AppImage smoke AppArmor profile"; return 1; } + + local profile_name="openhuman_appimage_runtime_smoke_$$" + printf '%s\n' \ + 'abi ,' \ + 'include ' \ + '' \ + "profile $profile_name \"$executable\" flags=(unconfined) {" \ + ' userns,' \ + '}' \ + >"$profile_file" + + sudo --non-interactive apparmor_parser --replace "$profile_file" >/dev/null \ + || { runtime_validation_error "could not load the AppImage smoke AppArmor profile"; return 1; } + echo "[appimage-runtime] Loaded temporary AppArmor userns profile for: $executable" +} + +remove_smoke_userns_profile() { + local profile_file="$1" + sudo --non-interactive apparmor_parser --remove "$profile_file" >/dev/null \ + || { runtime_validation_error "could not remove the AppImage smoke AppArmor profile"; return 1; } + echo "[appimage-runtime] Removed temporary AppArmor userns profile: $profile_file" +} + +smoke_extracted_apprun_with_userns() { + local appdir="$1" + local foreign_cwd="$2" + local log_file="$3" + local profile_file="$4" + local profile_loaded=0 + local previous_hup_trap previous_int_trap previous_term_trap + previous_hup_trap="$(trap -p HUP)" + previous_int_trap="$(trap -p INT)" + previous_term_trap="$(trap -p TERM)" + + restore_smoke_signal_traps() { + trap - HUP INT TERM + [ -z "$previous_hup_trap" ] || eval "$previous_hup_trap" + [ -z "$previous_int_trap" ] || eval "$previous_int_trap" + [ -z "$previous_term_trap" ] || eval "$previous_term_trap" + } + + cleanup_smoke_userns_profile() { + if [ "$profile_loaded" -eq 1 ]; then + profile_loaded=0 + remove_smoke_userns_profile "$profile_file" \ + || echo "[appimage-runtime] ERROR: AppArmor cleanup failed after smoke interruption" >&2 + fi + } + + interrupt_smoke_with_userns() { + local exit_status="$1" + trap - HUP INT TERM + cleanup_smoke_userns_profile + exit "$exit_status" + } + trap 'interrupt_smoke_with_userns 129' HUP + trap 'interrupt_smoke_with_userns 130' INT + trap 'interrupt_smoke_with_userns 143' TERM + + if ! install_smoke_userns_profile "$appdir" "$profile_file"; then + restore_smoke_signal_traps + return 1 + fi + profile_loaded=1 + + local smoke_status + if smoke_extracted_apprun "$appdir" "$foreign_cwd" "$log_file"; then + smoke_status=0 + else + smoke_status=$? + fi + + profile_loaded=0 + local remove_status + if remove_smoke_userns_profile "$profile_file"; then + remove_status=0 + else + remove_status=$? + fi + + restore_smoke_signal_traps + # Preserve the original smoke failure even if profile cleanup also fails. + [ "$smoke_status" -eq 0 ] || return "$smoke_status" + return "$remove_status" +} + +validate_final_appimage() ( + local image="$1" + if ! image="$(realpath "$image")"; then + runtime_validation_error "could not resolve AppImage path: $1" + return 1 + fi + if [ ! -f "$image" ] || [ ! -x "$image" ]; then + runtime_validation_error "AppImage must be an executable regular file: $image" + return 1 + fi + command -v patchelf >/dev/null 2>&1 \ + || { runtime_validation_error "patchelf is required for final AppImage validation"; return 1; } + + local temp_root + temp_root="$(mktemp -d)" + trap 'rm -rf -- "$temp_root"' EXIT + local foreign_cwd="$temp_root/foreign-cwd" + local extraction_log="$temp_root/extraction.log" + local smoke_log="$temp_root/smoke.log" + mkdir -p "$foreign_cwd" + + if ! ( + cd "$foreign_cwd" + "$image" --appimage-extract + ) >"$extraction_log" 2>&1; then + echo "[appimage-runtime] AppImage extraction failed:" >&2 + sed 's/^/[appimage-runtime] /' "$extraction_log" >&2 + return 1 + fi + + local appdir="$foreign_cwd/squashfs-root" + if [ ! -d "$appdir" ]; then + runtime_validation_error "extraction did not create $appdir" + return 1 + fi + validate_extracted_appdir "$appdir" || return 1 + + if [ "${APPIMAGE_RUNTIME_SMOKE:-0}" = "1" ]; then + if [ "${APPIMAGE_RUNTIME_APPARMOR_USERNS:-0}" = "1" ]; then + smoke_extracted_apprun_with_userns \ + "$appdir" \ + "$foreign_cwd" \ + "$smoke_log" \ + "$temp_root/openhuman-appimage-smoke.profile" \ + || return 1 + else + smoke_extracted_apprun "$appdir" "$foreign_cwd" "$smoke_log" || return 1 + fi + else + echo "[appimage-runtime] Static validation complete; executable smoke disabled for this architecture" + fi +) + +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + [ "$#" -eq 1 ] || { + echo "Usage: $0 " >&2 + exit 2 + } + APPIMAGE_EXPECTED_NEEDED="libxdo.so.3 libcef.so" \ + validate_final_appimage "$1" +fi