reverse-skills-max: initial public release (restored, redacted, hardened)
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
"""Device-side command construction, exercised without a device.
|
||||
|
||||
The behaviour under test is the one the repository's own documentation says is
|
||||
the reason these helpers exist: a device command is built as an **argv list** and
|
||||
handed to `subprocess` directly, so the host shell never expands `$`, `|`, `>`,
|
||||
`;` or quotes before adb sees them. Every assertion here inspects the argv list
|
||||
that would have been executed; none of them launches adb.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import load_script
|
||||
|
||||
devsh = load_script("devsh.py")
|
||||
coldstart = load_script("coldstart.py")
|
||||
install_test = load_script("install_test.py")
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
class Recorder:
|
||||
"""Captures the argv lists handed to a patched subprocess runner."""
|
||||
|
||||
def __init__(self, stdout="", stderr="", returncode=0):
|
||||
self.calls = []
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
self.returncode = returncode
|
||||
|
||||
def __call__(self, cmd, *args, **kwargs):
|
||||
self.calls.append((list(cmd), kwargs))
|
||||
return subprocess.CompletedProcess(cmd, self.returncode, self.stdout, self.stderr)
|
||||
|
||||
@property
|
||||
def last(self):
|
||||
return self.calls[-1][0]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# devsh.py
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.fixture
|
||||
def devsh_recorder(monkeypatch):
|
||||
rec = Recorder(stdout="ok\n")
|
||||
monkeypatch.setattr(devsh.subprocess, "run", rec)
|
||||
monkeypatch.delenv("ADB_PATH", raising=False)
|
||||
monkeypatch.delenv("ADB_SERIAL", raising=False)
|
||||
return rec
|
||||
|
||||
|
||||
def run_devsh(monkeypatch, rec, argv):
|
||||
monkeypatch.setattr(sys, "argv", ["devsh.py"] + argv)
|
||||
return devsh.main()
|
||||
|
||||
|
||||
class TestDevshCommandConstruction:
|
||||
def test_shell_command_is_one_argv_element_not_a_shell_string(self, monkeypatch, devsh_recorder):
|
||||
"""`|` and `grep` must survive as literal text inside a single argument."""
|
||||
assert run_devsh(monkeypatch, devsh_recorder, ["sh", "pm list packages | grep myapp"]) == 0
|
||||
assert devsh_recorder.last == ["adb", "shell", "pm list packages | grep myapp"]
|
||||
|
||||
def test_serial_is_injected_as_dash_s(self, monkeypatch, devsh_recorder):
|
||||
run_devsh(monkeypatch, devsh_recorder, ["--serial", "SERIAL123", "sh", "id"])
|
||||
assert devsh_recorder.last == ["adb", "-s", "SERIAL123", "shell", "id"]
|
||||
|
||||
def test_serial_equals_form_is_accepted(self, monkeypatch, devsh_recorder):
|
||||
run_devsh(monkeypatch, devsh_recorder, ["--adb=x", "--serial=SERIAL123", "sh", "id"])
|
||||
assert devsh_recorder.last == ["x", "-s", "SERIAL123", "shell", "id"]
|
||||
|
||||
def test_adb_path_comes_from_the_environment_when_not_given(self, monkeypatch, devsh_recorder):
|
||||
monkeypatch.setenv("ADB_PATH", "/opt/platform-tools/adb")
|
||||
monkeypatch.setenv("ADB_SERIAL", "ENVSERIAL")
|
||||
run_devsh(monkeypatch, devsh_recorder, ["sh", "id"])
|
||||
assert devsh_recorder.last == ["/opt/platform-tools/adb", "-s", "ENVSERIAL", "shell", "id"]
|
||||
|
||||
def test_device_listing_explicitly_ignores_the_serial(self, monkeypatch, devsh_recorder):
|
||||
run_devsh(monkeypatch, devsh_recorder, ["dev"])
|
||||
assert devsh_recorder.last == ["adb", "devices", "-l"]
|
||||
|
||||
def test_pull_and_push_keep_their_arguments_positional(self, monkeypatch, devsh_recorder):
|
||||
run_devsh(monkeypatch, devsh_recorder, ["pull", "/data/local/tmp/x.apk", "./x.apk"])
|
||||
assert devsh_recorder.last == ["adb", "pull", "/data/local/tmp/x.apk", "./x.apk"]
|
||||
run_devsh(monkeypatch, devsh_recorder, ["push", "./x.apk", "/data/local/tmp/x.apk"])
|
||||
assert devsh_recorder.last == ["adb", "push", "./x.apk", "/data/local/tmp/x.apk"]
|
||||
|
||||
def test_a_nonzero_device_status_is_reported_as_one(self, monkeypatch, devsh_recorder):
|
||||
devsh_recorder.returncode = 1
|
||||
assert run_devsh(monkeypatch, devsh_recorder, ["sh", "false"]) == 1
|
||||
|
||||
def test_the_su_wrapper_is_posix_single_quoted(self, monkeypatch, devsh_recorder):
|
||||
# Assert the *property*, not the representation. The previous version of this test pinned
|
||||
# `su -c "..."` with `\"` escapes, which is exactly the form that let `;`, `$( )` and
|
||||
# backticks through to the device shell -- the test was guarding the defect.
|
||||
run_devsh(monkeypatch, devsh_recorder, ["su", 'pm grant com.example.app "android.permission.X"'])
|
||||
payload = devsh_recorder.last[-1]
|
||||
assert payload.startswith("su -c '") and payload.endswith("'")
|
||||
inner = payload[len("su -c '"):-1]
|
||||
# A POSIX single-quoted string: no bare quote may appear except as the '\'' escape.
|
||||
assert re.fullmatch(r"[^']*('\\''[^']*)*", inner), payload
|
||||
assert '"' in inner # the caller's double quotes survive as literal characters
|
||||
|
||||
def test_an_unknown_mode_prints_usage_and_exits_two(self, monkeypatch, devsh_recorder):
|
||||
assert run_devsh(monkeypatch, devsh_recorder, ["frobnicate", "x"]) == 2
|
||||
|
||||
def test_no_arguments_prints_usage_and_exits_two(self, monkeypatch, devsh_recorder):
|
||||
assert run_devsh(monkeypatch, devsh_recorder, []) == 2
|
||||
|
||||
|
||||
class TestDevshSuQuotingGap:
|
||||
"""The quoting contract, now unified through `scripts/device_shell.py`.
|
||||
|
||||
This class used to record a *gap*: `devsh.su` wrapped the command in double quotes and escaped
|
||||
only `"`, so `$`, backticks, `;` and `|` from a caller-supplied value still reached the device
|
||||
shell, while `coldstart.Device.shell` used the POSIX single-quote escape and did neutralise them.
|
||||
Both now go through the shared module; the tests below pin the property from both sides, so a
|
||||
future edit that reintroduces double-quote escaping fails here instead of on a device.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("payload", [
|
||||
"id; rm -rf /sdcard/x",
|
||||
"id$(touch /data/local/tmp/pwned)",
|
||||
"id`touch /data/local/tmp/pwned`",
|
||||
"id | tee /data/local/tmp/pwned",
|
||||
"id 'quoted'",
|
||||
])
|
||||
def test_su_wrapper_neutralises_shell_metacharacters(self, monkeypatch, devsh_recorder, payload):
|
||||
run_devsh(monkeypatch, devsh_recorder, ["su", payload])
|
||||
wrapped = devsh_recorder.last[-1]
|
||||
assert wrapped.startswith("su -c '"), wrapped
|
||||
inner = wrapped[len("su -c '"):-1]
|
||||
# The payload must be present verbatim, with only the '\'' sequence able to break out --
|
||||
# i.e. the whole payload is one argument to sh, not syntax.
|
||||
assert re.fullmatch(r"[^']*('\\''[^']*)*", inner), wrapped
|
||||
assert payload.replace("'", "'\\''") == inner
|
||||
|
||||
def test_a_malformed_path_is_refused_before_the_device_shell(self, monkeypatch, devsh_recorder):
|
||||
"""`suf` interpolates a path, so it validates rather than quotes-and-hopes."""
|
||||
rc = run_devsh(monkeypatch, devsh_recorder, ["suf", "/data/local/tmp/x;id", "200"])
|
||||
assert rc == 2
|
||||
assert not devsh_recorder.calls, "the refused command must never reach adb"
|
||||
|
||||
def test_coldstart_uses_the_posix_single_quote_escape(self, monkeypatch):
|
||||
"""The contrasting implementation: this one does neutralise metacharacters."""
|
||||
rec = Recorder()
|
||||
monkeypatch.setattr(coldstart, "run", rec)
|
||||
dev = coldstart.Device(None, use_su=False)
|
||||
dev.su = True
|
||||
dev.shell("echo 'hi'; rm -rf /sdcard/x")
|
||||
|
||||
payload = rec.last[-1]
|
||||
assert payload.startswith("su -c '")
|
||||
assert payload.endswith("'")
|
||||
assert "'\\''" in payload, "the POSIX single-quote escape is missing"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# coldstart.Device: command shape and screenshot handling
|
||||
# --------------------------------------------------------------------------- #
|
||||
class TestDeviceHelperCommands:
|
||||
def make_device(self, monkeypatch, serial=None):
|
||||
rec = Recorder()
|
||||
monkeypatch.setattr(coldstart, "run", rec)
|
||||
dev = coldstart.Device(serial, use_su=False)
|
||||
return dev, rec
|
||||
|
||||
def test_shell_without_root_passes_the_command_as_one_argument(self, monkeypatch):
|
||||
dev, rec = self.make_device(monkeypatch, serial="SERIAL123")
|
||||
dev.shell("dumpsys window | grep mCurrentFocus")
|
||||
assert rec.last == ["adb", "-s", "SERIAL123", "shell", "dumpsys window | grep mCurrentFocus"]
|
||||
|
||||
def test_shell_without_a_serial_omits_dash_s(self, monkeypatch):
|
||||
dev, rec = self.make_device(monkeypatch)
|
||||
dev.shell("id")
|
||||
assert rec.last == ["adb", "shell", "id"]
|
||||
|
||||
def test_foreground_activity_parses_the_resumed_component(self, monkeypatch):
|
||||
dev, rec = self.make_device(monkeypatch, serial="S")
|
||||
rec.stdout = ("mResumedActivity: ActivityRecord{9f2c1 u0 "
|
||||
"com.example.app/.MainActivity t42}\n")
|
||||
assert coldstart.foreground_activity(dev) == "com.example.app/.MainActivity"
|
||||
|
||||
def test_foreground_activity_returns_none_when_absent(self, monkeypatch):
|
||||
dev, rec = self.make_device(monkeypatch, serial="S")
|
||||
rec.stdout = "no matches\n"
|
||||
assert coldstart.foreground_activity(dev) is None
|
||||
|
||||
def test_installed_facts_extracts_version_and_uid(self, monkeypatch):
|
||||
dev, rec = self.make_device(monkeypatch, serial="S")
|
||||
|
||||
def respond(cmd, *args, **kwargs):
|
||||
rec.calls.append((list(cmd), kwargs))
|
||||
if "versionCode" in cmd[-1]:
|
||||
out = " versionCode=42 minSdk=24 targetSdk=34\n versionName=1.2.3\n"
|
||||
elif "sha256sum" in cmd[-1]:
|
||||
out = "a" * 64 + " /data/app/base.apk\n"
|
||||
elif "base.apk" in cmd[-1]:
|
||||
out = "/data/app/~~x==/com.example.app-1/base.apk\n"
|
||||
elif "userId=" in cmd[-1]:
|
||||
out = " userId=10123\n"
|
||||
else:
|
||||
out = ""
|
||||
return subprocess.CompletedProcess(cmd, 0, out, "")
|
||||
|
||||
monkeypatch.setattr(coldstart, "run", respond)
|
||||
facts = coldstart.installed_facts(dev, "com.example.app")
|
||||
assert facts["versionName"] == "1.2.3"
|
||||
assert facts["uid"] == "10123"
|
||||
assert facts["apk_sha256"] == "a" * 64
|
||||
|
||||
def test_version_code_swallows_the_rest_of_the_line(self, monkeypatch):
|
||||
"""Measured sub-string behaviour of `installed_facts`, recorded as a work item.
|
||||
|
||||
The extraction is `r'\\s*(versionCode|...)=(.*)'`, so on a real
|
||||
`dumpsys package` line -- `versionCode=42 minSdk=24 targetSdk=34` --
|
||||
`facts['versionCode']` is the whole remainder, not `42`. It is still a
|
||||
usable before/after fingerprint, which is how the caller uses it, but the
|
||||
field name overstates what it holds.
|
||||
"""
|
||||
dev = coldstart.Device(None, use_su=False)
|
||||
|
||||
def respond(cmd, *args, **kwargs):
|
||||
if "versionCode" in cmd[-1]:
|
||||
out = " versionCode=42 minSdk=24 targetSdk=34\n"
|
||||
else:
|
||||
out = ""
|
||||
return subprocess.CompletedProcess(cmd, 0, out, "")
|
||||
|
||||
monkeypatch.setattr(coldstart, "run", respond)
|
||||
facts = coldstart.installed_facts(dev, "com.example.app")
|
||||
assert facts["versionCode"] == "42 minSdk=24 targetSdk=34"
|
||||
|
||||
|
||||
class TestScreenshotValidation:
|
||||
"""`screencap` is judged by the bytes on disk, not by the command's exit status."""
|
||||
|
||||
PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
def make_device(self, monkeypatch):
|
||||
dev = coldstart.Device(None, use_su=False)
|
||||
return dev
|
||||
|
||||
def test_a_png_header_is_accepted_and_the_size_returned(self, monkeypatch, tmp_path):
|
||||
dev = self.make_device(monkeypatch)
|
||||
payload = self.PNG_MAGIC + b"\x00" * 100
|
||||
|
||||
def fake_run(cmd, *args, **kwargs):
|
||||
kwargs["stdout"].write(payload)
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
monkeypatch.setattr(coldstart.subprocess, "run", fake_run)
|
||||
out = tmp_path / "shot.png"
|
||||
assert dev.screencap(str(out)) == len(payload)
|
||||
assert out.read_bytes() == payload
|
||||
|
||||
def test_the_capture_uses_exec_out_not_shell_redirect(self, monkeypatch, tmp_path):
|
||||
dev = self.make_device(monkeypatch)
|
||||
seen = []
|
||||
|
||||
def fake_run(cmd, *args, **kwargs):
|
||||
seen.append(list(cmd))
|
||||
kwargs["stdout"].write(self.PNG_MAGIC + b"\x00" * 100)
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
monkeypatch.setattr(coldstart.subprocess, "run", fake_run)
|
||||
dev.screencap(str(tmp_path / "shot.png"))
|
||||
assert seen[0] == ["adb", "exec-out", "screencap", "-p"]
|
||||
|
||||
def test_a_non_png_body_is_rejected(self, monkeypatch, tmp_path):
|
||||
"""A device that printed an error into the stream must not be read as a frame."""
|
||||
dev = self.make_device(monkeypatch)
|
||||
|
||||
def fake_run(cmd, *args, **kwargs):
|
||||
kwargs["stdout"].write(b"error: device offline\n")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
monkeypatch.setattr(coldstart.subprocess, "run", fake_run)
|
||||
assert dev.screencap(str(tmp_path / "shot.png")) == -1
|
||||
|
||||
def test_a_truncated_body_is_rejected(self, monkeypatch, tmp_path):
|
||||
dev = self.make_device(monkeypatch)
|
||||
|
||||
def fake_run(cmd, *args, **kwargs):
|
||||
kwargs["stdout"].write(b"\x89PNG")
|
||||
return subprocess.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
monkeypatch.setattr(coldstart.subprocess, "run", fake_run)
|
||||
assert dev.screencap(str(tmp_path / "shot.png")) == -1
|
||||
|
||||
def test_a_timeout_is_reported_as_a_failed_capture(self, monkeypatch, tmp_path):
|
||||
dev = self.make_device(monkeypatch)
|
||||
|
||||
def fake_run(cmd, *args, **kwargs):
|
||||
raise subprocess.TimeoutExpired(cmd, 30)
|
||||
|
||||
monkeypatch.setattr(coldstart.subprocess, "run", fake_run)
|
||||
assert dev.screencap(str(tmp_path / "shot.png")) == -1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# install_test.py: argument plumbing
|
||||
# --------------------------------------------------------------------------- #
|
||||
class TestInstallTestAdbWrappers:
|
||||
def test_serial_is_injected_as_dash_s(self, monkeypatch):
|
||||
rec = Recorder()
|
||||
monkeypatch.setattr(install_test.subprocess, "run", rec)
|
||||
install_test.adb(["shell", "id"], "SERIAL")
|
||||
assert rec.last == ["adb", "-s", "SERIAL", "shell", "id"]
|
||||
|
||||
def test_without_a_serial_no_dash_s_is_added(self, monkeypatch):
|
||||
rec = Recorder()
|
||||
monkeypatch.setattr(install_test.subprocess, "run", rec)
|
||||
install_test.adb(["devices", "-l"])
|
||||
assert rec.last == ["adb", "devices", "-l"]
|
||||
|
||||
def test_stderr_is_appended_so_device_errors_are_not_silently_dropped(self, monkeypatch):
|
||||
rec = Recorder(stdout="out", stderr="error: device offline")
|
||||
monkeypatch.setattr(install_test.subprocess, "run", rec)
|
||||
combined = install_test.adb(["devices"])
|
||||
assert "out" in combined
|
||||
assert "device offline" in combined
|
||||
|
||||
def test_su_wrapper_keeps_the_command_as_one_argument(self, monkeypatch):
|
||||
rec = Recorder()
|
||||
monkeypatch.setattr(install_test.subprocess, "run", rec)
|
||||
install_test.su('pm grant com.example.app "android.permission.X"', "S")
|
||||
assert rec.last == [
|
||||
"adb", "-s", "S", "shell",
|
||||
'su -c "pm grant com.example.app \\"android.permission.X\\""',
|
||||
]
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Regression tests for the documented-command gate itself.
|
||||
|
||||
`check_commands.py` is the gate that proves other documentation is runnable, so a blind spot in it
|
||||
is worth more than a blind spot elsewhere: it turns "146 commands checked, 0 drift" into a claim the
|
||||
repository cannot back. One such blind spot is pinned here, exactly as it was found.
|
||||
|
||||
**What was wrong.** `resolve_script()` tried the *document's own directory* first when resolving a
|
||||
relative path. A bare `python analyze_pair.py` written inside `docs/tool-verification/` therefore
|
||||
resolved to `tools/_work/bench/unpack/b3/analyze_pair.py` -- a real file, inside the gitignored
|
||||
workbench -- and scored `ok`. Seven commands in two evidence files used that form while the same
|
||||
files wrote the correct path elsewhere, and a reader copying one from the repository root gets
|
||||
"No such file". The gate said the documentation was clean.
|
||||
|
||||
The tests below drive the resolver and the finding classifier directly rather than through the CLI,
|
||||
so they stay fast and do not depend on the current state of the documentation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import REPO_ROOT
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
CHECK_COMMANDS = REPO_ROOT / "check_commands.py"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def gate():
|
||||
"""The gate as a module, so its internals can be driven without a subprocess."""
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
import importlib
|
||||
|
||||
return importlib.import_module("check_commands")
|
||||
|
||||
|
||||
def _workbench_script_name(gate):
|
||||
"""A `.py` basename that exists only under tools/, or skip if the workbench is absent."""
|
||||
names = sorted(gate._workbench_names())
|
||||
if not names:
|
||||
pytest.skip("no workbench present (tools/ is gitignored and may not exist in a checkout)")
|
||||
shipped = {p.name for p in (REPO_ROOT / "skills" / "apk-reverse" / "scripts").glob("*.py")}
|
||||
root = {p.name for p in REPO_ROOT.glob("*.py")}
|
||||
for name in names:
|
||||
if name not in shipped and name not in root:
|
||||
return name
|
||||
pytest.skip("every workbench script name is also shipped; nothing to test with")
|
||||
|
||||
|
||||
def test_a_bare_name_that_only_exists_in_the_workbench_is_drift(gate):
|
||||
"""The exact defect: the name exists *somewhere*, which used to be enough to pass it."""
|
||||
name = _workbench_script_name(gate)
|
||||
doc = str(REPO_ROOT / "docs" / "tool-verification" / "EXTENSION-extraction-shell-bench.md")
|
||||
path, why = gate.resolve_script(name, doc, include_workbench=False)
|
||||
assert path is None
|
||||
assert why == "unqualified-workbench", why
|
||||
|
||||
text = "```\n$ python %s --flag\n```\n" % name
|
||||
findings = gate.command_findings(doc, text, {}, False)
|
||||
kinds = [f["kind"] for f in findings]
|
||||
assert "unqualified-workbench" in kinds, findings
|
||||
|
||||
|
||||
def test_a_path_qualified_workbench_reference_is_a_skip_not_drift(gate):
|
||||
"""`tools/_work/...` is deliberate in an evidence record: a skip, reported with its reason."""
|
||||
name = _workbench_script_name(gate)
|
||||
doc = str(REPO_ROOT / "docs" / "tool-verification" / "EXTENSION-extraction-shell-bench.md")
|
||||
_path, why = gate.resolve_script("tools/_work/bench/unpack/b3/%s" % name, doc,
|
||||
include_workbench=False)
|
||||
assert why == "workbench"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_repo(gate, tmp_path, monkeypatch):
|
||||
"""A repository-shaped tree with its own workbench, so resolution can be tested anywhere.
|
||||
|
||||
This exists because the workbench-dependent tests above cannot run on a clean checkout: the
|
||||
`tools/` tree is gitignored, so on CI they skip and the gate's most important property goes
|
||||
unverified exactly where it matters. The tree built here has the layout the resolver assumes
|
||||
(a root, a skill dir, a gitignored `tools/`) and nothing else.
|
||||
"""
|
||||
root = tmp_path / "repo"
|
||||
(root / "skills" / "apk-reverse" / "scripts").mkdir(parents=True)
|
||||
(root / "docs" / "tool-verification").mkdir(parents=True)
|
||||
(root / "tools" / "_work" / "bench").mkdir(parents=True)
|
||||
(root / "README.md").write_text("# root\n", encoding="utf-8")
|
||||
(root / "skills" / "apk-reverse" / "scripts" / "shipped.py").write_text(
|
||||
'"""capability: x\n"""\nimport argparse\np = argparse.ArgumentParser()\n'
|
||||
'p.add_argument("--flag")\n', encoding="utf-8")
|
||||
(root / "tools" / "_work" / "bench" / "solo_in_workbench.py").write_text(
|
||||
"print('x')\n", encoding="utf-8")
|
||||
(root / "docs" / "tool-verification" / "record.md").write_text("# record\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(gate, "ROOT", str(root))
|
||||
monkeypatch.setattr(gate, "SKILL_DIR", str(root / "skills" / "apk-reverse"))
|
||||
monkeypatch.setattr(gate, "SCRIPT_DIR", str(root / "skills" / "apk-reverse" / "scripts"))
|
||||
gate._WORKBENCH_INDEX.clear()
|
||||
yield root
|
||||
gate._WORKBENCH_INDEX.clear()
|
||||
|
||||
|
||||
def test_resolution_order_is_root_then_skill_then_document(gate, fake_repo):
|
||||
"""The corrected order, asserted against a tree that exists on any machine."""
|
||||
doc = str(fake_repo / "docs" / "tool-verification" / "record.md")
|
||||
path, why = gate.resolve_script("README.md", doc, include_workbench=False)
|
||||
assert why == "ok" and path.endswith("README.md")
|
||||
path, why = gate.resolve_script("shipped.py", doc, include_workbench=False)
|
||||
assert why == "ok" and path.endswith("shipped.py")
|
||||
|
||||
|
||||
def test_a_workbench_only_name_is_drift_and_a_qualified_one_is_not(gate, fake_repo):
|
||||
doc = str(fake_repo / "docs" / "tool-verification" / "record.md")
|
||||
path, why = gate.resolve_script("solo_in_workbench.py", doc, include_workbench=False)
|
||||
assert (path, why) == (None, "unqualified-workbench")
|
||||
path, why = gate.resolve_script("tools/_work/bench/solo_in_workbench.py", doc,
|
||||
include_workbench=False)
|
||||
assert (path, why) == (None, "workbench")
|
||||
|
||||
|
||||
def test_a_bare_name_the_document_paths_elsewhere_is_a_skip(gate, fake_repo):
|
||||
"""The fix that made the gate behave the same on CI as on a populated machine.
|
||||
|
||||
`solo_in_workbench.py` appears once bare and once with a path in the same document. The
|
||||
document has already told the reader where it lives, so the bare line is a shorthand -- and
|
||||
that verdict must not depend on the gitignored tree being visible, which is how the first
|
||||
version of this fix passed locally and failed on CI.
|
||||
"""
|
||||
doc = str(fake_repo / "docs" / "tool-verification" / "record.md")
|
||||
text = ("Path: `tools/_work/bench/solo_in_workbench.py` explains it.\n\n"
|
||||
"```\n$ python solo_in_workbench.py\n```\n")
|
||||
findings = gate.command_findings(doc, text, {}, False)
|
||||
kinds = [f["kind"] for f in findings]
|
||||
assert "unqualified-workbench" not in kinds, findings
|
||||
assert "missing-script" not in kinds, findings
|
||||
assert "skipped" in kinds, findings
|
||||
|
||||
|
||||
def test_a_bare_name_after_cd_with_no_resolution_is_skipped_not_drift(gate, fake_repo):
|
||||
"""`cd <somewhere cloned>` then a bare name: unverifiable, and reported as such."""
|
||||
doc = str(fake_repo / "docs" / "tool-verification" / "record.md")
|
||||
text = "```\n$ cd tools/_work/bench/repos/elsewhere\n$ python upstream_tool.py --x\n```\n"
|
||||
findings = gate.command_findings(doc, text, {}, False)
|
||||
kinds = [f["kind"] for f in findings]
|
||||
assert "missing-script" not in kinds, findings
|
||||
assert "after-cd-unresolved" in kinds, findings
|
||||
|
||||
|
||||
def test_the_external_dcc_entry_point_is_never_drift(gate):
|
||||
"""`python dcc.py` after `cd tools/_work/bench/repos/dcc` runs inside the external dcc
|
||||
checkout, so it is a skip on every machine -- including one without the workbench.
|
||||
|
||||
The name is declared in EXTERNAL_SCRIPTS because recognition by filename needs `tools/` to
|
||||
exist: on a clean checkout `dcc.py` degraded to `missing-script` drift and the gate failed away
|
||||
from the machine that wrote the record. Asserted as a *property* rather than as one specific
|
||||
reason, because two mechanisms can legitimately produce it -- the declared entry point and the
|
||||
workbench-after-`cd` rule -- and pinning either one alone makes the test machine-dependent,
|
||||
which is the very defect it is guarding against.
|
||||
"""
|
||||
doc = str(REPO_ROOT / "docs" / "tool-verification" / "EXTENSION-java2c.md")
|
||||
_path, why = gate.resolve_script("dcc.py", doc, include_workbench=False, chdir=True)
|
||||
assert why in ("external", "workbench-after-cd"), why
|
||||
|
||||
text = "```\n$ cd tools/_work/bench/repos/dcc\n$ python dcc.py --no-build\n```\n"
|
||||
findings = gate.command_findings(doc, text, {}, False)
|
||||
kinds = [f["kind"] for f in findings]
|
||||
assert "missing-script" not in kinds, findings
|
||||
assert "unqualified-workbench" not in kinds, findings
|
||||
assert any(k in ("workbench-after-cd", "external") for k in kinds), findings
|
||||
|
||||
|
||||
def test_a_shipped_script_resolves_from_the_repository_root(gate):
|
||||
"""The order that matters: root, then the skill's scripts, then the document's own directory."""
|
||||
doc = str(REPO_ROOT / "docs" / "tool-verification" / "README.md")
|
||||
path, why = gate.resolve_script("check_repo.py", doc, include_workbench=False)
|
||||
assert why == "ok" and path and path.endswith("check_repo.py")
|
||||
path, why = gate.resolve_script("skills/apk-reverse/scripts/scan_leaks.py", doc,
|
||||
include_workbench=False)
|
||||
assert why == "ok" and path and path.endswith("scan_leaks.py")
|
||||
|
||||
|
||||
def test_the_committed_tree_reports_no_drift(gate, tmp_path):
|
||||
"""The gate's own verdict on this repository, run in-process so it cannot drift silently."""
|
||||
docs = gate.collect_docs(None)
|
||||
findings = []
|
||||
cache = {}
|
||||
for doc in docs:
|
||||
try:
|
||||
text = Path(doc).read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
continue
|
||||
findings.extend(gate.command_findings(doc, text, cache, False))
|
||||
drift = [f for f in findings
|
||||
if f["kind"] in ("unknown-flag", "missing-script", "unqualified-workbench")]
|
||||
detail = "\n".join(" %s:%s %s (%s)" % (f["doc"], f["line"], f["command"], f["kind"])
|
||||
for f in drift[:20])
|
||||
assert not drift, "documented commands that cannot run:\n%s" % detail
|
||||
252
vendor-new/apk-reverse/tests/integration/test_no_device_flows.py
Normal file
252
vendor-new/apk-reverse/tests/integration/test_no_device_flows.py
Normal file
@@ -0,0 +1,252 @@
|
||||
"""No-device flows: launch-health verdicts, log parsing, tombstone parsing.
|
||||
|
||||
Everything here runs on a bare checkout. The device commands are intercepted, so
|
||||
what is being tested is the *decision logic* -- which is exactly the part that a
|
||||
device test would otherwise hide behind "it looked fine on screen".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import load_script, run_script
|
||||
|
||||
install_test = load_script("install_test.py")
|
||||
coldstart = load_script("coldstart.py")
|
||||
native_crash = load_script("native_crash.py")
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
TOMBSTONE = "tombstone_sample.txt"
|
||||
LOGCAT = "logcat_sample.txt"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# install_test.py: the two-sample pid verdict
|
||||
# --------------------------------------------------------------------------- #
|
||||
def drive(monkeypatch, pids, log="", capsys=None):
|
||||
"""Run install_test.main() with the device replaced by a scripted responder.
|
||||
|
||||
`pids` is consumed in order by successive `pidof` calls, which is the whole
|
||||
point of the check: one sample cannot tell a running app from a crash loop.
|
||||
"""
|
||||
pids = list(pids)
|
||||
calls = []
|
||||
|
||||
def fake_adb(args, serial=None, timeout=600):
|
||||
calls.append(list(args))
|
||||
if list(args)[:1] == ["logcat"] and "-d" in args:
|
||||
return log
|
||||
return ""
|
||||
|
||||
def fake_sh(cmd, serial=None):
|
||||
calls.append(cmd)
|
||||
if cmd.startswith("pidof"):
|
||||
return pids.pop(0) if pids else ""
|
||||
if "mCurrentFocus" in cmd:
|
||||
return "mCurrentFocus=Window{a1b2 u0 com.example.app/.MainActivity}"
|
||||
return ""
|
||||
|
||||
monkeypatch.setattr(install_test, "adb", fake_adb)
|
||||
monkeypatch.setattr(install_test, "sh", fake_sh)
|
||||
monkeypatch.setattr(install_test.time, "sleep", lambda _s: None)
|
||||
monkeypatch.setattr(sys, "argv", [
|
||||
"install_test.py", "--apk", "out/app.apk", "--pkg", "com.example.app",
|
||||
"--activity", ".MainActivity",
|
||||
])
|
||||
return install_test.main(), calls
|
||||
|
||||
|
||||
class TestLaunchHealthVerdict:
|
||||
def test_a_stable_pid_with_no_fatal_signature_is_ok(self, monkeypatch, capsys):
|
||||
code, calls = drive(monkeypatch, ["1234", "1234"])
|
||||
assert code == 0, capsys.readouterr().out
|
||||
|
||||
def test_a_changed_pid_is_a_failure_even_though_the_app_is_running(self, monkeypatch, capsys):
|
||||
"""The measured shape of a crash loop: alive at the first sample, restarted by the second."""
|
||||
code, _calls = drive(monkeypatch, ["1234", "5678"])
|
||||
out = capsys.readouterr().out
|
||||
assert code == 1
|
||||
assert "pid @+6s : 1234" in out
|
||||
assert "5678" in out
|
||||
|
||||
def test_a_dead_app_is_a_failure(self, monkeypatch, capsys):
|
||||
code, _calls = drive(monkeypatch, ["", ""])
|
||||
assert code == 1
|
||||
assert "(dead)" in capsys.readouterr().out
|
||||
|
||||
def test_a_fatal_signature_fails_even_with_a_stable_pid(self, monkeypatch, capsys):
|
||||
"""Stability is not health: a caught-but-fatal error still fails the check."""
|
||||
code, _calls = drive(monkeypatch, ["1234", "1234"],
|
||||
log="01-01 10:00:01 E AndroidRuntime: FATAL EXCEPTION: main\n")
|
||||
assert code == 1
|
||||
assert "FATAL EXCEPTION" in capsys.readouterr().out
|
||||
|
||||
def test_both_pid_samples_are_actually_taken(self, monkeypatch, capsys):
|
||||
_code, calls = drive(monkeypatch, ["1234", "1234"])
|
||||
capsys.readouterr()
|
||||
pidof_calls = [c for c in calls if isinstance(c, str) and c.startswith("pidof")]
|
||||
assert len(pidof_calls) == 2, "the second sample is what catches a crash loop"
|
||||
|
||||
def test_the_launch_uses_the_package_slash_activity_component(self, monkeypatch, capsys):
|
||||
_code, calls = drive(monkeypatch, ["1234", "1234"])
|
||||
capsys.readouterr()
|
||||
assert any("am start -n com.example.app/.MainActivity" == c for c in calls
|
||||
if isinstance(c, str))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# coldstart.py: logcat signal counting
|
||||
# --------------------------------------------------------------------------- #
|
||||
class TestLogcatSignalCounting:
|
||||
@pytest.fixture
|
||||
def counts(self, fixtures_dir):
|
||||
path = fixtures_dir / LOGCAT
|
||||
assert path.exists(), "the logcat fixture is missing from tests/fixtures"
|
||||
return coldstart.signal_counts(str(path), coldstart.SIGNAL_PATTERNS)
|
||||
|
||||
@pytest.mark.parametrize("name,expected", [
|
||||
("FATAL EXCEPTION", 1),
|
||||
("VerifyError", 1),
|
||||
("Bad checksum", 1),
|
||||
("ANR", 1),
|
||||
("Displayed", 1),
|
||||
("IncompatibleClassChangeError", 0),
|
||||
("ClassNotFoundException", 0),
|
||||
("uncaughtException", 0),
|
||||
("am_crash", 0),
|
||||
])
|
||||
def test_counts(self, counts, name, expected):
|
||||
assert counts[name] == expected
|
||||
|
||||
def test_every_pattern_in_the_table_is_reported(self, counts):
|
||||
assert set(counts) == set(coldstart.SIGNAL_PATTERNS)
|
||||
|
||||
def test_a_missing_log_yields_no_counts_rather_than_raising(self, tmp_path):
|
||||
assert coldstart.signal_counts(str(tmp_path / "nope.txt"),
|
||||
coldstart.SIGNAL_PATTERNS) == {}
|
||||
|
||||
def test_the_displayed_pattern_captures_the_milliseconds(self):
|
||||
import re
|
||||
match = re.search(coldstart.SIGNAL_PATTERNS["Displayed"],
|
||||
"I ActivityManager: Displayed com.example.app/.MainActivity: +842ms")
|
||||
assert match is not None
|
||||
assert match.group(1) == "842"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# native_crash.py: tombstone parsing
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.fixture
|
||||
def crash(fixtures_dir):
|
||||
text = (fixtures_dir / TOMBSTONE).read_text(encoding="utf-8")
|
||||
crashes = native_crash.parse(text)
|
||||
assert len(crashes) == 1, "the fixture holds exactly one crash block"
|
||||
return crashes[0]
|
||||
|
||||
|
||||
class TestTombstoneParsing:
|
||||
def test_signal_and_code(self, crash):
|
||||
assert crash["signal"] == 11
|
||||
assert crash["signame"] == "SIGSEGV"
|
||||
assert crash["si_code"] == "SEGV_MAPERR"
|
||||
assert crash["fault"] == "0x0000000000000010"
|
||||
|
||||
def test_process_identity_is_picked_up(self, crash):
|
||||
assert crash["pid"] == "12345"
|
||||
assert crash["tid"] == "12345"
|
||||
assert crash["proc"] == "com.example.app"
|
||||
|
||||
def test_uptime_and_cause(self, crash):
|
||||
assert crash["uptime"] == "5"
|
||||
assert "null pointer" in crash["cause"]
|
||||
|
||||
def test_backtrace_frames_are_complete(self, crash):
|
||||
assert len(crash["frames"]) == 4
|
||||
assert crash["frames"][0]["path"].endswith("libfoo.so")
|
||||
assert crash["frames"][0]["symbol"] == ""
|
||||
assert crash["frames"][1]["symbol"] == "Java_com_example_app_Native_check+120"
|
||||
assert crash["frames"][2]["path"].startswith("/apex/")
|
||||
assert crash["frames"][2]["symbol"] == "__pthread_start+256"
|
||||
|
||||
def test_registers_are_collected_from_the_dump_line(self, crash):
|
||||
assert crash["regs"]["x0"] == 0x10
|
||||
assert crash["regs"]["x1"] == 0x7A12345678
|
||||
assert crash["regs"]["sp"] == 0x7FFABC1110
|
||||
assert crash["regs"]["lr"] == 0x7A11112222
|
||||
|
||||
def test_the_pc_key_is_overwritten_by_the_last_backtrace_frame(self, crash):
|
||||
"""Measured behaviour, recorded rather than smoothed over.
|
||||
|
||||
Register extraction scans every line of the block with one permissive
|
||||
pattern, so a `#NN pc <hex>` backtrace line also matches and the last one
|
||||
wins. The register dump's own `pc 0000007a111128cc` is therefore not what
|
||||
`regs['pc']` holds. It is a reporting wart, not a wrong verdict -- but it
|
||||
is a wart, so it is pinned here and listed in the harness report.
|
||||
"""
|
||||
assert crash["regs"]["pc"] == 0x55488, "the last frame's pc"
|
||||
assert crash["regs"]["pc"] != 0x7A111128CC, "the register dump's pc"
|
||||
|
||||
def test_empty_input_produces_no_crashes(self):
|
||||
assert native_crash.parse("") == []
|
||||
|
||||
def test_a_log_without_a_signal_line_produces_no_crashes(self):
|
||||
assert native_crash.parse("I/whatever: nothing to see\n") == []
|
||||
|
||||
|
||||
class TestLibraryClassification:
|
||||
@pytest.mark.parametrize("path,expected", [
|
||||
("/apex/com.android.runtime/lib64/bionic/libc.so", True),
|
||||
("/system/lib64/libart.so", True),
|
||||
("/vendor/lib64/libqcom.so", True),
|
||||
("/data/dalvik-cache/arm64/system@framework@boot.oat", True),
|
||||
("/data/app/~~AbCdEf==/com.example.app-1/lib/arm64/libfoo.so", False),
|
||||
("/data/local/tmp/libpayload.so", False),
|
||||
])
|
||||
def test_classification(self, path, expected):
|
||||
assert native_crash.is_system_lib(path) is expected
|
||||
|
||||
def test_a_bare_name_is_treated_as_system(self):
|
||||
"""Measured behaviour: a path that is not absolute cannot be attributed to the app."""
|
||||
assert native_crash.is_system_lib("libfoo.so") is True
|
||||
|
||||
|
||||
class TestVerdictHint:
|
||||
def test_a_small_fault_address_held_in_a_register_is_flagged_as_arranged(self, crash):
|
||||
hints = " ".join(native_crash.verdict_hint(crash))
|
||||
assert "small integer" in hints
|
||||
assert "ARRANGED" in hints
|
||||
assert "x0" in hints
|
||||
|
||||
def test_an_ordinary_null_dereference_gets_no_arranged_hint(self):
|
||||
ordinary = {"fault": "0x0", "regs": {"x1": 0x7A111128CC}, "signame": "SIGSEGV"}
|
||||
hints = native_crash.verdict_hint(ordinary)
|
||||
assert any("small integer" in h for h in hints)
|
||||
assert not any("ARRANGED" in h for h in hints)
|
||||
|
||||
def test_sigkill_is_called_out_as_a_deliberate_termination(self):
|
||||
hints = native_crash.verdict_hint({"fault": "0x0", "regs": {}, "signame": "SIGKILL"})
|
||||
assert any("deliberate termination" in h for h in hints)
|
||||
|
||||
|
||||
class TestNativeCrashCli:
|
||||
def test_the_fixture_tombstone_is_parsed_by_the_cli(self, fixtures_dir):
|
||||
result = run_script("native_crash.py", [str(fixtures_dir / TOMBSTONE)], timeout=120)
|
||||
assert not result.timed_out
|
||||
assert not result.has_traceback, result.output
|
||||
assert result.returncode == 0, result.output
|
||||
assert "libfoo.so" in result.stdout
|
||||
assert "SIGSEGV" in result.stdout
|
||||
|
||||
def test_a_missing_log_is_reported_without_a_traceback(self, tmp_path):
|
||||
result = run_script("native_crash.py", [str(tmp_path / "absent.txt")])
|
||||
assert not result.has_traceback, result.output
|
||||
assert result.returncode != 0
|
||||
assert "no such file" in result.output
|
||||
|
||||
def test_a_malformed_lib_spec_is_refused(self, fixtures_dir):
|
||||
result = run_script("native_crash.py",
|
||||
[str(fixtures_dir / TOMBSTONE), "--lib", "notapair"])
|
||||
assert result.returncode != 0
|
||||
assert "NAME=PATH" in result.output
|
||||
Reference in New Issue
Block a user