Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ jobs:
USE_SYSTEM_SECURE_STORAGE_DEPS: "1"
run: flutter build linux --release --verbose

- name: Prepare Linux release bundle
run: python3 scripts/linux/prepare_release_bundle.py build/linux/x64/release/bundle

- name: Package
run: |
tar -czf "stack_wallet-linux-x86_64-${{ steps.ver.outputs.version }}.tar.gz" \
Expand Down Expand Up @@ -481,6 +484,9 @@ jobs:
USE_SYSTEM_SECURE_STORAGE_DEPS: "1"
run: flutter build linux --release --verbose

- name: Prepare Linux release bundle
run: python3 scripts/linux/prepare_release_bundle.py build/linux/x64/release/bundle

- name: Package
run: |
tar -czf "campfire-linux-x86_64-${{ steps.ver.outputs.version }}.tar.gz" \
Expand Down Expand Up @@ -545,6 +551,9 @@ jobs:
USE_SYSTEM_SECURE_STORAGE_DEPS: "1"
run: flutter build linux --release --verbose

- name: Prepare Linux release bundle
run: python3 scripts/linux/prepare_release_bundle.py build/linux/x64/release/bundle

- name: Package
run: |
tar -czf "stack_duo-linux-x86_64-${{ steps.ver.outputs.version }}.tar.gz" \
Expand Down
15 changes: 15 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
name: Test
on: [pull_request]
jobs:
linux-release-bundle:
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- uses: actions/checkout@v6

- name: Install ELF test tools
run: |
sudo apt-get update -q
sudo apt-get install -y --no-install-recommends binutils build-essential patchelf python3

- name: Test Linux release bundle preparation
run: python3 -m unittest discover -s scripts/linux/tests -p 'test_*.py' -v

test:
runs-on: ubuntu-24.04
permissions:
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"]

RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl file git gnupg python3 sudo unzip xz-utils \
automake build-essential cmake debhelper libtool meson ninja-build pkg-config rsync \
automake build-essential cmake debhelper libtool meson ninja-build patchelf pkg-config rsync \
clang libclang-dev llvm \
libgcrypt20-dev libgirepository1.0-dev libgit2-dev libglib2.0-dev libgtk-3-dev \
libjsoncpp-dev liblzma-dev libncurses5-dev libncursesw5-dev \
Expand Down
23 changes: 23 additions & 0 deletions docs/building.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,29 @@ flutter pub get
flutter run linux
```

#### Linux release bundle preparation

After `flutter build linux --release`, run this from the repository root before
archiving the bundle or using it to build an AppImage or Flatpak:

```sh
sudo apt-get install binutils patchelf python3
python3 scripts/linux/prepare_release_bundle.py build/linux/x64/release/bundle
```

The helper clears the executable-stack flag on the bundled Monero, Wownero and
Salvium wallet libraries, which modern Linux loaders can otherwise reject. It
then checks every ELF in the bundle and fails if any still requests an executable
stack. Other libraries are not patched automatically. Ubuntu 24.04's `patchelf`
supports the required `--clear-execstack` option. CI runs this before producing
the Linux tarball, so both downstream packagers inherit the prepared bundle.

Standalone regression tests (also require a C compiler):

```sh
python3 -m unittest discover -s scripts/linux/tests -p 'test_*.py' -v
```

## Mac host

### Dependencies
Expand Down
76 changes: 76 additions & 0 deletions scripts/linux/prepare_release_bundle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Prepare a finished Flutter Linux bundle before tar/AppImage/Flatpak packaging.

Requires patchelf with --clear-execstack support and readelf (binutils).
Only the known Monero-family wallet libraries are patched; any other ELF that
requests an executable stack fails validation rather than being silently changed.
"""

import argparse
from pathlib import Path
import shutil
import subprocess
import sys


WALLET_LIBRARIES = {
"monero_libwallet2_api_c.so",
"wownero_libwallet2_api_c.so",
"salvium_libwallet2_api_c.so",
}


def prepare_bundle(bundle):
if not bundle.is_dir() or not (bundle / "lib").is_dir():
raise ValueError(f"Not a Flutter Linux bundle (missing lib directory): {bundle}")

for tool in ("patchelf", "readelf"):
if shutil.which(tool) is None:
raise ValueError(f"Required tool not found: {tool}")

files = sorted(path for path in bundle.rglob("*") if path.is_file())
for path in files:
if path.parent == bundle / "lib" and path.name in WALLET_LIBRARIES:
# Never patch a link into the pub cache or another source tree.
if path.is_symlink():
raise ValueError(f"Wallet library must be a bundled copy, not a symlink: {path}")
print(f"Clearing executable-stack flag: {path}", flush=True)
subprocess.run(["patchelf", "--clear-execstack", str(path)], check=True)

# Check the executable and all bundled libraries, including native assets.
# A library without PT_GNU_STACK has no explicit executable-stack request;
# do not require that header on unrelated artifacts generated by Dart.
executable_stacks = []
for path in files:
with path.open("rb") as source:
if source.read(4) != b"\x7fELF":
continue
result = subprocess.run(
["readelf", "--wide", "--program-headers", str(path)],
check=True,
capture_output=True,
text=True,
)
for line in result.stdout.splitlines():
fields = line.split()
# readelf can print flags as either RWE or separate R / E fields.
if fields and fields[0] == "GNU_STACK" and "E" in "".join(fields[6:-1]):
executable_stacks.append(str(path))

if executable_stacks:
raise ValueError("Executable stack still requested by:\n" + "\n".join(executable_stacks))
print(f"Linux bundle executable-stack validation passed: {bundle}")


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("bundle", type=Path, help="Finished Flutter Linux bundle directory")
args = parser.parse_args()
try:
prepare_bundle(args.bundle.resolve())
except (OSError, ValueError, subprocess.CalledProcessError) as error:
parser.exit(1, f"Linux bundle preparation failed: {error}\n")


if __name__ == "__main__":
main()
113 changes: 113 additions & 0 deletions scripts/linux/tests/test_prepare_release_bundle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""Core ELF regression tests; no Flutter, pub cache, network or wallet data.

Run: python3 -m unittest discover -s scripts/linux/tests -p 'test_*.py' -v
Requires a C compiler, binutils/readelf and patchelf with --clear-execstack.
"""

import ctypes
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest


SCRIPT = Path(__file__).resolve().parents[1] / "prepare_release_bundle.py"


class PrepareReleaseBundleTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.fixtures = tempfile.TemporaryDirectory(prefix="linux-elf-fixtures-")
cls.addClassCleanup(cls.fixtures.cleanup)
cls.exec_so = Path(cls.fixtures.name) / "exec.so"
cls.safe_so = Path(cls.fixtures.name) / "safe.so"
for path, stack in ((cls.exec_so, "execstack"), (cls.safe_so, "noexecstack")):
subprocess.run(
["cc", "-shared", "-fPIC", f"-Wl,-z,{stack}",
"-x", "c", "-", "-o", str(path)],
input="int fixture_value(void) { return 42; }\n",
text=True,
check=True,
capture_output=True,
)

def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix="linux bundle 'quoted' ")
self.addCleanup(self.temp.cleanup)
self.bundle = Path(self.temp.name) / "bundle"
(self.bundle / "lib").mkdir(parents=True)

def run_prepare(self, *, expected=0, env=None):
result = subprocess.run(
[sys.executable, str(SCRIPT), str(self.bundle)],
text=True,
capture_output=True,
env=env,
)
self.assertEqual(result.returncode, expected, result.stdout + result.stderr)
return result

def copy_library(self, name, *, executable=True):
path = self.bundle / "lib" / name
shutil.copy2(self.exec_so if executable else self.safe_so, path)
return path

def tool_environment(self, patchelf_body):
tools = Path(self.temp.name) / "tools"
tools.mkdir()
patcher = tools / "patchelf"
patcher.write_text("#!/bin/sh\n" + patchelf_body + "\n")
patcher.chmod(0o755)
return {**os.environ, "PATH": str(tools) + os.pathsep + os.environ["PATH"]}

def test_all_wallets_are_nonexec_and_loadable(self):
for wallet in ("monero", "wownero", "salvium"):
self.copy_library(f"{wallet}_libwallet2_api_c.so")
self.run_prepare()
for path in (self.bundle / "lib").iterdir():
with self.subTest(library=path.name):
flags = subprocess.check_output(
["patchelf", "--print-execstack", str(path)], text=True,
)
self.assertEqual(flags.strip(), "execstack: -")
self.assertEqual(ctypes.CDLL(str(path)).fixture_value(), 42)

def test_no_wallets_and_unrelated_files_are_unchanged(self):
library = self.copy_library("libunrelated.so", executable=False)
asset = self.bundle / "data.txt"
asset.write_text("not an ELF\n")
original = {path: path.read_bytes() for path in (library, asset)}
self.run_prepare()
for path, content in original.items():
self.assertEqual(path.read_bytes(), content)

def test_unexpected_executable_stack_is_rejected_not_patched(self):
for relative in ("lib/libunexpected.so", "stack_wallet",
"lib/native_assets/nested.so"):
with self.subTest(path=relative):
path = self.bundle / relative
path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(self.exec_so, path)
before = path.read_bytes()
result = self.run_prepare(expected=1)
self.assertIn("Executable stack still requested", result.stderr)
self.assertIn(str(path), result.stderr)
self.assertEqual(path.read_bytes(), before)
path.unlink()

def test_patchelf_failure_aborts(self):
self.copy_library("monero_libwallet2_api_c.so")
result = self.run_prepare(expected=1, env=self.tool_environment("exit 23"))
self.assertIn("23", result.stderr)

def test_successful_noop_patchelf_cannot_bypass_validation(self):
self.copy_library("monero_libwallet2_api_c.so")
result = self.run_prepare(expected=1, env=self.tool_environment("exit 0"))
self.assertIn("Executable stack still requested", result.stderr)


if __name__ == "__main__":
unittest.main()
Loading