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:
Executable
+96
@@ -0,0 +1,96 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user