Files
apk-reverse/skills/apk-reverse/scripts/preflight.py
T
inliver233 a1ada96cc3 Lint clean: adopt the PR's type/style gate, apply it to the current tree, and drop its path filter
PR #3 proposed adding mypy and flake8 to CI, with configurations for both. The idea is right and the
configurations are kept. Three things about the PR itself needed correcting, and all three were found
by running the gate rather than reading the diff:

  1. It was written against an older tree. Its own edits reference code that no longer exists there
     (`unknown, positions, warnings = ...`, a `struct` import at line 857 that is used 600 lines
     later), so merging it as-is would have removed a live import. The fixes were re-derived against
     the current tree instead, and the PR is superseded.
  2. It added a `paths:` filter to the workflow, watching only `*.py` plus the two lint configs. That
     would stop `check_refs.py` and `check_routing.py` -- documentation gates -- from running on a
     documentation-only commit, which is exactly the change they exist to catch. The workflow keeps
     the PR's `actions/*@v7` bump and gains no filter; the reason is written next to the trigger.
  3. `pip install --upgrade pip pytest` was dropped to just the packages, because upgrading pip in CI
     is not a goal and can fail for reasons unrelated to this repository.

What the gate found on the current tree, and what was done about it -- 70 flake8 findings and 3 mypy
errors, none of which were cosmetic-only:

  F401 (23)  unused imports, removed. These were safe to delete because the import statement itself
             cannot have a side effect that matters here.
  F841 (12)  unused bindings, handled three ways rather than one. Dead code was deleted
             (`mt_mcp_probe.print_waiting_help`'s `host`, `repack.py`'s `signer_jar`); a computed
             value that was then forgotten was **wired up** (`rasc_build.py` now names the linker
             target in its build-failure hint instead of discarding it); and a binding that is
             intentional kept its place with `# noqa: F841` and the reason -- `java2c_probe` reads
             dex header fields one by one against the format spec, and deleting three of them to
             please a linter would have made the parser stop lining up with the format it implements.
  E741 (14)  the ambiguous loop name `l`, renamed with an AST pass that resolves each binding to its
             own scope. A regex rename would have been wrong: several scopes bind their own `l`.
             That pass had a defect of its own -- it treated a comprehension target as living in the
             comprehension scope while leaving references to it in the enclosing one -- which
             produced seven `undefined name 'l'`. Found by running flake8, fixed, and the counting
             bug is documented in `check_routing.py`, whose own `tables()` had the same class of
             error in the other direction.
  style      continuation indents, blank lines between top-level definitions, and lines over length.
             The line limit is 160, not 120, and the number is a **measurement**: this repository's
             secret-scanner rule table holds raw regex source of 148 and 156 characters, which cannot
             be split without changing the pattern.
  mypy (3)   two missing annotations on module-level containers, and one optional-dependency rebind
             (`dexutil = None` after `except ImportError`) that is the repository's own idiom and
             now says so with a `type: ignore[assignment]` and the reason. No code was reordered to
             satisfy the checker.

Verified on a venv containing only `mypy` and `flake8` -- the poorest environment the configuration
must work in, and the one CI uses: **flake8 reports 0, mypy reports "Success: no issues found in 55
source files"**. Behaviour is unchanged: 625 passed / 163 skipped / 25 xfailed, the same five gates
green (check_repo 0 problem, check_refs 0 dangling, check_routing coherent, check_budget 0 failure,
check_commands 0 drift), and every key script still prints help.

  .flake8        new, with the line-length and complexity numbers and their reasons
  mypy.ini       new; optional imports declared, nothing excluded from the analysis
  ci.yml         `actions/*@v7`; new `lint` job on one interpreter; no path filter
  30 files       the findings above
2026-09-22 19:56:34 +08:00

252 lines
12 KiB
Python

#!/usr/bin/env python3
"""Preflight: prove the environment is sane BEFORE you blame your patch.
Run this at the start of every experiment block, and again whenever something
fails in a way you did not expect. A large share of "my patch broke the app"
turns out to be device state, a dead device server, a leftover proxy setting, or
a clock skew -- all of which look exactly like a broken artifact.
It is deliberately read-only: it changes nothing except optional cleanup of a
device-wide proxy setting and port forwards it created itself.
Usage:
python preflight.py
python preflight.py --serial <serial> --pkg com.example.app
python preflight.py --pkg com.example.app --expect-root
python preflight.py --cleanup # remove proxy + frida port forwards it finds
python preflight.py --json # machine-readable summary
Exit code is 0 when no BLOCKER was found, 1 otherwise. WARN never fails the run.
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
BLOCKER, WARN, OK, INFO = "BLOCKER", "WARN", "OK", "INFO"
class Report:
def __init__(self) -> None:
self.rows: list[tuple[str, str, str, str]] = []
def add(self, level: str, area: str, detail: str, fix: str = "") -> None:
self.rows.append((level, area, detail, fix))
def worst(self) -> str:
for lv in (BLOCKER, WARN, OK, INFO):
if any(r[0] == lv for r in self.rows):
return lv
return OK
def run(cmd: list[str], timeout: int = 60) -> tuple[int, str]:
try:
p = subprocess.run(cmd, capture_output=True, text=True, errors="replace", timeout=timeout)
return p.returncode, (p.stdout or "") + (p.stderr or "")
except FileNotFoundError:
return 127, "not found: %s" % cmd[0]
except subprocess.TimeoutExpired:
return 124, "TIMEOUT after %ss: %s" % (timeout, " ".join(cmd[:4]))
except Exception as e: # pragma: no cover - defensive
return 1, "error: %s" % e
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--adb", default=os.environ.get("ADB", "adb"), help="adb executable (default: $ADB or 'adb')")
ap.add_argument("--serial", default=None, help="device serial; required when more than one is attached")
ap.add_argument("--pkg", default=None, help="target package to check (optional)")
ap.add_argument("--expect-root", action="store_true", help="treat missing root as a BLOCKER")
ap.add_argument("--cleanup", action="store_true", help="remove leftover proxy setting and frida forwards")
ap.add_argument("--json", action="store_true", dest="as_json")
a = ap.parse_args()
r = Report()
ADB = a.adb
# ---- host tools -------------------------------------------------------
for tool, why in (
("java", "dexlib2 patcher, apksigner"),
("python", "all scripts"),
):
found = shutil.which(tool)
r.add(OK if found else WARN, "host:" + tool, found or "not on PATH",
"" if found else "install it if you need " + why)
for tool in ("apksigner", "zipalign", "aapt", "aapt2"):
found = shutil.which(tool)
r.add(OK if found else INFO, "host:" + tool, found or "not on PATH",
"" if found else "only needed for signing/manifest work; build-tools on PATH helps")
# ---- adb + device selection ------------------------------------------
if shutil.which(ADB) is None and not os.path.exists(ADB):
r.add(BLOCKER, "adb", "executable not found: %s" % ADB, "pass --adb <path> or set $ADB")
return finish(r, a.as_json)
rc, out = run([ADB, "devices", "-l"])
if rc != 0:
r.add(BLOCKER, "adb", "adb devices failed: %s" % out.strip()[:160], "start the adb server; check USB/emulator")
return finish(r, a.as_json)
devices = []
for line in out.splitlines()[1:]:
line = line.strip()
if not line:
continue
parts = line.split()
if len(parts) >= 2:
devices.append((parts[0], parts[1], line))
online = [d for d in devices if d[1] == "device"]
if not devices:
r.add(BLOCKER, "device", "no device attached", "boot the emulator/connect the device, then re-run")
return finish(r, a.as_json)
if len(online) > 1 and not a.serial:
r.add(BLOCKER, "device", "%d devices online but no --serial given" % len(online),
"pass --serial explicitly, or adb will pick one at random and you will debug the wrong target")
if not online:
bad = ", ".join("%s(%s)" % (d[0], d[1]) for d in devices)
r.add(BLOCKER, "device", "attached but not usable: %s" % bad,
"unauthorized -> accept the USB debugging prompt; offline -> reconnect/reboot the emulator")
return finish(r, a.as_json)
serial = a.serial or online[0][0]
r.add(OK, "device", "using %s (of %d online)" % (serial, len(online)), "")
def sh(cmd: str, timeout: int = 60) -> str:
return run([ADB, "-s", serial, "shell", cmd], timeout=timeout)[1]
def su(cmd: str, timeout: int = 90) -> str:
return run([ADB, "-s", serial, "shell", 'su -c "%s"' % cmd], timeout=timeout)[1]
# ---- device facts -----------------------------------------------------
abi = sh("getprop ro.product.cpu.abi").strip()
abilist = sh("getprop ro.product.cpu.abilist").strip()
rel = sh("getprop ro.build.version.release").strip()
sdk = sh("getprop ro.build.version.sdk").strip()
model = sh("getprop ro.product.model").strip()
sec = sh("getenforce").strip()
r.add(OK, "device:os", "Android %s (sdk %s) %s" % (rel, sdk, model), "")
r.add(OK, "device:abi", "%s abilist=[%s]" % (abi, abilist), "")
r.add(OK if sec.lower() in ("permissive", "") else INFO, "device:selinux", sec or "?", "")
# translation layer / emulator hints -- these change how native code behaves
trans = []
for probe in ("ro.dalvik.vm.native.bridge", "ro.enable.native.bridge.exec", "ro.boot.native_bridge"):
v = sh("getprop " + probe).strip()
if v and v != "0":
trans.append("%s=%s" % (probe, v))
r.add(WARN if trans else OK, "device:translation",
"; ".join(trans) if trans else "no ARM translation declared",
"native arm libraries run through a translator here: timing differs and some native checks misbehave" if trans else "")
if re.search(r"^1$", sh("getprop ro.kernel.qemu").strip()):
r.add(INFO, "device:type", "emulator (ro.kernel.qemu=1)",
"final verification belongs on the real ABI the user will run")
# ---- root -------------------------------------------------------------
idout = su("id")
has_root = "uid=0" in idout
lvl = OK if has_root else (BLOCKER if a.expect_root else WARN)
r.add(lvl, "root", "available" if has_root else "NOT available (%s)" % idout.strip()[:60],
"" if has_root else "anything beyond static analysis needs root: data dirs, Frida, file locks")
# ---- clock (silently breaks TLS work) --------------------------------
dev_epoch = sh("date +%s").strip()
if dev_epoch.isdigit():
drift = abs(int(dev_epoch) - int(__import__("time").time()))
r.add(OK if drift < 120 else WARN, "clock",
"device/host drift %ds" % drift,
"" if drift < 120 else "an expired/wrong certificate can be nothing but this clock; fix before TLS triage")
# ---- leftover state that fakes a failure -----------------------------
proxy = sh("settings get global http_proxy").strip()
if proxy and proxy not in ("null", ":0"):
r.add(WARN, "state:proxy", "device-wide http_proxy=%s" % proxy,
"a leftover proxy makes every request fail; clear with: settings put global http_proxy :0")
if a.cleanup:
sh("settings put global http_proxy :0")
r.add(INFO, "state:proxy", "cleared (--cleanup)", "")
else:
r.add(OK, "state:proxy", "not set", "")
rc_f, fwd = run([ADB, "-s", serial, "forward", "--list"])
if fwd.strip():
r.add(INFO, "state:forwards", fwd.strip().replace("\n", " | ")[:200],
"stale forwards point frida at the wrong place; 'adb forward --remove-all' resets them")
if a.cleanup:
run([ADB, "-s", serial, "forward", "--remove-all"])
r.add(INFO, "state:forwards", "removed (--cleanup)", "")
# ---- frida server reachability ---------------------------------------
srv = su("pgrep -f frida-server || pgrep -f kwork")
if srv.strip().isdigit() or srv.strip().split("\n")[0].strip().isdigit():
r.add(OK, "frida:server", "device server process alive (pid %s)" % srv.strip().split("\n")[0].strip(), "")
else:
r.add(INFO, "frida:server", "no device server process found",
"only needed for dynamic work; see references/dynamic-frida.md (name it out of obvious paths)")
# ---- target package ---------------------------------------------------
if a.pkg:
pm = sh("pm list packages | grep -F %s" % a.pkg)
installed = ("package:" + a.pkg) in pm or a.pkg in pm
r.add(OK if installed else INFO, "target", "installed" if installed else "not installed", "")
if installed:
path = sh("pm path %s" % a.pkg).strip()
uid = su("dumpsys package %s | grep -m1 userId=" % a.pkg).strip()
r.add(INFO, "target:apk", path[:160] or "?", "")
r.add(INFO, "target:uid", uid or "?", "re-check after every reinstall: uid increments")
# the native library dir the package manager will use for this device
nat = sh("dumpsys package %s | grep -m3 primaryCpuAbi" % a.pkg).strip()
if nat:
r.add(INFO, "target:abi", nat.replace("\n", " | ")[:160],
"this is the ABI the package manager chose for THIS device")
maps = su("cat /proc/$(pidof %s | awk '{print $1}')/maps 2>/dev/null | head -1" % a.pkg)
if maps.strip():
r.add(INFO, "target:running", "process is running",
"prefer a clean cold start before measuring anything")
else:
r.add(OK, "target:running", "not running", "")
# ---- free space -------------------------------------------------------
# Report by device node, not by the mount-point column: a block device can be
# mounted at more than one path (an ARM translation shim does exactly this on
# some emulators), and `df` will then label /data with an unrelated path.
space = ""
for line in sh("df -h /data 2>/dev/null").splitlines()[1:]:
cols = line.split()
if len(cols) >= 5:
space = "%s: %s available of %s (%s used)" % (cols[0], cols[3], cols[1], cols[4])
break
if space:
r.add(INFO, "device:space", space, "")
return finish(r, a.as_json)
def finish(r: Report, as_json: bool) -> int:
if as_json:
print(json.dumps({"worst": r.worst(),
"rows": [dict(level=lv, area=ar, detail=d, fix=f) for lv, ar, d, f in r.rows]},
indent=2, ensure_ascii=False))
return 1 if r.worst() == BLOCKER else 0
width = max(len(x[1]) for x in r.rows) if r.rows else 10
for level, area, detail, fix in r.rows:
print("%-8s %-*s %s" % ("[" + level + "]", width, area, detail))
if fix:
print(" " * 9 + "%-*s -> %s" % (width, "", fix))
print("\nworst: %s" % r.worst())
if r.worst() == BLOCKER:
print("Fix every BLOCKER before running another experiment. Do not attribute a failure to your patch yet.")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())