reverse-skills-max: initial public release (restored, redacted, hardened)

This commit is contained in:
baradok609hecl1pq6qi
2026-10-20 02:17:15 +03:00
commit da24c2bd20
2227 changed files with 443931 additions and 0 deletions

View File

@@ -0,0 +1,686 @@
#!/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('<HH', head, 26)
return fh.read(nlen + elen)[nlen:]
def data_offset(path, name):
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('<HH', head, 26)
return info.header_offset + 30 + nlen + elen
def zip_dump(path):
"""Everything a rebuild must preserve, keyed by entry name."""
out = {}
with zipfile.ZipFile(path) as z, open(path, 'rb') as fh:
for info in z.infolist():
fh.seek(info.header_offset)
head = fh.read(30)
nlen, elen = struct.unpack_from('<HH', head, 26)
raw_name = fh.read(nlen)
local_extra_bytes = fh.read(elen)
out[info.filename] = {
'info': info,
'raw_name': raw_name,
'local_extra': local_extra_bytes,
'method': info.compress_type,
'extra': info.extra,
'external_attr': info.external_attr,
'internal_attr': info.internal_attr,
'create_system': info.create_system,
'comment': info.comment,
'flags': info.flag_bits,
'date_time': info.date_time,
'crc': info.CRC,
'compress_size': info.compress_size,
'file_size': info.file_size,
'data_offset': info.header_offset + 30 + nlen + elen,
'sha256': hashlib.sha256(z.read(info.filename)).hexdigest(),
}
return out
def fixture(name):
p = FIXTURES / name
assert p.is_file(), 'missing fixture %s; run tests/fixtures/make_fixture_apk.py' % p
return p
def so_bytes(apk, entry=SO_ENTRY):
with zipfile.ZipFile(apk) as z:
return z.read(entry)
# ------------------------------------------------------------------ fixtures themselves
def test_fixtures_carry_the_metadata_under_test():
"""A fixture that lost its `extra`/`external_attr` would make the suite vacuous."""
for name in ('plain.apk', 'arsc_aligned.apk', 'noextract.apk', 'services.apk'):
dump = zip_dump(fixture(name))
assert dump, name
for entry, row in dump.items():
assert row['extra'], '%s!%s has no extra field to preserve' % (name, entry)
assert any(row['external_attr'] for row in dump.values()), \
'%s has no non-zero external_attr to preserve' % name
# And one entry that must keep a zero external_attr / create_system=0 too.
zeroed = zip_dump(fixture('services.apk'))[UTF8_ENTRY]
assert zeroed['external_attr'] == 0 and zeroed['create_system'] == 0
arsc = zip_dump(fixture('arsc_aligned.apk'))['resources.arsc']
assert arsc['method'] == 0
assert arsc['data_offset'] % 4 == 0
noextract = zip_dump(fixture('noextract.apk'))
assert noextract[SO_ENTRY]['method'] == 0
assert noextract[SO_ENTRY]['data_offset'] % 4 == 0
def test_fixture_manifest_is_binary_axml_with_the_declaration():
"""The manifest is real AXML, and the script's reader agrees with repack.py's.
Two independent readers on the same bytes: if either drifts, this fails instead of
one tool quietly disagreeing with the other about `extractNativeLibs`.
"""
sys.path.insert(0, str(SCRIPTS_DIR))
try:
import repack
finally:
sys.path.pop(0)
with zipfile.ZipFile(fixture('noextract.apk')) as z:
blob = z.read('AndroidManifest.xml')
assert blob[:2] == b'\x03\x00', 'not a binary AXML document'
attrs = repack.axml_root_attrs(blob)
assert attrs['extractNativeLibs'] == 'false'
assert attrs['package'] == 'com.example.noextract'
mod = load_module()
assert mod.axml_root_attributes(blob)['extractNativeLibs'] == 'false'
assert mod.manifest_extract_native_libs(blob) == 'false'
with zipfile.ZipFile(fixture('plain.apk')) as z:
assert mod.manifest_extract_native_libs(z.read('AndroidManifest.xml')) is None
assert mod.manifest_extract_native_libs(
b'<manifest xmlns:android="x" android:extractNativeLibs="false">') == 'false'
assert mod.manifest_extract_native_libs(b'</bad>') == 'unreadable'
def test_alignment_predicate_matches_a_real_abi_directory():
"""`lib/<abi>/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('<H', appended, 2)[0] == pad, 'malformed extra record'
assert 'alignment padding' in proc.stdout
assert 'resources.arsc' in proc.stdout
assert '4-byte aligned' in proc.stdout
def test_an_unaligned_stored_entry_is_not_silently_relaid(tmp_path):
"""This tool preserves alignment; it does not re-lay-out an archive it was not asked
to fix. A STORED entry that arrived unaligned stays that way, with its extra and
offsets untouched -- silently rewriting it would be the same class of change the
STORED/alignment work exists to prevent."""
fx = load_named('make_fixture_apk_under_test', MAKE_FIXTURES)
src = tmp_path / 'handmade.apk'
entries = [
fx.Entry('classes.dex', fx.make_dex(), method=8),
fx.Entry('lib/arm64-v8a/libfoo.so', fx.make_elf(), method=8),
# STORED, deliberately NOT aligned (the generator aligns only when asked).
fx.Entry('AndroidManifest.xml', fx.make_axml('com.example.unaligned'), method=0),
]
fx.write_apk(str(src), entries)
before = zip_dump(src)
assert before['AndroidManifest.xml']['data_offset'] % 4 != 0, \
'the handmade archive is supposed to be unaligned here'
out = tmp_path / 'handmade.patched.apk'
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']
got = after['AndroidManifest.xml']
assert got['data_offset'] % 4 != 0, 'an unaligned STORED entry was silently moved'
assert got['local_extra'] == before['AndroidManifest.xml']['local_extra']
assert got['sha256'] == before['AndroidManifest.xml']['sha256']
assert got['extra'] == before['AndroidManifest.xml']['extra']
assert 'NOT 4-byte aligned in the input' in proc.stdout
def test_self_check_is_printed_and_reads_back_the_written_file(tmp_path):
src = fixture('arsc_aligned.apk')
out = tmp_path / 'selfcheck.apk'
proc = run_cli(src, '--entry', STORE_ENTRY, '--replace', 'apkhuan=android', '-o', out)
assert proc.returncode == 0, proc.stdout + proc.stderr
text = proc.stdout
assert 'rebuild self-check' in text
assert 'entry count' in text and 'identical' in text
assert 'CRC+size identical' in text
assert 'sha256 identical' in text
assert 'header_offset' in text
def test_services_and_utf8_entries_are_untouched(tmp_path):
src = fixture('services.apk')
out = tmp_path / 'services.patched.apk'
before = zip_dump(src)
proc = run_cli(src, '--entry', X64_SO_ENTRY, '--replace', 'apkhuan=android', '-o', out,
'--verbose')
assert proc.returncode == 0, proc.stdout + proc.stderr
after = zip_dump(out)
for name in SERVICES_ENTRIES + (UTF8_ENTRY, 'AndroidManifest.xml'):
assert after[name]['sha256'] == before[name]['sha256'], '%s changed' % name
assert after[name]['extra'] == before[name]['extra'], '%s extra changed' % name
assert after[name]['external_attr'] == before[name]['external_attr']
assert after[name]['create_system'] == before[name]['create_system']
assert after[name]['comment'] == before[name]['comment']
utf8 = after[UTF8_ENTRY]
assert utf8['info'].filename == UTF8_ENTRY
assert utf8['flags'] & 0x800, 'the UTF-8 name flag was dropped'
assert utf8['create_system'] == 0
assert utf8['internal_attr'] == 1
assert utf8['comment'] == b'fixture comment'
with zipfile.ZipFile(src) as a, zipfile.ZipFile(out) as b:
assert a.comment == b.comment == b'fixture-archive-comment'
# ------------------------------------------------------------------ the refusal gate
def test_extract_native_libs_false_is_refused_by_default(tmp_path):
src = fixture('noextract.apk')
out = tmp_path / 'noextract.patched.apk'
proc = run_cli(src, '--entry', SO_ENTRY, '--replace', 'apkhuan=android', '-o', out)
assert proc.returncode == 1, proc.stdout + proc.stderr
assert token_of(proc) == 'refused_unsafe_rebuild'
assert not out.exists(), 'a refused rebuild must write nothing'
assert 'REFUSED' in proc.stdout
assert 'extractNativeLibs' in proc.stdout
assert 'scripts/repack.py' in proc.stdout, 'the refusal must name the right tool'
assert '--unsafe-rebuild' in proc.stdout
def test_unsafe_rebuild_is_explicit_and_reported(tmp_path):
src = fixture('noextract.apk')
out = tmp_path / 'noextract.unsafe.apk'
proc = run_cli(src, '--entry', SO_ENTRY, '--replace', 'apkhuan=android', '-o', out,
'--unsafe-rebuild')
assert proc.returncode == 0, proc.stdout + proc.stderr
assert token_of(proc) == 'patched'
assert 'WARNING: --unsafe-rebuild accepted' in proc.stdout
after = zip_dump(out)
assert after[SO_ENTRY]['method'] == 0
assert after[SO_ENTRY]['data_offset'] % 4 == 0
assert after['resources.arsc']['method'] == 0
assert after['resources.arsc']['data_offset'] % 4 == 0
def test_a_compressed_arsc_is_refused(tmp_path):
"""An arsc that is not STORED+aligned cannot be made compliant by this writer."""
mod = load_module()
broken = tmp_path / 'compressed_arsc.apk'
with zipfile.ZipFile(fixture('arsc_aligned.apk')) as src_z:
plan = [(i.filename, src_z.read(i.filename), i.compress_type, i.extra,
i.external_attr, i.create_system, i.date_time)
for i in src_z.infolist()]
with zipfile.ZipFile(broken, 'w') as dst:
for name, blob, method, extra, eattr, csys, dt in plan:
zi = zipfile.ZipInfo(name, date_time=dt)
zi.compress_type = zipfile.ZIP_DEFLATED if name == 'resources.arsc' else method
zi.extra = extra
zi.external_attr = eattr
zi.create_system = csys
dst.writestr(zi, blob)
out = tmp_path / 'x.apk'
proc = run_cli(broken, '--entry', STORE_ENTRY, '--replace', 'apkhuan=android',
'-o', out)
assert proc.returncode == 1, proc.stdout + proc.stderr
assert token_of(proc) == 'refused_unsafe_rebuild'
assert 'resources.arsc' in proc.stdout
assert not out.exists()
# The audit reads the storage state without needing a rebuild at all.
facts = mod.audit_apk_for_rebuild(str(broken), mod.Reporter(as_json=True))
assert facts['arsc']['stored'] is False
assert facts['unsafe']
# ------------------------------------------------------------------ find / usage errors
def test_find_reports_isolated_and_non_isolated(tmp_path):
src = fixture('plain.apk')
proc = run_cli(src, '--entry', SO_ENTRY, '--find', 'apkhuan')
assert proc.returncode == 0
assert token_of(proc) == 'found'
assert 'isolated=True' in proc.stdout and 'isolated=False' in proc.stdout
assert 'isolated (safe to rewrite in place): 1 / 2' in proc.stdout
def test_find_without_hits_is_a_negative_finding():
proc = run_cli(fixture('plain.apk'), '--entry', SO_ENTRY, '--find', 'nosuchthing')
assert proc.returncode == 1
assert token_of(proc) == 'not_found'
def test_equal_length_is_mandatory():
proc = run_cli(fixture('plain.apk'), '--entry', SO_ENTRY, '--replace', 'apkhuan=and',
'-o', 'out.apk')
assert proc.returncode == 2
assert 'Equal length is mandatory' in proc.stdout
def test_non_isolated_and_multi_hits_need_an_explicit_flag():
# The decoy `libapkhuanx.so` makes a second, non-isolated hit.
proc = run_cli(fixture('plain.apk'), '--entry', SO_ENTRY, '--replace', 'apkhuan=android',
'-o', 'out.apk', '--allow-nonisolated')
assert proc.returncode == 2
assert '--force' in proc.stdout
assert token_of(proc) == 'usage_error'
def test_missing_entry_and_missing_out_are_usage_errors(tmp_path):
p = run_cli(fixture('plain.apk'), '--entry', 'lib/arm64-v8a/nope.so',
'--replace', 'apkhuan=android', '-o', tmp_path / 'x.apk')
assert p.returncode == 2
assert 'available .so entries' in p.stdout
p = run_cli(fixture('plain.apk'), '--replace', 'apkhuan=android')
assert p.returncode == 2
p = run_cli(fixture('plain.apk'), '--entry', SO_ENTRY, '--replace', 'apkhuan=android')
assert p.returncode == 2, 'patching an APK without -o must be rejected'
p = run_cli(fixture('plain.apk'), '--entry', SO_ENTRY, '--replace', 'apkhuan=android',
'-o', fixture('plain.apk'))
assert p.returncode == 2, 'overwriting the input in place must be rejected'
# ------------------------------------------------------------------ json contract
def test_json_contract_on_success(tmp_path):
out = tmp_path / 'j.apk'
proc = run_cli(fixture('arsc_aligned.apk'), '--entry', STORE_ENTRY, '--replace',
'apkhuan=android', '-o', out, '--json')
assert proc.returncode == 0, proc.stdout + proc.stderr
payload = json_of(proc)
assert payload['status'] == 'ok'
assert payload['exit_code'] == 0
assert payload['capability'] == 'native_const_patch'
assert payload['result'] == 'patched'
for key in ('evidence', 'warnings', 'next_action'):
assert key in payload
assert isinstance(payload['evidence'], list) and payload['evidence']
assert payload['verify']['entries'] == payload['verify']['untouched'] + 1
assert payload['verify']['sha256_identical'] == payload['verify']['untouched']
assert payload['audit']['arsc']['stored'] is True
assert payload['audit']['unsafe'] == []
def test_json_contract_on_refusal(tmp_path):
proc = run_cli(fixture('noextract.apk'), '--entry', SO_ENTRY, '--replace',
'apkhuan=android', '-o', tmp_path / 'r.apk', '--json')
assert proc.returncode == 1
payload = json_of(proc)
assert payload['status'] == 'refused'
assert payload['exit_code'] == 1
assert payload['result'] == 'refused_unsafe_rebuild'
assert payload['audit']['extract_native_libs'] == 'false'
assert payload['audit']['unsafe']
assert 'repack.py' in payload['next_action']
def test_json_contract_on_negative_find():
proc = run_cli(fixture('plain.apk'), '--entry', SO_ENTRY, '--find', 'nope', '--json')
assert proc.returncode == 1
payload = json_of(proc)
assert payload['status'] == 'negative'
assert payload['result'] == 'not_found'
assert payload['exit_code'] == 1
# ------------------------------------------------------------------ streaming archives
def test_data_descriptors_are_resolved_into_the_local_header(tmp_path):
"""An entry written while streaming carries flag bit 3 and no CRC in its local
header. The rebuild resolves it from the central directory, so the output is
readable by tools that never look at the central directory."""
class _Sink(object):
"""A file object without seek/tell, which is what makes zipfile stream."""
def __init__(self, fh):
self._fh = fh
def write(self, data):
return self._fh.write(data)
def flush(self):
self._fh.flush()
def close(self):
self._fh.close()
streaming = tmp_path / 'streaming.apk'
with zipfile.ZipFile(fixture('plain.apk')) as zin:
with open(streaming, 'wb') as raw:
with zipfile.ZipFile(_Sink(raw), 'w', zipfile.ZIP_DEFLATED) as zout:
for info in zin.infolist():
zi = zipfile.ZipInfo(info.filename, date_time=info.date_time)
zi.compress_type = info.compress_type
with zout.open(zi, 'w') as fh:
fh.write(zin.read(info.filename))
before = zip_dump(streaming)
assert all(row['flags'] & 0x08 for row in before.values()), \
'this fixture must carry data descriptors, or the branch is not covered'
assert all(row['local_extra'] == b'' or True for row in before.values())
out = tmp_path / 'streaming.patched.apk'
proc = run_cli(streaming, '--entry', SO_ENTRY, '--replace', 'apkhuan=android',
'-o', out)
assert proc.returncode == 0, proc.stdout + proc.stderr
after = zip_dump(out)
assert 'data descriptors' in proc.stdout
for name, row in before.items():
got = after[name]
assert not (got['flags'] & 0x08), '%s still declares a data descriptor' % name
if name != SO_ENTRY:
assert got['sha256'] == row['sha256']
assert got['crc'] == row['crc']
with zipfile.ZipFile(out) as z:
assert z.testzip() is None
# ------------------------------------------------------------------ internal invariants
def test_padding_record_is_four_byte_expressible():
"""A zip extra record is (id, size, payload) and its minimum length is 4 bytes."""
mod = load_module()
assert mod._pad_extra(0) == b''
for pad in (1, 2, 3):
rec = mod._pad_extra(pad)
assert len(rec) == 4 + pad
assert len(rec) % 4 == pad
assert struct.unpack_from('<H', rec, 2)[0] == pad
def test_eocd_and_unsupported_containers_are_reported(tmp_path):
mod = load_module()
junk = tmp_path / 'not-a-zip.apk'
junk.write_bytes(b'not a zip at all')
with pytest.raises(mod.Refused) as exc:
mod.audit_apk_for_rebuild(str(junk), mod.Reporter(as_json=True))
assert exc.value.exit_code == 3
assert exc.value.token == 'unsupported_container'
proc = run_cli(junk, '--entry', 'anything', '--replace', 'a=b', '-o', tmp_path / 'o.apk')
assert proc.returncode == 3
assert token_of(proc) == 'unsupported_container'