#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Regression tests for `skills/apk-reverse/scripts/so_constpatch.py`. What is being locked down ------------------------- The script rewrites one zip entry inside an APK. The first implementation rebuilt the whole archive through `zipfile.ZipInfo(filename, date_time)` and re-deflated everything, which dropped `extra`, `external_attr` and `create_system`, and could push `resources.arsc` off its STORED/4-byte-aligned layout -- an APK the installer refuses with `[-124]`, and one that may not load at all when the manifest asks for `extractNativeLibs="false"`. These tests run the CLI the way a caller does (subprocess, exit code, RESULT token, JSON) and re-read the written archive to check the contract, so they cannot pass by trusting the writer's own bookkeeping. No device, no Android toolchain, no network. The fixtures in `tests/fixtures/apk/` are committed; `tests/fixtures/make_fixture_apk.py` regenerates them and is exercised too, so a fixture that stops carrying the metadata under test fails here rather than silently weakening the suite. """ from __future__ import annotations import hashlib import importlib.util import json import os import struct import subprocess import sys import zipfile from pathlib import Path import pytest HERE = Path(__file__).resolve().parent REPO = HERE.parent SCRIPT = REPO / 'skills' / 'apk-reverse' / 'scripts' / 'so_constpatch.py' SCRIPTS_DIR = SCRIPT.parent FIXTURES = HERE / 'fixtures' / 'apk' MAKE_FIXTURES = HERE / 'fixtures' / 'make_fixture_apk.py' APKHUNAN = b'apkhuan' ANDROID = b'android' SO_ENTRY = 'lib/arm64-v8a/libfoo.so' STORE_ENTRY = 'lib/arm64-v8a/libbar.so' X64_SO_ENTRY = 'lib/x86_64/libfoo.so' SERVICES_ENTRIES = ( 'META-INF/services/com.example.Provider', 'META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory', ) UTF8_ENTRY = 'res/raw/\u6d4b\u8bd5.bin' # ------------------------------------------------------------------ helpers def run_cli(*args, cwd=None): return subprocess.run([sys.executable, str(SCRIPT)] + [str(a) for a in args], capture_output=True, text=True, encoding='utf-8', errors='replace', cwd=cwd) def token_of(proc): for line in reversed(proc.stdout.splitlines()): if line.startswith('RESULT='): return line.split('=', 1)[1].strip() return None def json_of(proc): lines = proc.stdout.strip().splitlines() assert lines and lines[-1].startswith('RESULT='), proc.stdout return json.loads('\n'.join(lines[:-1])) def load_named(name, path): """Import a module by path, so the tests do not depend on sys.path state.""" spec = importlib.util.spec_from_file_location(name, path) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod def load_module(): return load_named('so_constpatch_under_test', SCRIPT) def local_extra(path, name): """The local-header extra field, which is what alignment lives in.""" with zipfile.ZipFile(path) as z, open(path, 'rb') as fh: info = z.getinfo(name) fh.seek(info.header_offset) head = fh.read(30) nlen, elen = struct.unpack_from('') == 'false' assert mod.manifest_extract_native_libs(b'') == 'unreadable' def test_alignment_predicate_matches_a_real_abi_directory(): """`lib//x.so` has two slashes; a `[^/]+` pattern would match nothing.""" mod = load_module() for name in ('lib/arm64-v8a/libfoo.so', 'lib/x86/libfoo.so', 'lib/x86_64/a.so'): assert mod.needs_alignment(name, 0, 8) is True, name # `resources.arsc` is mandatory whatever its input offset is. assert mod.needs_alignment('resources.arsc', 0, 10) is True assert mod.needs_alignment('resources.arsc', 8, 10) is True # An uncompressed .so is mandatory too (extractNativeLibs=false maps it in place). assert mod.needs_alignment('lib/arm64-v8a/libfoo.so', 0, 10) is True # Any other STORED entry keeps the alignment it arrived with, and is not re-laid out. assert mod.needs_alignment('assets/blob.bin', 0, 8) is True assert mod.needs_alignment('assets/blob.bin', 0, 10) is False # Deflated entries have no alignment requirement. assert mod.needs_alignment('classes.dex', 8, 8) is False assert mod.needs_alignment('lib/arm64-v8a/libfoo.so', 8, 8) is False def test_fixture_generator_reproduces_the_committed_archives(tmp_path): proc = subprocess.run([sys.executable, str(MAKE_FIXTURES), '--out', str(tmp_path)], capture_output=True, text=True) assert proc.returncode == 0, proc.stderr for name in ('plain.apk', 'arsc_aligned.apk', 'noextract.apk', 'services.apk'): regenerated = tmp_path / name assert regenerated.is_file() assert list(zip_dump(regenerated)) == list(zip_dump(fixture(name))), name # Structure must match even if a different zlib produced different bytes. assert os.path.getsize(regenerated) < 8192, 'fixtures must stay tiny' # ------------------------------------------------------------------ the bare-.so path def test_bare_so_rewrite_is_byte_exact(tmp_path): """The `.so` path predates the container work and must not move a single byte. The expected output is constructed here from the input, so this is not a self-consistency check: patching `apkhuan` at its reported offset is the whole contract. The sha256 of this same output was measured identical under the pre-change script (see tools/_work/zip-safety-EVIDENCE.md). """ src = so_bytes(fixture('plain.apk')) so_path = tmp_path / 'libfoo.so' so_path.write_bytes(src) out = tmp_path / 'libfoo.patched.so' proc = run_cli(so_path, '--replace', 'apkhuan=android', '-o', out) assert proc.returncode == 0, proc.stdout + proc.stderr assert token_of(proc) == 'patched' got = out.read_bytes() expected = bytearray(src) expected[0x41:0x41 + len(APKHUNAN)] = ANDROID assert got == bytes(expected) assert len(got) == len(src) assert hashlib.sha256(got).hexdigest() == \ hashlib.sha256(bytes(expected)).hexdigest() def test_bare_so_reports_the_bytes_actually_changed(tmp_path): """`N byte(s) actually changed`: the window is 7, the changed bytes are 6.""" so_path = tmp_path / 'libfoo.so' so_path.write_bytes(so_bytes(fixture('plain.apk'))) proc = run_cli(so_path, '--replace', 'apkhuan=android', '-o', tmp_path / 'o.so') assert '6 byte(s) actually changed' in proc.stdout, proc.stdout # ------------------------------------------------------------------ APK rebuild: metadata def test_deflated_entry_rebuild_preserves_every_other_entry(tmp_path): src = fixture('plain.apk') out = tmp_path / 'plain.patched.apk' before = zip_dump(src) proc = run_cli(src, '--entry', SO_ENTRY, '--replace', 'apkhuan=android', '-o', out, '--verbose') assert proc.returncode == 0, proc.stdout + proc.stderr after = zip_dump(out) assert list(after) == list(before), 'entry order changed' assert len(after) == len(before) changed = after[SO_ENTRY] assert changed['sha256'] != before[SO_ENTRY]['sha256'], 'the patched entry did not change' assert changed['method'] == 8 for name, row in before.items(): if name == SO_ENTRY: continue got = after[name] assert got['sha256'] == row['sha256'], '%s payload changed' % name assert got['crc'] == row['crc'], '%s CRC changed' % name assert got['compress_size'] == row['compress_size'], '%s compress_size changed' % name assert got['extra'] == row['extra'], '%s central-directory extra changed' % name assert got['local_extra'] == row['local_extra'], '%s local extra changed' % name assert got['external_attr'] == row['external_attr'], '%s external_attr changed' % name assert got['internal_attr'] == row['internal_attr'] assert got['create_system'] == row['create_system'] assert got['comment'] == row['comment'] assert got['date_time'] == row['date_time'] assert got['flags'] == row['flags'] def test_store_stays_store_and_is_not_recompressed(tmp_path): """A STORED entry must come back STORED with the same layout, not re-deflated.""" src = fixture('arsc_aligned.apk') out = tmp_path / 'arsc_store.patched.apk' before = zip_dump(src) proc = run_cli(src, '--entry', STORE_ENTRY, '--replace', 'apkhuan=android', '-o', out) assert proc.returncode == 0, proc.stdout + proc.stderr after = zip_dump(out) got, want = after[STORE_ENTRY], before[STORE_ENTRY] assert want['method'] == 0 assert got['method'] == 0, 'a STORED entry was re-compressed' assert got['compress_size'] == want['compress_size'] == want['file_size'] assert got['file_size'] == want['file_size'] assert got['sha256'] != want['sha256'] # Nothing moved, so alignment needed no new padding and every extra field is # byte-identical -- the strongest form of "metadata preserved". assert got['data_offset'] == want['data_offset'] assert got['extra'] == want['extra'] assert got['local_extra'] == want['local_extra'] for name, row in before.items(): assert after[name]['data_offset'] == row['data_offset'], name assert after[name]['local_extra'] == row['local_extra'], name def test_deflated_change_keeps_arsc_and_stored_so_aligned(tmp_path): """The case the old writer broke: a size change before `resources.arsc`.""" src = fixture('arsc_aligned.apk') out = tmp_path / 'arsc_deflate.patched.apk' before = zip_dump(src) proc = run_cli(src, '--entry', SO_ENTRY, '--replace', 'apkhuan=android', '-o', out) assert proc.returncode == 0, proc.stdout + proc.stderr after = zip_dump(out) assert after[SO_ENTRY]['compress_size'] != before[SO_ENTRY]['compress_size'], \ 'this fixture must make the patched entry change size, or padding is not exercised' for name, row in before.items(): got = after[name] if row['method'] != 0: continue # Every uncompressed entry that was aligned stays aligned (zipalign semantics), # and `resources.arsc` is aligned even if it had not been. assert got['data_offset'] % 4 == 0, '%s lost 4-byte alignment' % name assert got['method'] == 0, '%s is no longer STORED' % name if name != SO_ENTRY: # The entry itself is untouched... assert got['sha256'] == row['sha256'], name assert got['crc'] == row['crc'], name assert got['extra'] == row['extra'], name assert got['external_attr'] == row['external_attr'], name # ...and its original local extra survives as a prefix of the new one: # padding is appended, never substituted. assert got['local_extra'].startswith(row['local_extra']), name # `libbar.so` is STORED and sits before `resources.arsc`, so the size change shifts # it by an unaligned amount: the writer must restore its alignment with a pad record. bar_pad = len(after[STORE_ENTRY]['local_extra']) \ - len(before[STORE_ENTRY]['local_extra']) assert bar_pad > 0, 'a shifted STORED .so was left unaligned without a pad record' pad = bar_pad - 4 assert 1 <= pad <= 3, 'the appended record claims a pad of %d bytes' % pad assert bar_pad % 4 == pad, 'the appended record does not land on the boundary' appended = after[STORE_ENTRY]['local_extra'][ len(before[STORE_ENTRY]['local_extra']):] assert struct.unpack_from('