Build the shared F-Droid publishing tooling: sign, index, publish

Adds sign-apk.sh (align-check + apksigner), fdroid-update.sh (fdroid
update over a binary repo, signing the index with its own separate
key), publish.sh (S3-compatible upload) and the composite action
tying them together, matching weblib-ci's shape.

zipalign is not a standalone nixpkgs attribute; resolved by measurement
rather than assumption that Gradle/AGP already aligns its output (Android's
own docs, corroborated by fdroidserver never shelling out to zipalign) and
added check_zip_alignment.py as the safety net, since apksigner itself does
not check alignment at all. nixpkgs is pinned via a committed nixpkgs-pin
revision, since this repo has no flake.lock of its own for with-nixpkgs.sh
to read (fdroid-repo-ci#1, weblib-ci#9) -- a missing or malformed pin fails
loudly rather than degrading to an unpinned nix shell.

No consumer repo, bucket, hostname or channel name is hard-coded: which
repo holds the keys and runs the publish has already changed once during
this issue and may again, so every example uses a placeholder.

42 offline/local-only test assertions across five suites (run_tests.sh),
including fault injection on the alignment check, the password/argument
validation, and the with-nixpkgs.sh fail-loud paths.

Co-authored-by: bit <bit@das-labor.org>
This commit is contained in:
2026-09-09 06:34:36 +00:00
co-authored by bit
parent f1dcedd3f7
commit 3f14c01b00
15 changed files with 1292 additions and 0 deletions
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""Offline tests for check_zip_alignment.py.
Standard library only, no network, no nix, no external binary -- this is the
one piece of this repo that needs none of the pinned toolchain, so it is
tested at plain `python3` speed rather than through with-nixpkgs.sh.
Builds both a genuinely aligned fixture and a deliberately misaligned one
with nothing but `zipfile`/`struct`, and runs the checker as a *subprocess*
(not an import) against both, because that is how sign-apk.sh actually calls
it -- a test that only imports the function would miss an argv or exit-code
regression.
python3 test_check_zip_alignment.py
"""
import os
import struct
import subprocess
import sys
import tempfile
import unittest
import zipfile
HERE = os.path.dirname(os.path.abspath(__file__))
CHECKER = os.path.join(HERE, "check_zip_alignment.py")
def add_stored(zf, name, data, align=None):
"""Write a STORED entry, padded to `align` bytes if given.
Mirrors what a real zip-aligning tool does: pad the "extra field" of the
local header so the entry's *data* (not its header) starts on the
boundary. `align=None` writes the entry with no padding at all, which is
what a plain zipfile.writestr call already does by default -- the
negative-control case that must fail the check.
"""
info = zipfile.ZipInfo(name)
info.compress_type = zipfile.ZIP_STORED
if align:
header_len = 30 + len(name.encode())
data_start_if_no_extra = zf.fp.tell() + header_len
pad = (-data_start_if_no_extra) % align
if pad:
if pad < 4:
pad += align
extra_len = pad - 4
info.extra = struct.pack("<HH", 0xD935, extra_len) + b"\x00" * extra_len
zf.writestr(info, data)
class CheckZipAlignmentTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
def _run(self, path):
return subprocess.run([sys.executable, CHECKER, path],
capture_output=True, text=True)
def _build(self, name, entries):
path = os.path.join(self.tmp.name, name)
with zipfile.ZipFile(path, "w") as zf:
for entry_name, data, align in entries:
add_stored(zf, entry_name, data, align)
return path
def test_aligned_apk_passes(self):
path = self._build("aligned.apk", [
("AndroidManifest.xml", b"0123456789", 4),
("lib/arm64-v8a/libfoo.so", b"X" * 50, 4096),
])
result = self._run(path)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("all uncompressed entries aligned", result.stdout)
def test_misaligned_generic_entry_fails(self):
# No padding at all -- the fault this check exists to catch.
path = self._build("misaligned.apk", [
("x", b"pad", None),
("AndroidManifest.xml", b"0123456789", None),
])
result = self._run(path)
self.assertEqual(result.returncode, 1)
self.assertIn("AndroidManifest.xml", result.stderr)
self.assertIn("not a multiple of 4", result.stderr)
def test_misaligned_native_library_fails_at_4096(self):
# Aligned to 4 bytes (would pass a naive "mod 4" check) but not to
# the 4096 a .so specifically needs.
path = self._build("misaligned-so.apk", [
("lib/arm64-v8a/libfoo.so", b"X" * 50, 4),
])
result = self._run(path)
self.assertEqual(result.returncode, 1)
self.assertIn("libfoo.so", result.stderr)
self.assertIn("not a multiple of 4096", result.stderr)
def test_compressed_entries_are_never_flagged(self):
# Alignment only matters for STORED entries; a DEFLATEd one is
# decompressed into a fresh buffer regardless of its file offset.
path = os.path.join(self.tmp.name, "compressed.apk")
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("classes.dex", b"\x00" * 100)
result = self._run(path)
self.assertEqual(result.returncode, 0, result.stderr)
def test_fixing_alignment_flips_fail_to_pass(self):
"""The fault-injection/revert pair the task asked for, in one test."""
broken = self._build("broken.apk", [("AndroidManifest.xml", b"x" * 20, None)])
self.assertEqual(self._run(broken).returncode, 1)
fixed = self._build("fixed.apk", [("AndroidManifest.xml", b"x" * 20, 4)])
self.assertEqual(self._run(fixed).returncode, 0)
if __name__ == "__main__":
unittest.main()