Repository: beeware/briefcase
Pull request #667 (2022-02-08 03:05:40)

Add ELF patcher for linuxtools
This PR addresses an awkward to trace issue on Linux systems running briefcase that also have AppImageLauncher installed, first reported via the old Gitter chat room, then again in Discord.  This issue was also observed in https://github.com/beeware/briefcase/issues/416#issuecomment-640744410

On Linux systems with AppImageLauncher, AppImages that are run are hooked to start AppImageLauncher via the `binfmt-misc` kernel module. When briefcase creates a Docker container to build AppImages, that kernel module's capabilities are passed through to the container. As the container image doesn't have AppImageLauncher (nor should it), we need to ensure that linuxtools launches as a regular ELF binary. 

By patching the ELF header to remove the flags from the header that would trip `binfmt_misc` to pass execution along to AppImageLauncher, the system launches `linuxtools` as a regular ELF binary. This works, and has been tested on a system that doesn't have AppImageLauncher installed. 

Signed-off-by: Cody Wilson <cody@codywilson.co>


Patch:
diff --git a/changes/667.bugfix.rst b/changes/667.bugfix.rst
new file mode 100644
index 00000000..2793610e
--- /dev/null
+++ b/changes/667.bugfix.rst
@@ -0,0 +1,1 @@
+Added binary patcher for linuxtools AppImage to increase compatibility.
\ No newline at end of file
diff --git a/src/briefcase/exceptions.py b/src/briefcase/exceptions.py
index 907f622f..8614cbed 100644
--- a/src/briefcase/exceptions.py
+++ b/src/briefcase/exceptions.py
@@ -139,3 +139,13 @@ class InvalidDeviceError(BriefcaseCommandError):
                 device=device,
             )
         )
+
+
+class CorruptToolError(BriefcaseCommandError):
+    def __init__(self, tool):
+        self.tool = tool
+        super().__init__(
+            msg="{tool!r} found, but it appears to be corrupted.".format(
+                tool=tool,
+            )
+        )
diff --git a/src/briefcase/integrations/linuxdeploy.py b/src/briefcase/integrations/linuxdeploy.py
index 88903053..bf98c256 100644
--- a/src/briefcase/integrations/linuxdeploy.py
+++ b/src/briefcase/integrations/linuxdeploy.py
@@ -1,6 +1,10 @@
 from requests import exceptions as requests_exceptions
 
-from briefcase.exceptions import MissingToolError, NetworkFailure
+from briefcase.exceptions import CorruptToolError, MissingToolError, NetworkFailure
+
+ELF_PATCH_OFFSET = 0x08
+ELF_PATCH_ORIGINAL_BYTES = bytes.fromhex('414902')
+ELF_PATCH_PATCHED_BYTES = bytes.fromhex('000000')
 
 
 class LinuxDeploy:
@@ -62,6 +66,7 @@ class LinuxDeploy:
                 download_path=self.command.tools_path
             )
             self.command.os.chmod(linuxdeploy_appimage_path, 0o755)
+            self.patch_elf_header()
         except requests_exceptions.ConnectionError:
             raise NetworkFailure('downloading linuxdeploy AppImage')
 
@@ -77,3 +82,43 @@ class LinuxDeploy:
             print("...done.")
         else:
             raise MissingToolError('linuxdeploy')
+
+    def patch_elf_header(self):
+        """
+        Patch the ELF header of the AppImage to ensure it's always executable.
+
+        This patch is necessary on Linux hosts that use AppImageLauncher.
+        AppImages use a modified ELF binary header starting at offset 0x08
+        for additional identification. If a system has AppImageLauncher,
+        the Linux kernel module `binfmt-misc` will try to load the AppImage
+        with AppImageLauncher. As this binary does not exist in the Docker
+        container context, we patch the ELF header of linuxdeploy to remove
+        the AppImage bits, thus making all systems treat it like a regular
+        ELF binary.
+
+        Citations:
+        - https://github.com/AppImage/AppImageKit/issues/1027#issuecomment-1028232809
+        - https://github.com/AppImage/AppImageKit/issues/828
+        """
+
+        if self.exists():
+            with open(self.appimage_path, 'r+b') as appimage:
+                appimage.seek(ELF_PATCH_OFFSET)
+                # Check if the header at the offset is the original value
+                # If so, patch it.
+                if appimage.read(len(ELF_PATCH_ORIGINAL_BYTES)) == ELF_PATCH_ORIGINAL_BYTES:
+                    appimage.seek(ELF_PATCH_OFFSET)
+                    appimage.write(ELF_PATCH_PATCHED_BYTES)
+                    appimage.flush()
+                    appimage.seek(0)
+                    print("Patched ELF header of linuxdeploy AppImage.")
+                # Else if the header is the patched value, do nothing.
+                elif appimage.read(len(ELF_PATCH_ORIGINAL_BYTES)) == ELF_PATCH_PATCHED_BYTES:
+                    print("ELF header of linuxdeploy AppImage is already patched.")
+                else:
+                    # We should only get here if the file at the AppImage patch doesn't have
+                    # The original or patched value. If this is the case, the file is likely
+                    # wrong and we should raise an exception.
+                    raise CorruptToolError("linuxdeploy")
+        else:
+            raise MissingToolError("linuxdeploy")


Test patch:
diff --git a/tests/integrations/linuxdeploy/test_LinuxDeploy__patch_elf_binary.py b/tests/integrations/linuxdeploy/test_LinuxDeploy__patch_elf_binary.py
new file mode 100644
index 00000000..537b1469
--- /dev/null
+++ b/tests/integrations/linuxdeploy/test_LinuxDeploy__patch_elf_binary.py
@@ -0,0 +1,81 @@
+from unittest.mock import MagicMock
+
+import pytest
+
+from briefcase.exceptions import CorruptToolError, MissingToolError
+from briefcase.integrations.linuxdeploy import (
+    LinuxDeploy, ELF_PATCH_OFFSET, ELF_PATCH_ORIGINAL_BYTES, ELF_PATCH_PATCHED_BYTES
+    )
+
+from tests.integrations.linuxdeploy.utils import create_mock_appimage
+
+
+@pytest.fixture
+def mock_command(tmp_path):
+    command = MagicMock()
+    command.host_arch = 'wonky'
+    command.tools_path = tmp_path / 'tools'
+    command.tools_path.mkdir()
+
+    return command
+
+
+def test_patch_linuxdeploy_elf_header_unpatched(mock_command, tmp_path):
+    "If linuxdeploy is not patched, patch it."
+    appimage_path = tmp_path / 'tools' / 'linuxdeploy-wonky.AppImage'
+
+    # Mock an unpatched linuxdeploy AppImage
+    pre_patch_header = create_mock_appimage(appimage_path=appimage_path, mock_appimage_kind='original')
+
+    # Create a linuxdeploy wrapper, then patch the elf header
+    linuxdeploy = LinuxDeploy(mock_command)
+    linuxdeploy.patch_elf_header()
+
+    # Ensure the patch was applied.
+    with open(appimage_path, 'rb') as mock_appimage:
+        mock_appimage.seek(ELF_PATCH_OFFSET)
+        patched_header = mock_appimage.read(len(ELF_PATCH_PATCHED_BYTES))
+
+    assert pre_patch_header == ELF_PATCH_ORIGINAL_BYTES
+    assert patched_header == ELF_PATCH_PATCHED_BYTES
+
+
+def test_patch_linuxdeploy_elf_header_already_patched(mock_command, tmp_path):
+    "If linuxdeploy is already patched, don't patch it."
+    appimage_path = tmp_path / 'tools' / 'linuxdeploy-wonky.AppImage'
+
+    # Mock a patched linuxdeploy AppImage
+    pre_patch_header = create_mock_appimage(appimage_path=appimage_path, mock_appimage_kind='patched')
+
+    # Create a linuxdeploy wrapper, then patch the elf header
+    linuxdeploy = LinuxDeploy(mock_command)
+    linuxdeploy.patch_elf_header()
+
+    # Ensure the patch was applied.
+    with open(appimage_path, 'rb') as mock_appimage:
+        mock_appimage.seek(ELF_PATCH_OFFSET)
+        patched_header = mock_appimage.read(len(ELF_PATCH_PATCHED_BYTES))
+
+    assert pre_patch_header == ELF_PATCH_PATCHED_BYTES
+    assert patched_header == ELF_PATCH_PATCHED_BYTES
+
+
+def test_patch_linuxdeploy_elf_header_bad_appimage(mock_command, tmp_path):
+    "If linuxdeploy does not have a valid header, raise an error."
+    appimage_path = tmp_path / 'tools' / 'linuxdeploy-wonky.AppImage'
+
+    # Mock an bad linuxdeploy AppImage
+    create_mock_appimage(appimage_path=appimage_path, mock_appimage_kind='corrupt')
+
+    # Create a linuxdeploy wrapper, then patch the elf header
+    linuxdeploy = LinuxDeploy(mock_command)
+    with pytest.raises(CorruptToolError):
+        linuxdeploy = linuxdeploy.patch_elf_header()
+
+
+def test_patch_linuxdeploy_elf_header_no_file(mock_command, tmp_path):
+    "If there is no linuxdeploy AppImage, raise an error."
+    # Create a linuxdeploy wrapper, then patch the elf header
+    linuxdeploy = LinuxDeploy(mock_command)
+    with pytest.raises(MissingToolError):
+        linuxdeploy = linuxdeploy.patch_elf_header()
diff --git a/tests/integrations/linuxdeploy/test_LinuxDeploy__upgrade.py b/tests/integrations/linuxdeploy/test_LinuxDeploy__upgrade.py
index f16d17ce..22ea31fb 100644
--- a/tests/integrations/linuxdeploy/test_LinuxDeploy__upgrade.py
+++ b/tests/integrations/linuxdeploy/test_LinuxDeploy__upgrade.py
@@ -6,6 +6,8 @@ from requests import exceptions as requests_exceptions
 from briefcase.exceptions import MissingToolError, NetworkFailure
 from briefcase.integrations.linuxdeploy import LinuxDeploy
 
+from tests.integrations.linuxdeploy.utils import create_mock_appimage
+
 
 @pytest.fixture
 def mock_command(tmp_path):
@@ -25,14 +27,17 @@ def test_upgrade_exists(mock_command, tmp_path):
     appimage_path.touch()
 
     # Mock a successful download
-    mock_command.download_url.return_value = 'new-downloaded-file'
+    def side_effect_create_mock_appimage(*args, **kwargs):
+        create_mock_appimage(appimage_path=appimage_path)
+        return 'new-downloaded-file'
+    mock_command.download_url.side_effect = side_effect_create_mock_appimage
 
     # Create a linuxdeploy wrapper, then upgrade it
     linuxdeploy = LinuxDeploy(mock_command)
     linuxdeploy.upgrade()
 
-    # The mock file will be deleted
-    assert not appimage_path.exists()
+    # The mock file should exist as the upgraded version
+    assert appimage_path.exists()
 
     # A download is invoked
     mock_command.download_url.assert_called_with(
diff --git a/tests/integrations/linuxdeploy/test_LinuxDeploy__verify.py b/tests/integrations/linuxdeploy/test_LinuxDeploy__verify.py
index 060c0a2d..39c18df8 100644
--- a/tests/integrations/linuxdeploy/test_LinuxDeploy__verify.py
+++ b/tests/integrations/linuxdeploy/test_LinuxDeploy__verify.py
@@ -6,6 +6,8 @@ from requests import exceptions as requests_exceptions
 from briefcase.exceptions import MissingToolError, NetworkFailure
 from briefcase.integrations.linuxdeploy import LinuxDeploy
 
+from tests.integrations.linuxdeploy.utils import create_mock_appimage
+
 
 @pytest.fixture
 def mock_command(tmp_path):
@@ -55,7 +57,10 @@ def test_verify_does_not_exist(mock_command, tmp_path):
     appimage_path = tmp_path / 'tools' / 'linuxdeploy-wonky.AppImage'
 
     # Mock a successful download
-    mock_command.download_url.return_value = 'new-downloaded-file'
+    def side_effect_create_mock_appimage(*args, **kwargs):
+        create_mock_appimage(appimage_path=appimage_path)
+        return 'new-downloaded-file'
+    mock_command.download_url.side_effect = side_effect_create_mock_appimage
 
     # Create a linuxdeploy wrapper by verification
     linuxdeploy = LinuxDeploy.verify(mock_command)
diff --git a/tests/integrations/linuxdeploy/utils.py b/tests/integrations/linuxdeploy/utils.py
new file mode 100644
index 00000000..8baeb828
--- /dev/null
+++ b/tests/integrations/linuxdeploy/utils.py
@@ -0,0 +1,37 @@
+from random import randrange
+from pathlib import Path
+
+from briefcase.integrations.linuxdeploy import ELF_PATCH_OFFSET, ELF_PATCH_ORIGINAL_BYTES
+
+
+def create_mock_appimage(appimage_path: Path, mock_appimage_kind: str = 'original'):
+    """
+    Create a mock AppImage for testing purposes.
+
+    :param appimage_path: Path to the appimage to create.
+    :param mock_appimage_kind: The kind of mock appimage to create.
+            'original' creates an unpatched mock appimage.
+            'patched' creates a patched mock appimage.
+            'corrupt' creates a corrupted mock appimage.
+
+    :returns: The bytes to be patched of the created AppImage.
+    """
+
+    bytes_to_be_patched = None
+
+    appimage_headers = {
+        'original': bytes.fromhex('7f454c46020101004149020000000000'),
+        'patched': bytes.fromhex('7f454c46020101000000000000000000'),
+        'corrupt': bytes.fromhex('%030x' % randrange(16**30))
+    }
+
+    appimage_path.touch()
+    with open(appimage_path, 'w+b') as mock_appimage:
+        if mock_appimage_kind in appimage_headers:
+            mock_appimage.write(appimage_headers[mock_appimage_kind])
+        else:
+            raise ValueError(f'Unknown mock_appimage_kind: {mock_appimage_kind}')
+        mock_appimage.seek(ELF_PATCH_OFFSET)
+        bytes_to_be_patched = mock_appimage.read(len(ELF_PATCH_ORIGINAL_BYTES))
+
+    return bytes_to_be_patched