#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Build the four tiny APKs that pin the APK-rebuild contract of `so_constpatch.py`. WHY THIS EXISTS --------------- `so_constpatch.py` rebuilt an APK through `zipfile.ZipInfo(filename, date_time)`, which drops `extra`, `external_attr`, `create_system` and re-deflates every entry. An entry that must stay STORED or 4-byte aligned (`resources.arsc`, an uncompressed `lib/*.so`) can therefore come back compressed or unaligned, and the result is refused by the installer with `[-124]` -- or simply not loadable when the manifest asks for `extractNativeLibs="false"`. A regression test for that fix needs inputs that actually carry the metadata which was being dropped, at a size worth keeping in the repository: plain.apk DEFLATED entries, no `resources.arsc` -> an unsafe rebuild is not in play, so this is the "rebuild is allowed" control arsc_aligned.apk `resources.arsc` STORED and 4-byte aligned, plus a STORED and a DEFLATED `lib/*.so` -> the storage/alignment contract must survive noextract.apk the manifest declares `android:extractNativeLibs="false"` and both `resources.arsc` and `lib/*.so` are STORED+aligned -> a rebuild must be REFUSED unless `--unsafe-rebuild` is passed services.apk `META-INF/services/**` entries, a UTF-8 entry name and a non-empty archive comment -> untouched entries must come back byte-identical Nothing here is target data: every fixture is synthesised from the constants below. Two details exist so the fixtures are not weaker than real input: * `AndroidManifest.xml` is a real binary AXML document (string pool + start element), so the manifest check reads the same bytes a compiler-produced manifest would give it. * `resources.arsc` is a valid `RES_TABLE_TYPE` header plus filler: enough to pin the storage/alignment contract, not a renderable resource table. * the `lib/*.so` payload is a real (tiny) aarch64 shared object with `.rodata` and `.shstrtab` section headers, so `--section-aware` exercises the section map instead of falling back to "no sections". USAGE ----- python make_fixture_apk.py --out tests/fixtures/apk The generated files are committed. Regenerate only when the contract itself changes; the output is deterministic (fixed timestamps, fixed compression level, no randomness). """ from __future__ import annotations import argparse import hashlib import os import struct import zlib ALIGN = 4 STAMP_UNIX = 1577836800 # 2020-01-01T00:00:00Z FORCE_UTF8 = 0x800 # general purpose bit 11 # ------------------------------------------------------------------ small builders def _dos(dt): """(time, date) MS-DOS words, the way a zip local header stores them.""" y, mo, d, h, mi, s = dt return (h << 11) | (mi << 5) | (s // 2), ((y - 1980) << 9) | (mo << 5) | d def _deflate(data): """Raw deflate (no zlib wrapper) -- what a method-8 zip entry holds.""" c = zlib.compressobj(9, zlib.DEFLATED, -15) return c.compress(data) + c.flush() def ext_stamp(unix=STAMP_UNIX): """An extended-timestamp extra field (id 0x5455), so `extra` is never empty. Real APKs carry this on entries written by a toolchain that stamps mtimes, and it is exactly the field a rebuilt archive used to lose. """ return b'\x55\x54' + struct.pack('. The attribute shape matches what aapt2 emits: `extractNativeLibs` is a boolean (dataType 0x12) with no raw string, namespaced to the android URI. """ android_uri = 'http://schemas.android.com/apk/res/android' strings = ['android', android_uri, 'manifest', 'package', 'extractNativeLibs', package] s = {v: i for i, v in enumerate(strings)} attrs = [(0xFFFFFFFF, s['package'], 0xFFFFFFFF, 0x03, s[package])] if extract_native_libs is not None: attrs.append((s[android_uri], s['extractNativeLibs'], 0xFFFFFFFF, 0x12, 1 if extract_native_libs else 0)) pool = _string_pool(strings) def chunk(ctype, node, tail): # chunk size covers the 8-byte chunk header as well as `node` and `tail`. body = node + tail return struct.pack(' (entries, archive comment). Deterministic; no timestamps from the clock. Every STORED entry is 4-byte aligned, the way `zipalign` lays a real APK out: that is what makes "the rebuild did not break alignment" a checkable claim, and what keeps a rebuilt fixture acceptable to `zipalign -c`. """ elf = make_elf() dex = make_dex() strings_xml = b'\n\n' plain_manifest = make_axml('com.example.plain') noextract_manifest = make_axml('com.example.noextract', extract_native_libs=False) st = dict(method=0, align=True, date_time=(2021, 6, 15, 12, 34, 56)) plain = [ Entry('AndroidManifest.xml', plain_manifest, **st), Entry('classes.dex', dex, external_attr=0o100600 << 16), Entry('res/values/strings.xml', strings_xml), Entry('lib/arm64-v8a/libfoo.so', elf), Entry('META-INF/MANIFEST.MF', b'Manifest-Version: 1.0\r\n\r\n'), ] arsc_aligned = [ Entry('AndroidManifest.xml', plain_manifest, **st), Entry('classes.dex', dex), # DEFLATED and patched: its compressed size moves, so resources.arsc has to be # re-aligned on output. This is the case the old writer silently broke. Entry('lib/arm64-v8a/libfoo.so', elf), # STORED: must stay STORED, must not be re-compressed, must stay aligned. Entry('lib/arm64-v8a/libbar.so', elf, method=0, align=True, external_attr=0o100755 << 16), Entry('resources.arsc', make_arsc(), method=0, align=True), Entry('res/values/strings.xml', strings_xml), Entry('META-INF/services/com.example.Provider', b'com.example.ProviderImpl\n'), ] noextract = [ Entry('AndroidManifest.xml', noextract_manifest, **st), Entry('classes.dex', dex), Entry('lib/arm64-v8a/libfoo.so', elf, method=0, align=True), Entry('resources.arsc', make_arsc(), method=0, align=True), ] services = [ Entry('AndroidManifest.xml', plain_manifest, **st), Entry('META-INF/services/com.example.Provider', b'com.example.ProviderImpl\n'), Entry('META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory', b'kotlinx.coroutines.internal.MainDispatcherFactoryImpl\n', method=0, align=True), Entry('lib/x86_64/libfoo.so', elf, external_attr=0), # A non-ASCII name with the UTF-8 flag (bit 11) set: the raw name bytes and the # flag must both survive a rebuild, and this entry must not be touched at all. Entry('res/raw/\u6d4b\u8bd5.bin', b'fixture-payload\x00', utf8=True, create_system=0, external_attr=0, internal_attr=1, comment=b'fixture comment'), ] return { 'plain.apk': (plain, b''), 'arsc_aligned.apk': (arsc_aligned, b''), 'noextract.apk': (noextract, b''), 'services.apk': (services, b'fixture-archive-comment'), } def main(): ap = argparse.ArgumentParser(description=__doc__.split('\n')[0]) ap.add_argument('--out', default=os.path.join(os.path.dirname(os.path.abspath(__file__)), 'apk'), help='directory to write the fixtures into') ap.add_argument('--list', action='store_true', help='print names and exit') a = ap.parse_args() defs = definitions() if a.list: for name in sorted(defs): print(name) return 0 for name in sorted(defs): entries, comment = defs[name] path = os.path.join(a.out, name) blob = write_apk(path, entries, comment) print('%-18s %6d bytes %2d entries sha256=%s' % (name, len(blob), len(entries), hashlib.sha256(blob).hexdigest()[:16])) print('wrote %d fixture(s) to %s' % (len(defs), os.path.abspath(a.out))) return 0 if __name__ == '__main__': raise SystemExit(main())