Skip to content

← All posts

4 min read

How linuxdeploy's patchelf broke a static-pie binary in our AppImage

Our AppImage could not export PDF because linuxdeploy ran patchelf --set-rpath on the static-pie typst binary we bundle. A $PATCHELF wrapper now keeps it untouched.

On October 4, 2026 (UTC), the release check for Noteline 0.2.29 failed on the AppImage. The .deb exported a Markdown note to PDF. The AppImage, built in the same job from the same files, did not. Pandoc gave one line: Error producing PDF.

The cause was not in our code or in Pandoc. While packing the AppImage, linuxdeploy had run patchelf --set-rpath on the Typst binary we ship, and that binary is a static-pie executable. After the edit it crashed before it could print its own version.

What the app ships

Noteline exports Word and PDF without a network connection, so the desktop app carries two converters: Pandoc 3.5 and Typst 0.13.0, both the upstream release binaries. Pandoc turns Markdown into a Typst document, then calls typst to produce the PDF.

On Linux the app is built by Tauri 2 into three packages. The .deb and the .rpm are assembled from a file tree. The AppImage starts from the same tree, but Tauri then runs linuxdeploy over it to collect shared libraries and set an rpath on the binaries it treats as dynamically linked.

The check that caught it

0.2.29 was the first release with a script that, after the build, unpacks the .deb and the AppImage and runs the bundled Pandoc from each to export a sample note to .docx and to PDF. The .deb passed. The AppImage failed at PDF.

Run by hand, the Typst inside the AppImage died with SIGSEGV on typst --version. The copy in the .deb printed its version. The two files had different hashes. We had not changed Typst between packages, so something in the AppImage step had written to it.

What linuxdeploy does to every ELF file

linuxdeploy walks usr/bin and usr/lib in the AppDir and calls patchelf on every ELF file it considers dynamically linked, to set an rpath such as $ORIGIN/../lib. It decides "dynamically linked" from the program headers: a PT_INTERP or a PT_DYNAMIC entry is enough.

A static-pie executable has one. It contains no references to shared libraries, but it is position-independent, so at start-up it relocates itself, and it finds its relocation data through its own .dynamic section. It has no PT_INTERP, but it does have PT_DYNAMIC. file reports Typst's official x86_64 Linux build as "static-pie linked", and linuxdeploy treated it like any other dynamic binary.

To add an rpath, patchelf needs more room in the dynamic section than the file has. It moves .dynamic to the end of the file and overwrites the old location with the byte X. A normal dynamic executable is fine with that, because the loader follows the program headers to the new place. A static-pie executable has no loader. Its start-up code reads .dynamic at the address fixed at link time, finds XXXX…, and crashes.

We ran the patchelf that linuxdeploy carries in its x86_64 build (0.8) on a clean copy of Typst and got a byte-identical file to the one in our 0.2.28 AppImage. And on arm64 the problem does not occur: Typst's arm64 release is a static executable that is not PIE, it has no PT_DYNAMIC, and linuxdeploy skips it.

So this was not new in 0.2.29. Every x86_64 AppImage we had published since 0.1.0 had a Typst that could not start. PDF export from those AppImages failed, while the .deb and .rpm worked. Our earlier release checks had only built the AppImage, never run what was inside it.

Things that did not help

NO_STRIP=1 is the usual answer when linuxdeploy damages a binary. It turns off stripping. Our problem was the rpath edit, which runs either way.

Moving the converters out of usr/bin was not enough either. linuxdeploy also walks usr/lib and its subdirectories, which is where Noteline 0.2.30 keeps them on Linux.

The fix: a $PATCHELF wrapper

linuxdeploy calls whatever the PATCHELF environment variable names instead of its own patchelf. We point it at a short shell script. The script looks at the last argument, which is the target file in both calls linuxdeploy makes (--print-rpath <file> and --set-rpath <value> <file>). If the path ends in /bin/pandoc or /bin/typst and the call is anything other than --print-rpath, the script prints a line to the build log and exits 0, so linuxdeploy carries on. Every other call goes to the real patchelf. The script looks for it next to the linuxdeploy binary that invoked it first, so the hundreds of other libraries get the same patchelf as before, and falls back to $LINUXDEPLOY, $APPDIR and finally PATH.

case "$target" in
  */bin/pandoc | */bin/typst)
    if [ "${1:-}" != "--print-rpath" ]; then
      echo "linuxdeploy-patchelf: left untouched (bundled converter): $target" >&2
      exit 0
    fi
    ;;
esac
exec "$real" "$@"

In CI it is one step before the Tauri build, Linux job only:

- name: Keep linuxdeploy from patching the bundled converters (AppImage)
  if: matrix.platform == 'ubuntu-22.04'
  run: |
    chmod +x apps/desktop/scripts/linuxdeploy-patchelf.sh
    echo "PATCHELF=$GITHUB_WORKSPACE/apps/desktop/scripts/linuxdeploy-patchelf.sh" >> "$GITHUB_ENV"

The wrapper has its own failure mode. If it cannot find a real patchelf, linuxdeploy logs the error and still produces an AppImage, without the rpaths it meant to set. So the 0.2.30 release check also runs each converter with --version, compares the converters inside the AppImage byte for byte with the ones in the .deb, and looks for $ORIGIN/../lib in the app's main executable.

What we changed in the release

0.2.29 stayed a draft and was never published. 0.2.30 went out the same day with the wrapper and the extra checks, and its AppImage exports PDF.

Anyone bundling a third-party executable in an AppImage built with linuxdeploy, through Tauri or directly, can run file on it. A static-pie binary is the case to test from inside the finished AppImage.