Files
fdroid-repo-ci/check_zip_alignment.py
T
claudeandbit 3f14c01b00 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>
2026-09-09 06:34:36 +00:00

97 lines
3.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""Check that every uncompressed entry in a zip/APK starts at an aligned offset.
Exists to *verify* rather than assume the zipalign decision recorded in
sign-apk.sh: this repo has no `zipalign` (it is not a standalone nixpkgs
attribute -- fdroid-repo-ci#1), on the strength of Android's own docs saying
a build made through the Android Gradle Plugin already aligns its zip entries
and that `zipalign` is only needed afterwards to check that, not to do it.
`apksigner` itself does not check alignment at all -- signing and verifying a
deliberately misaligned fixture produces no warning, measured while writing
this script -- so nothing downstream catches a regression here. This does.
Standard library only (`zipfile`, `struct`), like the rest of this repo's
Python. No `zipalign` binary is invoked or required.
Alignment only matters for *uncompressed* (STORED) entries: a compressed
entry is inflated into a fresh buffer before use, so its position in the zip
file is irrelevant. Two rules, matching what `zipalign`/`apksigner` apply:
* `.so` (native libraries) must start on a 4096-byte boundary, so the
loader can mmap them directly.
* everything else stored must start on a 4-byte boundary.
`zipfile.ZipInfo` records the *central directory's* header offset and extra
field, which is not always identical to the *local* file header at that
offset -- the two can carry different extra-field lengths. Reading the local
header directly, the same way apksigcopier's realignment code does, is what
makes this check exact rather than approximate.
"""
import argparse
import struct
import sys
import zipfile
LOCAL_HEADER_SIGNATURE = b"PK\x03\x04"
def local_data_offset(fh, header_offset):
"""Where this entry's data actually starts, per its local file header."""
fh.seek(header_offset)
sig = fh.read(4)
if sig != LOCAL_HEADER_SIGNATURE:
raise ValueError(f"no local file header at offset {header_offset} "
f"(got {sig!r})")
fh.seek(header_offset + 26)
name_len, extra_len = struct.unpack("<HH", fh.read(4))
return header_offset + 30 + name_len + extra_len
def alignment_for(filename):
return 4096 if filename.endswith(".so") else 4
def find_misaligned(path):
"""Uncompressed entries that do not start on their required boundary.
Returns a list of (filename, offset, required_alignment).
"""
problems = []
with zipfile.ZipFile(path) as zf, open(path, "rb") as fh:
for info in zf.infolist():
if info.compress_type != zipfile.ZIP_STORED:
continue
align = alignment_for(info.filename)
offset = local_data_offset(fh, info.header_offset)
if offset % align != 0:
problems.append((info.filename, offset, align))
return problems
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("apk", help="path to the zip/APK to check")
args = ap.parse_args(argv)
problems = find_misaligned(args.apk)
if problems:
plural = "y" if len(problems) == 1 else "ies"
print(f"{args.apk}: {len(problems)} unaligned uncompressed entr{plural}:",
file=sys.stderr)
for name, offset, align in problems:
print(f" {name}: data starts at {offset}, not a multiple of {align}",
file=sys.stderr)
print("expected the build to already align these (see sign-apk.sh); "
"not signing an APK that would fail to install correctly",
file=sys.stderr)
return 1
print(f"{args.apk}: all uncompressed entries aligned")
return 0
if __name__ == "__main__":
sys.exit(main())