Files
JMR-devandClaude Opus 4.8 55f1f59e3d refactor(scripts): fold cold-fetch pause-hook A/B into device-testing harness (#405)
Bakes the proven 2026-07-06 cold-vs-warm pause-hook flow into
scripts/device-testing/ as a first-class, reproducible `cold-fetch-ab`
scenario, upstreaming the scratchpad driver.

- fetchgate.py: FETCH_GATE pause/resume/query helpers through the guarded
  adb wrapper, with ordered-broadcast read-back parsing (paused=[...]).
- scenarios.cold_fetch_ab: pre-arm halt -> detect sign-in (sync all
  breadcrumb) -> confirm halt (prefetch skipped) -> wait for header sync ->
  measure cold opens -> resume -> measure warm opens. ALWAYS resumes on exit
  (finally), even on error -- never leaves fetch paused.
- report.render_cold_fetch_ab: gate summary, cold/warm tables, cold-vs-warm
  delta, connect=0ms reuse proof, throttle signature.
- Portability (subsumes #392): file-based uiautomator dump (not /dev/tty),
  UTF-8 adb decode + PYTHONUTF8/console I/O, openMessage-breadcrumb readiness,
  row-selection hardening (skip non-message rows).

The pause hook is debug-build-only (#393/#395), so the scenario needs a debug
APK. Automated validation: mocked unittest coverage (adb/breadcrumbs/gate) for
the helpers and the A/B scenario incl. restore-on-error, plus a --dry-run path
exercised end-to-end through perf_harness.main. A full on-device run is a
follow-up. Dev-tooling only; no app/src changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 05:07:11 -05:00

404 lines
16 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""adb.py -- a deliberately narrow, safety-guarded wrapper around ``adb``.
Every device interaction in the harness goes through :class:`Adb`. The wrapper enforces
two independent layers so a dangerous command *cannot be constructed*, even by a buggy
caller:
* a TOP-LEVEL ALLOW-LIST of adb subcommands (``devices``, ``get-state``, ``install``,
``shell``, ``logcat``, ``wait-for-device``) -- anything else raises :class:`AdbSafetyError`;
* a DENY-LIST + assertions applied to every ``shell`` command: no ``pm clear`` / ``pm
uninstall``, no ``reboot`` / ``remount`` / ``root`` / ``disable-verity`` / factory reset,
no touching the app's ``databases/`` / ``files/`` / ``shared_prefs/`` / ``datastore/``,
no output redirects, and the ONLY sanctioned destructive operation is clearing the app's
own ``cache/`` (an exact-match allow-list of ``run-as <pkg> sh -c 'rm -rf cache/*'``).
``am force-stop`` / ``am start`` and ``run-as`` are constrained to the target package
(default ``org.libremail.app``). All checks run in ``--dry-run`` mode too, so the guardrails
are unit-testable without a device.
Scope: this harness operates ONLY on LibreMail plus read-only system-log/settings/dumpsys
collection. It never writes app data other than clearing the cache.
"""
from __future__ import annotations
import re
import subprocess
import time
from dataclasses import dataclass
from typing import List, Optional, Sequence
DEFAULT_PACKAGE = "org.libremail.app"
DEFAULT_COMPONENT = "org.libremail.app/org.libremail.MainActivity"
# uiautomator's default public scratch file. The harness dumps the view hierarchy to this
# file and ``cat``s it back (a file-based dump) rather than dumping to ``/dev/tty``: the tty
# path interleaves the "dumped to" status banner with the XML and is unreliable across
# devices/hosts (issue #392). It is public external storage -- never app-private data.
UI_DUMP_DEVICE_PATH = "/sdcard/window_dump.xml"
# adb subcommands the harness is ever allowed to invoke.
_ALLOWED_SUBCOMMANDS = frozenset(
{"devices", "get-state", "install", "shell", "logcat", "wait-for-device", "start-server"}
)
# Substrings that must never appear anywhere in a shell command line.
_DENY_SUBSTRINGS = (
"pm clear",
"pm uninstall",
"pm disable",
"pm hide",
"disable-verity",
"set-verity",
"remount",
"reboot",
"bootloader",
"fastboot",
"factory",
"wipe-data",
"wipe_data",
"mkfs",
"format ",
# App-private dirs we must never read or write (cache/ is the sole exception, handled
# separately). Banning the segment names outright is defence-in-depth for this harness,
# which never has a legitimate reason to touch them.
"databases",
"shared_prefs",
"datastore",
"/files",
"files/",
)
# Verbs that mutate the filesystem. Detected as whole *words* anywhere in the shell command
# (they can be buried inside a ``sh -c '<payload>'`` token); permitted ONLY for the exact
# sanctioned cache-clear (see _is_sanctioned_cache_clear).
_DESTRUCTIVE_VERBS = ("rm", "rmdir", "mv", "dd", "truncate", "shred", "unlink", "mkfs", "chmod", "chown")
_DESTRUCTIVE_RE = re.compile(r"\b(?:" + "|".join(_DESTRUCTIVE_VERBS) + r")\b")
# The exact ``sh -c`` payloads permitted for the cache clear -- nothing else.
_CACHE_CLEAR_PAYLOADS = ("rm -rf cache/*", "rm -rf cache")
class AdbSafetyError(RuntimeError):
"""Raised when a command would violate the harness's device-safety guardrails."""
class AdbError(RuntimeError):
"""Raised when an adb command fails (non-zero exit) and the caller wanted a check."""
@dataclass
class AdbResult:
"""Outcome of one adb invocation."""
args: List[str]
returncode: int
stdout: str
stderr: str
@property
def ok(self) -> bool:
return self.returncode == 0
def _shell_command_string(args: Sequence[str]) -> str:
"""Join the tokens *after* ``shell`` into a single inspection string."""
return " ".join(args[1:]) if len(args) > 1 else ""
def _is_sanctioned_cache_clear(package: str, args: Sequence[str]) -> bool:
"""True iff ``args`` is exactly ``shell run-as <package> sh -c '<cache-clear>'``."""
return (
len(args) == 6
and args[0] == "shell"
and args[1] == "run-as"
and args[2] == package
and args[3] == "sh"
and args[4] == "-c"
and args[5] in _CACHE_CLEAR_PAYLOADS
)
def assert_safe(package: str, args: Sequence[str]) -> None:
"""Raise :class:`AdbSafetyError` if ``args`` (an adb argv, sans ``adb``/``-s``) is unsafe.
Pure and side-effect-free so the guardrails can be unit-tested exhaustively.
"""
if not args:
raise AdbSafetyError("empty adb command")
subcommand = args[0]
if subcommand not in _ALLOWED_SUBCOMMANDS:
raise AdbSafetyError(f"adb subcommand {subcommand!r} is not on the allow-list")
# 'install' may only take known-safe flags plus a path; it is never 'uninstall'.
if subcommand == "install":
for tok in args[1:]:
if tok.startswith("-") and tok not in ("-r", "-t", "-g", "-d", "-i"):
raise AdbSafetyError(f"install flag {tok!r} is not allowed")
return
if subcommand != "shell":
# devices / get-state / logcat / wait-for-device / start-server take no risky args.
return
sanctioned_cache_clear = _is_sanctioned_cache_clear(package, args)
sh = _shell_command_string(args)
lowered = sh.lower()
for bad in _DENY_SUBSTRINGS:
if bad in lowered:
raise AdbSafetyError(f"shell command contains forbidden text {bad!r}: {sh!r}")
if ">" in sh:
raise AdbSafetyError(f"shell output redirection is not allowed: {sh!r}")
tokens = list(args[1:])
if _DESTRUCTIVE_RE.search(sh) and not sanctioned_cache_clear:
raise AdbSafetyError(
"the only permitted destructive shell command is clearing the app cache; "
f"refusing: {sh!r}"
)
# run-as must target our package and (unless the sanctioned cache clear) be a read.
if "run-as" in tokens:
idx = tokens.index("run-as")
target = tokens[idx + 1] if idx + 1 < len(tokens) else None
if target != package:
raise AdbSafetyError(f"run-as may only target {package!r}, got {target!r}: {sh!r}")
# am force-stop / am start may only reference our package/component.
if "am" in tokens:
if "force-stop" in tokens:
i = tokens.index("force-stop")
target = tokens[i + 1] if i + 1 < len(tokens) else None
if target != package:
raise AdbSafetyError(f"am force-stop must target {package!r}: {sh!r}")
if "-n" in tokens: # component: <package>/<activity>
i = tokens.index("-n")
component = tokens[i + 1] if i + 1 < len(tokens) else ""
comp_pkg = component.split("/", 1)[0]
if comp_pkg != package:
raise AdbSafetyError(f"am start component must be in {package!r}: {sh!r}")
for pkg_flag in ("-p", "--package"):
if pkg_flag in tokens:
i = tokens.index(pkg_flag)
target = tokens[i + 1] if i + 1 < len(tokens) else None
if target != package:
raise AdbSafetyError(f"am {pkg_flag} must be {package!r}: {sh!r}")
class Adb:
"""Guarded adb wrapper bound to a single device serial and target package."""
def __init__(
self,
serial: Optional[str] = None,
package: str = DEFAULT_PACKAGE,
adb_path: str = "adb",
dry_run: bool = False,
default_timeout: float = 60.0,
logger=None,
) -> None:
self.serial = serial
self.package = package
self.adb_path = adb_path
self.dry_run = dry_run
self.default_timeout = default_timeout
self._log = logger or (lambda msg: None)
# -- core ---------------------------------------------------------------- #
def _argv(self, args: Sequence[str]) -> List[str]:
prefix = [self.adb_path]
if self.serial:
prefix += ["-s", self.serial]
return prefix + list(args)
def run(
self,
args: Sequence[str],
check: bool = False,
timeout: Optional[float] = None,
) -> AdbResult:
"""Validate and execute an adb command (``args`` excludes ``adb`` and ``-s <serial>``)."""
assert_safe(self.package, args)
argv = self._argv(args)
printable = " ".join(argv)
if self.dry_run:
self._log(f"[dry-run] {printable}")
return AdbResult(list(args), 0, "", "")
self._log(f"$ {printable}")
completed = subprocess.run(
argv,
capture_output=True,
encoding="utf-8",
errors="replace",
timeout=timeout if timeout is not None else self.default_timeout,
)
result = AdbResult(list(args), completed.returncode, completed.stdout, completed.stderr)
if check and not result.ok:
raise AdbError(f"adb failed ({result.returncode}): {printable}\n{result.stderr}")
return result
# -- device discovery ---------------------------------------------------- #
def devices(self) -> List[str]:
"""Return the serials of attached, ready devices."""
out = self.run(["devices"]).stdout
return parse_devices(out)
def get_state(self) -> str:
return self.run(["get-state"]).stdout.strip()
# -- lifecycle (LibreMail only) ------------------------------------------ #
def force_stop(self) -> AdbResult:
return self.run(["shell", "am", "force-stop", self.package])
def start_activity(
self, component: str = DEFAULT_COMPONENT, wait: bool = True
) -> AdbResult:
args = ["shell", "am", "start"]
if wait:
args.append("-W")
args += ["-n", component]
return self.run(args)
def broadcast(
self, action: str, component: str, extras: Optional[dict] = None
) -> AdbResult:
"""Send an ``am broadcast`` to ``component`` (constrained to our package by the guard).
``extras`` are sent as string extras (``--es <key> <value>``). Used for the debug
FETCH_GATE pause/resume/query hook (see :mod:`fetchgate`): ``am broadcast`` delivers
ordered, so the receiver echoes its result data back on stdout for a race-free
read-back. The ``-n`` component's package must equal our target package or the
:func:`assert_safe` guard refuses the command.
"""
args = ["shell", "am", "broadcast", "-a", action, "-n", component]
for key, value in (extras or {}).items():
args += ["--es", str(key), str(value)]
return self.run(args)
def clear_cache(self) -> AdbResult:
"""Clear ONLY the app's ``cache/`` via run-as. The sole sanctioned mutation."""
return self.run(["shell", "run-as", self.package, "sh", "-c", "rm -rf cache/*"])
def list_cache(self) -> AdbResult:
"""Read-only listing of the app cache dir (for verification/logging)."""
return self.run(["shell", "run-as", self.package, "ls", "-la", "cache"])
# -- input / UI ---------------------------------------------------------- #
def input_tap(self, x: int, y: int) -> AdbResult:
return self.run(["shell", "input", "tap", str(x), str(y)])
def input_swipe(self, x1: int, y1: int, x2: int, y2: int, ms: int = 300) -> AdbResult:
return self.run(
["shell", "input", "swipe", str(x1), str(y1), str(x2), str(y2), str(ms)]
)
def input_keyevent(self, keycode: str) -> AdbResult:
return self.run(["shell", "input", "keyevent", str(keycode)])
def uiautomator_dump(self) -> str:
"""Return the current window's uiautomator XML via a portable file-based dump.
Dumps to an on-device scratch file (``uiautomator dump <path>``) and ``cat``s it back
rather than dumping to ``/dev/tty``: the tty path interleaves the "dumped to" status
banner with the XML and is unreliable across devices/hosts (issue #392). The path is
uiautomator's own public default (:data:`UI_DUMP_DEVICE_PATH`), never app storage.
"""
self.run(["shell", "uiautomator", "dump", UI_DUMP_DEVICE_PATH], timeout=90)
out = self.run(["shell", "cat", UI_DUMP_DEVICE_PATH], timeout=30).stdout
return _extract_xml(out)
# -- screen / keyguard --------------------------------------------------- #
def stay_on(self, on: bool = True) -> AdbResult:
return self.run(["shell", "svc", "power", "stayon", "true" if on else "false"])
def wake(self) -> AdbResult:
return self.input_keyevent("KEYCODE_WAKEUP")
def dumpsys(self, service: str, *extra: str) -> AdbResult:
return self.run(["shell", "dumpsys", service, *extra])
def settings_get(self, namespace: str, key: str) -> str:
return self.run(["shell", "settings", "get", namespace, key]).stdout.strip()
# -- logcat -------------------------------------------------------------- #
def clear_logcat(self) -> AdbResult:
return self.run(["shell", "logcat", "-c"])
def start_logcat(self, out_file) -> Optional[subprocess.Popen]:
"""Start streaming the full logcat to an already-open file handle.
Returns the :class:`subprocess.Popen` (or ``None`` in dry-run). Stop it with
:meth:`stop_logcat`.
"""
args = ["logcat", "-b", "all", "-v", "threadtime"]
assert_safe(self.package, args)
argv = self._argv(args)
if self.dry_run:
self._log(f"[dry-run] {' '.join(argv)} > <session-raw.log>")
return None
self._log(f"$ {' '.join(argv)} > <session-raw.log>")
return subprocess.Popen(argv, stdout=out_file, stderr=subprocess.DEVNULL)
def settle(self, seconds: float) -> None:
"""Sleep to let device state settle -- a no-op in dry-run so previews are instant."""
if not self.dry_run and seconds > 0:
time.sleep(seconds)
@staticmethod
def stop_logcat(proc: Optional[subprocess.Popen]) -> None:
if proc is None:
return
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
# --------------------------------------------------------------------------- #
# Pure parsers / helpers (unit-tested)
# --------------------------------------------------------------------------- #
def parse_devices(output: str) -> List[str]:
"""Parse ``adb devices`` output into a list of ready serials (state ``device``)."""
serials: List[str] = []
for line in output.splitlines():
line = line.strip()
if not line or line.startswith("List of devices"):
continue
parts = line.split()
if len(parts) >= 2 and parts[1] == "device":
serials.append(parts[0])
return serials
_AM_FIELD_RE = re.compile(r"^\s*(TotalTime|WaitTime|ThisTime):\s*(\d+)\s*$")
def parse_am_start(output: str) -> dict:
"""Extract ``TotalTime`` / ``WaitTime`` / ``ThisTime`` (ms) from ``am start -W`` output."""
result: dict = {}
for line in output.splitlines():
m = _AM_FIELD_RE.match(line)
if m:
result[m.group(1)] = int(m.group(2))
return result
def _extract_xml(raw: str) -> str:
"""Pull the ``<?xml ...</hierarchy>`` payload out of a uiautomator dump's ``cat`` output.
Tolerant of a leading ``UI hierchary dumped to: <path>`` banner or other noise around the
XML payload (the file-based ``cat``; historically the ``/dev/tty`` output too)."""
start = raw.find("<?xml")
if start == -1:
start = raw.find("<hierarchy")
end = raw.rfind("</hierarchy>")
if start == -1 or end == -1:
return raw.strip()
return raw[start : end + len("</hierarchy>")]