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>
97 lines
3.7 KiB
Python
Executable File
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())
|