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>
119 lines
4.5 KiB
Python
Executable File
119 lines
4.5 KiB
Python
Executable File
#!/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()
|