codevanta 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codevanta/analysis/adb.py +1358 -0
- codevanta/analysis/apple_ips.py +628 -0
- codevanta/analysis/asar.py +519 -0
- codevanta/analysis/binmeta.py +1520 -0
- codevanta/analysis/cdp_client.py +1154 -0
- codevanta/analysis/compare.py +738 -0
- codevanta/analysis/containers.py +1364 -0
- codevanta/analysis/crash_core.py +676 -0
- codevanta/analysis/debug_session.py +1504 -0
- codevanta/analysis/evidence_bundle.py +25 -8
- codevanta/analysis/fdt.py +436 -0
- codevanta/analysis/nav.py +704 -0
- codevanta/analysis/nav_providers.py +172 -0
- codevanta/analysis/sigrok_cli_wrap.py +319 -0
- codevanta/analysis/sigrok_sr.py +352 -0
- codevanta/analysis/solver.py +712 -2
- codevanta/analysis/sqlite_view.py +281 -0
- codevanta/analysis/uart_decode.py +360 -0
- codevanta/cli/containers.py +272 -0
- codevanta/cli/crash.py +234 -0
- codevanta/cli/main.py +585 -6
- codevanta/cli/meta.py +107 -0
- codevanta/cli/mobile.py +361 -0
- codevanta/cli/nav.py +268 -0
- codevanta/cli/signal.py +285 -0
- codevanta/cli/web.py +259 -0
- codevanta/core/unknowns.py +340 -0
- codevanta/mcp_server/ext_bin.py +64 -0
- codevanta/mcp_server/ext_crash.py +80 -0
- codevanta/mcp_server/ext_meta.py +25 -0
- codevanta/mcp_server/ext_mobile.py +65 -0
- codevanta/mcp_server/ext_nav.py +214 -0
- codevanta/mcp_server/ext_signal.py +91 -0
- codevanta/mcp_server/ext_solver.py +130 -0
- codevanta/mcp_server/ext_web.py +138 -0
- codevanta/mcp_server/server.py +118 -1
- codevanta/mcp_server/tools.py +247 -0
- {codevanta-0.1.0.dist-info → codevanta-0.2.0.dist-info}/METADATA +72 -46
- {codevanta-0.1.0.dist-info → codevanta-0.2.0.dist-info}/RECORD +43 -11
- {codevanta-0.1.0.dist-info → codevanta-0.2.0.dist-info}/WHEEL +0 -0
- {codevanta-0.1.0.dist-info → codevanta-0.2.0.dist-info}/entry_points.txt +0 -0
- {codevanta-0.1.0.dist-info → codevanta-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {codevanta-0.1.0.dist-info → codevanta-0.2.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1358 @@
|
|
|
1
|
+
"""Android/ADB lane: pure parsers plus thin, skip-gated live wrappers.
|
|
2
|
+
|
|
3
|
+
This module is the ADB twin of the gdb lane: everything that *understands*
|
|
4
|
+
device output is a pure parser that is fully tested offline against canned
|
|
5
|
+
transcripts, and everything that *touches* a device is a thin wrapper that is
|
|
6
|
+
skip-gated on one ``shutil.which`` probe and shells out with a bounded
|
|
7
|
+
timeout. On a machine without adb -- a Windows workstation, a slim CI
|
|
8
|
+
container -- the parsers still run and the wrappers still import; they only
|
|
9
|
+
ever fail with a typed error naming exactly what is missing.
|
|
10
|
+
|
|
11
|
+
Pure parsers
|
|
12
|
+
------------
|
|
13
|
+
:func:`parse_devices` reads ``adb devices -l`` output (serial, state, model,
|
|
14
|
+
and an emulator/physical kind), :func:`parse_packages` reads
|
|
15
|
+
``pm list packages -3`` (name, and the ``base.apk`` path when the ``-f``
|
|
16
|
+
prefixed transcript is fed in), :func:`parse_processes` reads ``ps -A`` in
|
|
17
|
+
both the modern (``VSZ``/``ADDR``/``S``) and legacy (``VSIZE``) column
|
|
18
|
+
layouts, :func:`parse_services` reads ``service list`` lines,
|
|
19
|
+
:func:`parse_getprop` keeps the build-identity whitelist
|
|
20
|
+
(``ro.build.version.*``, ``ro.product.*``, ``ro.build.fingerprint``) from a
|
|
21
|
+
getprop dump in either the bracketed or the plain rendering,
|
|
22
|
+
:func:`parse_dumpsys_package` pulls versionName, installer, firstInstallTime
|
|
23
|
+
and the requested-permission count out of one ``dumpsys package`` transcript,
|
|
24
|
+
and :func:`parse_window_focus` decodes ``mCurrentFocus``/``mFocusedApp`` from
|
|
25
|
+
``dumpsys window``. Every parser tolerates junk lines as warnings; malformed
|
|
26
|
+
*input to the parser itself* (anything that is not ``str``) raises
|
|
27
|
+
:class:`~codevanta.core.report.AnalysisError`.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import hashlib
|
|
33
|
+
import re
|
|
34
|
+
import shutil
|
|
35
|
+
import subprocess
|
|
36
|
+
from collections.abc import Sequence
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any, Protocol
|
|
40
|
+
|
|
41
|
+
from codevanta.core.report import AnalysisError
|
|
42
|
+
from codevanta.evidence.confidence import Confidence
|
|
43
|
+
from codevanta.evidence.models import Evidence
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"ADB_MISSING_MESSAGE",
|
|
47
|
+
"AdbBugreportResult",
|
|
48
|
+
"AdbDevice",
|
|
49
|
+
"AdbDevicesResult",
|
|
50
|
+
"AdbInstallResult",
|
|
51
|
+
"AdbPackageEntry",
|
|
52
|
+
"AdbPackageInfoResult",
|
|
53
|
+
"AdbPackagesResult",
|
|
54
|
+
"AdbProcessEntry",
|
|
55
|
+
"AdbProcessesResult",
|
|
56
|
+
"AdbPropsResult",
|
|
57
|
+
"AdbScreencapResult",
|
|
58
|
+
"AdbServiceEntry",
|
|
59
|
+
"AdbServicesResult",
|
|
60
|
+
"AdbWindowFocusResult",
|
|
61
|
+
"DEFAULT_TIMEOUT",
|
|
62
|
+
"MAX_APK_BYTES",
|
|
63
|
+
"MAX_DEVICES",
|
|
64
|
+
"MAX_PACKAGES",
|
|
65
|
+
"MAX_PROCESSES",
|
|
66
|
+
"MAX_PROPS",
|
|
67
|
+
"MAX_SERVICES",
|
|
68
|
+
"MAX_TIMEOUT",
|
|
69
|
+
"MAX_WARNINGS",
|
|
70
|
+
"Runner",
|
|
71
|
+
"RunnerOutcome",
|
|
72
|
+
"adb_available",
|
|
73
|
+
"bugreport",
|
|
74
|
+
"inspect_device",
|
|
75
|
+
"install_apk",
|
|
76
|
+
"list_devices",
|
|
77
|
+
"list_packages",
|
|
78
|
+
"list_processes",
|
|
79
|
+
"parse_devices",
|
|
80
|
+
"parse_dumpsys_package",
|
|
81
|
+
"parse_getprop",
|
|
82
|
+
"parse_packages",
|
|
83
|
+
"parse_processes",
|
|
84
|
+
"parse_services",
|
|
85
|
+
"parse_window_focus",
|
|
86
|
+
"screencap",
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
#: Devices kept from one ``adb devices -l`` transcript, whatever is attached.
|
|
90
|
+
MAX_DEVICES = 64
|
|
91
|
+
|
|
92
|
+
#: Packages kept from one ``pm list packages -3`` transcript (bounded work).
|
|
93
|
+
MAX_PACKAGES = 256
|
|
94
|
+
|
|
95
|
+
#: Processes kept from one ``ps -A`` transcript (bounded work).
|
|
96
|
+
MAX_PROCESSES = 256
|
|
97
|
+
|
|
98
|
+
#: Services kept from one ``service list`` transcript (bounded work).
|
|
99
|
+
MAX_SERVICES = 256
|
|
100
|
+
|
|
101
|
+
#: Whitelisted properties kept from one ``getprop`` transcript (bounded work).
|
|
102
|
+
MAX_PROPS = 256
|
|
103
|
+
|
|
104
|
+
#: Warnings kept per parse before the rest are summarised away.
|
|
105
|
+
MAX_WARNINGS = 32
|
|
106
|
+
|
|
107
|
+
#: Default wall-clock budget for one adb invocation, in seconds.
|
|
108
|
+
DEFAULT_TIMEOUT = 20.0
|
|
109
|
+
|
|
110
|
+
#: Hard ceiling on the wall-clock budget of one adb invocation. Bugreports on
|
|
111
|
+
#: real hardware regularly exceed the 20 s default; raise ``timeout``
|
|
112
|
+
#: deliberately, never silently.
|
|
113
|
+
MAX_TIMEOUT = 600.0
|
|
114
|
+
|
|
115
|
+
#: Hard ceiling on the size of an APK :func:`install_apk` will accept.
|
|
116
|
+
MAX_APK_BYTES = 4 * 1024 * 1024 * 1024
|
|
117
|
+
|
|
118
|
+
#: Characters a serial may contain (``emulator-5554``, ``127.0.0.1:5555``,
|
|
119
|
+
#: USB serials, product names); anything else is refused before it reaches argv.
|
|
120
|
+
_SERIAL_CHARS = re.compile(r"^[A-Za-z0-9._:-]+$")
|
|
121
|
+
|
|
122
|
+
#: Prefixes of ``adb devices`` chatter that is not a device row.
|
|
123
|
+
_DEVICES_SKIP_PREFIXES = (
|
|
124
|
+
"List of devices attached",
|
|
125
|
+
"* daemon",
|
|
126
|
+
"* server",
|
|
127
|
+
"* failed",
|
|
128
|
+
"- waiting",
|
|
129
|
+
"adb:",
|
|
130
|
+
"restarting in",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
#: ``package:/data/app/.../base.apk=com.example.app`` (``-f`` transcripts) and
|
|
134
|
+
#: ``package:com.example.app`` (bare ``-3`` transcripts).
|
|
135
|
+
_PACKAGE_LINE = re.compile(r"^package:(?P<rest>.+)$")
|
|
136
|
+
|
|
137
|
+
#: ``[ro.build.version.release]: [14]`` -- the bracketed getprop rendering.
|
|
138
|
+
_PROP_BRACKETED = re.compile(r"^\[(?P<key>[^\]]+)\]:\s*\[(?P<value>.*)\]$")
|
|
139
|
+
|
|
140
|
+
#: ``ActivityRecord{... u0 com.example.app/.MainActivity ...}`` /
|
|
141
|
+
#: ``Window{... u0 com.example.app/com.example.app.MainActivity}``.
|
|
142
|
+
_FOCUS_COMPONENT = re.compile(r"\bu0\s+(?P<component>[^\s{}]+/[^\s{}]+)")
|
|
143
|
+
|
|
144
|
+
#: ``ComponentInfo{com.example.app/com.example.app.MainActivity}``.
|
|
145
|
+
_COMPONENT_INFO = re.compile(r"ComponentInfo\{(?P<component>[^}]+)\}")
|
|
146
|
+
|
|
147
|
+
#: ``Package [com.example.app] (...)`` -- dumpsys package's first line.
|
|
148
|
+
#: ``Package [com.example.app] (...)`` -- dumpsys package's first line.
|
|
149
|
+
_DUMPSYS_PACKAGE_HEADER = re.compile(r"^Package \[(?P<name>[^\]]+)\]")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
#: ``service list`` row: ``59 window: [android.view.IWindowManager]``.
|
|
153
|
+
_SERVICE_LINE = re.compile(
|
|
154
|
+
r"^(?P<number>\d+)\s+(?P<name>\S+?):(?:\s+\[(?P<interface>[^\]]*)\])?\s*$"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
#: Canonical ``ps -A`` column names, from both modern and legacy headers.
|
|
158
|
+
_PS_COLUMNS = {
|
|
159
|
+
"user",
|
|
160
|
+
"pid",
|
|
161
|
+
"ppid",
|
|
162
|
+
"vsz",
|
|
163
|
+
"vsize",
|
|
164
|
+
"rss",
|
|
165
|
+
"wchan",
|
|
166
|
+
"addr",
|
|
167
|
+
"s",
|
|
168
|
+
"name",
|
|
169
|
+
"pcy",
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#: Evidence ``source`` ids, one per adb command family (``adb.<command>``).
|
|
173
|
+
EVIDENCE_DEVICES = "adb.devices"
|
|
174
|
+
EVIDENCE_GETPROP = "adb.getprop"
|
|
175
|
+
EVIDENCE_PACKAGES = "adb.pm_list_packages"
|
|
176
|
+
EVIDENCE_PROCESSES = "adb.ps"
|
|
177
|
+
EVIDENCE_SERVICES = "adb.service_list"
|
|
178
|
+
EVIDENCE_DUMPSYS_PACKAGE = "adb.dumpsys_package"
|
|
179
|
+
EVIDENCE_WINDOW = "adb.dumpsys_window"
|
|
180
|
+
EVIDENCE_INSTALL = "adb.install"
|
|
181
|
+
EVIDENCE_SCREENCAP = "adb.screencap"
|
|
182
|
+
EVIDENCE_BUGREPORT = "adb.bugreport"
|
|
183
|
+
|
|
184
|
+
#: Typed failure for every wrapper when adb is absent, with the install hint.
|
|
185
|
+
ADB_MISSING_MESSAGE = (
|
|
186
|
+
"adb is not on PATH; install Android platform-tools "
|
|
187
|
+
"(https://developer.android.com/tools/releases/platform-tools) "
|
|
188
|
+
"and make sure adb is on PATH"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
#: Build-identity whitelist for :func:`parse_getprop`.
|
|
192
|
+
_PROP_KEY_PREFIXES = ("ro.build.version.", "ro.product.")
|
|
193
|
+
_PROP_KEY_EXACT = ("ro.build.fingerprint",)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class RunnerOutcome(Protocol):
|
|
197
|
+
"""What a runner must return: ``subprocess.CompletedProcess``-shaped.
|
|
198
|
+
|
|
199
|
+
Attributes:
|
|
200
|
+
returncode: The adb process exit code (``0`` on success).
|
|
201
|
+
stdout: Everything adb printed (``str`` for text wrappers, ``bytes``
|
|
202
|
+
for :func:`screencap`).
|
|
203
|
+
stderr: Everything adb wrote to its error stream.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
returncode: int
|
|
207
|
+
stdout: Any
|
|
208
|
+
stderr: Any
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class Runner(Protocol):
|
|
212
|
+
"""The injectable stand-in for ``subprocess.run`` (see module docstring)."""
|
|
213
|
+
|
|
214
|
+
def __call__(self, command: tuple[str, ...], **kwargs: Any) -> RunnerOutcome: ...
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass(frozen=True)
|
|
218
|
+
class AdbDevice:
|
|
219
|
+
"""One row of ``adb devices -l`` output."""
|
|
220
|
+
|
|
221
|
+
serial: str
|
|
222
|
+
state: str
|
|
223
|
+
kind: str
|
|
224
|
+
model: str | None = None
|
|
225
|
+
device: str | None = None
|
|
226
|
+
|
|
227
|
+
def to_dict(self) -> dict[str, Any]:
|
|
228
|
+
data: dict[str, Any] = {
|
|
229
|
+
"serial": self.serial,
|
|
230
|
+
"state": self.state,
|
|
231
|
+
"kind": self.kind,
|
|
232
|
+
}
|
|
233
|
+
if self.model is not None:
|
|
234
|
+
data["model"] = self.model
|
|
235
|
+
if self.device is not None:
|
|
236
|
+
data["device"] = self.device
|
|
237
|
+
return data
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@dataclass(frozen=True)
|
|
241
|
+
class AdbDevicesResult:
|
|
242
|
+
"""Bounded, evidence-backed parse of one ``adb devices -l`` transcript."""
|
|
243
|
+
|
|
244
|
+
devices: tuple[AdbDevice, ...]
|
|
245
|
+
truncated: bool
|
|
246
|
+
warnings: list[str] = field(default_factory=list)
|
|
247
|
+
evidence: Evidence = field(
|
|
248
|
+
default_factory=lambda: Evidence(
|
|
249
|
+
source=EVIDENCE_DEVICES,
|
|
250
|
+
method="devices -l",
|
|
251
|
+
confidence=Confidence.UNKNOWN,
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def to_dict(self) -> dict[str, Any]:
|
|
256
|
+
return {
|
|
257
|
+
"devices": [device.to_dict() for device in self.devices],
|
|
258
|
+
"count": len(self.devices),
|
|
259
|
+
"truncated": self.truncated,
|
|
260
|
+
"warnings": list(self.warnings),
|
|
261
|
+
"evidence": self.evidence.to_dict(),
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@dataclass(frozen=True)
|
|
266
|
+
class AdbPackageEntry:
|
|
267
|
+
"""One row of ``pm list packages -3`` (path present on ``-f`` transcripts)."""
|
|
268
|
+
|
|
269
|
+
name: str
|
|
270
|
+
path: str | None = None
|
|
271
|
+
|
|
272
|
+
def to_dict(self) -> dict[str, Any]:
|
|
273
|
+
data: dict[str, Any] = {"name": self.name}
|
|
274
|
+
if self.path is not None:
|
|
275
|
+
data["path"] = self.path
|
|
276
|
+
return data
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@dataclass(frozen=True)
|
|
280
|
+
class AdbPackagesResult:
|
|
281
|
+
"""Bounded name -> path mapping from one ``pm list packages`` transcript."""
|
|
282
|
+
|
|
283
|
+
packages: tuple[AdbPackageEntry, ...]
|
|
284
|
+
truncated: bool
|
|
285
|
+
warnings: list[str] = field(default_factory=list)
|
|
286
|
+
evidence: Evidence = field(
|
|
287
|
+
default_factory=lambda: Evidence(
|
|
288
|
+
source=EVIDENCE_PACKAGES,
|
|
289
|
+
method="shell pm list packages -3",
|
|
290
|
+
confidence=Confidence.UNKNOWN,
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
def to_dict(self) -> dict[str, Any]:
|
|
295
|
+
return {
|
|
296
|
+
"packages": [package.to_dict() for package in self.packages],
|
|
297
|
+
"count": len(self.packages),
|
|
298
|
+
"truncated": self.truncated,
|
|
299
|
+
"warnings": list(self.warnings),
|
|
300
|
+
"evidence": self.evidence.to_dict(),
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@dataclass(frozen=True)
|
|
305
|
+
class AdbProcessEntry:
|
|
306
|
+
"""One row of ``ps -A`` output (rss in KiB, state the ``S`` letter)."""
|
|
307
|
+
|
|
308
|
+
user: str
|
|
309
|
+
pid: int
|
|
310
|
+
ppid: int | None
|
|
311
|
+
rss: int | None
|
|
312
|
+
state: str | None
|
|
313
|
+
name: str
|
|
314
|
+
|
|
315
|
+
def to_dict(self) -> dict[str, Any]:
|
|
316
|
+
return {
|
|
317
|
+
"user": self.user,
|
|
318
|
+
"pid": self.pid,
|
|
319
|
+
"ppid": self.ppid,
|
|
320
|
+
"rss": self.rss,
|
|
321
|
+
"state": self.state,
|
|
322
|
+
"name": self.name,
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@dataclass(frozen=True)
|
|
327
|
+
class AdbProcessesResult:
|
|
328
|
+
"""Bounded process list from one ``ps -A`` transcript."""
|
|
329
|
+
|
|
330
|
+
processes: tuple[AdbProcessEntry, ...]
|
|
331
|
+
truncated: bool
|
|
332
|
+
warnings: list[str] = field(default_factory=list)
|
|
333
|
+
evidence: Evidence = field(
|
|
334
|
+
default_factory=lambda: Evidence(
|
|
335
|
+
source=EVIDENCE_PROCESSES,
|
|
336
|
+
method="shell ps -A",
|
|
337
|
+
confidence=Confidence.UNKNOWN,
|
|
338
|
+
)
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
def to_dict(self) -> dict[str, Any]:
|
|
342
|
+
return {
|
|
343
|
+
"processes": [process.to_dict() for process in self.processes],
|
|
344
|
+
"count": len(self.processes),
|
|
345
|
+
"truncated": self.truncated,
|
|
346
|
+
"warnings": list(self.warnings),
|
|
347
|
+
"evidence": self.evidence.to_dict(),
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@dataclass(frozen=True)
|
|
352
|
+
class AdbServiceEntry:
|
|
353
|
+
"""One row of ``service list`` output."""
|
|
354
|
+
|
|
355
|
+
number: int
|
|
356
|
+
name: str
|
|
357
|
+
interface: str | None = None
|
|
358
|
+
|
|
359
|
+
def to_dict(self) -> dict[str, Any]:
|
|
360
|
+
data: dict[str, Any] = {"number": self.number, "name": self.name}
|
|
361
|
+
if self.interface:
|
|
362
|
+
data["interface"] = self.interface
|
|
363
|
+
return data
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@dataclass(frozen=True)
|
|
367
|
+
class AdbServicesResult:
|
|
368
|
+
"""Bounded binder-service listing from one ``service list`` transcript."""
|
|
369
|
+
|
|
370
|
+
services: tuple[AdbServiceEntry, ...]
|
|
371
|
+
truncated: bool
|
|
372
|
+
warnings: list[str] = field(default_factory=list)
|
|
373
|
+
evidence: Evidence = field(
|
|
374
|
+
default_factory=lambda: Evidence(
|
|
375
|
+
source=EVIDENCE_SERVICES,
|
|
376
|
+
method="shell service list",
|
|
377
|
+
confidence=Confidence.UNKNOWN,
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
def to_dict(self) -> dict[str, Any]:
|
|
382
|
+
return {
|
|
383
|
+
"services": [service.to_dict() for service in self.services],
|
|
384
|
+
"count": len(self.services),
|
|
385
|
+
"truncated": self.truncated,
|
|
386
|
+
"warnings": list(self.warnings),
|
|
387
|
+
"evidence": self.evidence.to_dict(),
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@dataclass(frozen=True)
|
|
392
|
+
class AdbPropsResult:
|
|
393
|
+
"""Whitelisted build identity from one ``getprop`` transcript."""
|
|
394
|
+
|
|
395
|
+
props: dict[str, str]
|
|
396
|
+
truncated: bool
|
|
397
|
+
warnings: list[str] = field(default_factory=list)
|
|
398
|
+
evidence: Evidence = field(
|
|
399
|
+
default_factory=lambda: Evidence(
|
|
400
|
+
source=EVIDENCE_GETPROP,
|
|
401
|
+
method="shell getprop",
|
|
402
|
+
confidence=Confidence.UNKNOWN,
|
|
403
|
+
)
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
def to_dict(self) -> dict[str, Any]:
|
|
407
|
+
return {
|
|
408
|
+
"props": dict(self.props),
|
|
409
|
+
"count": len(self.props),
|
|
410
|
+
"truncated": self.truncated,
|
|
411
|
+
"warnings": list(self.warnings),
|
|
412
|
+
"evidence": self.evidence.to_dict(),
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@dataclass(frozen=True)
|
|
417
|
+
class AdbPackageInfoResult:
|
|
418
|
+
"""Package identity decoded from one ``dumpsys package`` transcript."""
|
|
419
|
+
|
|
420
|
+
package: str | None
|
|
421
|
+
version_name: str | None
|
|
422
|
+
installer: str | None
|
|
423
|
+
first_install_time: str | None
|
|
424
|
+
requested_permissions_count: int
|
|
425
|
+
warnings: list[str] = field(default_factory=list)
|
|
426
|
+
evidence: Evidence = field(
|
|
427
|
+
default_factory=lambda: Evidence(
|
|
428
|
+
source=EVIDENCE_DUMPSYS_PACKAGE,
|
|
429
|
+
method="shell dumpsys package",
|
|
430
|
+
confidence=Confidence.UNKNOWN,
|
|
431
|
+
)
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
def to_dict(self) -> dict[str, Any]:
|
|
435
|
+
return {
|
|
436
|
+
"package": self.package,
|
|
437
|
+
"version_name": self.version_name,
|
|
438
|
+
"installer": self.installer,
|
|
439
|
+
"first_install_time": self.first_install_time,
|
|
440
|
+
"requested_permissions_count": self.requested_permissions_count,
|
|
441
|
+
"warnings": list(self.warnings),
|
|
442
|
+
"evidence": self.evidence.to_dict(),
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
@dataclass(frozen=True)
|
|
447
|
+
class AdbWindowFocusResult:
|
|
448
|
+
"""Focused window/app decoded from one ``dumpsys window`` transcript."""
|
|
449
|
+
|
|
450
|
+
current_focus: str | None
|
|
451
|
+
focused_app: str | None
|
|
452
|
+
current_focus_component: str | None = None
|
|
453
|
+
focused_app_component: str | None = None
|
|
454
|
+
warnings: list[str] = field(default_factory=list)
|
|
455
|
+
evidence: Evidence = field(
|
|
456
|
+
default_factory=lambda: Evidence(
|
|
457
|
+
source=EVIDENCE_WINDOW,
|
|
458
|
+
method="shell dumpsys window",
|
|
459
|
+
confidence=Confidence.UNKNOWN,
|
|
460
|
+
)
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
def to_dict(self) -> dict[str, Any]:
|
|
464
|
+
return {
|
|
465
|
+
"current_focus": self.current_focus,
|
|
466
|
+
"focused_app": self.focused_app,
|
|
467
|
+
"current_focus_component": self.current_focus_component,
|
|
468
|
+
"focused_app_component": self.focused_app_component,
|
|
469
|
+
"warnings": list(self.warnings),
|
|
470
|
+
"evidence": self.evidence.to_dict(),
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
@dataclass(frozen=True)
|
|
475
|
+
class AdbInstallResult:
|
|
476
|
+
"""Record of one confirmed ``adb install`` (host sha256 taken first)."""
|
|
477
|
+
|
|
478
|
+
serial: str
|
|
479
|
+
apk: str
|
|
480
|
+
sha256: str
|
|
481
|
+
size: int
|
|
482
|
+
output: str
|
|
483
|
+
warnings: list[str] = field(default_factory=list)
|
|
484
|
+
evidence: Evidence = field(
|
|
485
|
+
default_factory=lambda: Evidence(
|
|
486
|
+
source=EVIDENCE_INSTALL,
|
|
487
|
+
method="install -r",
|
|
488
|
+
confidence=Confidence.CERTAIN,
|
|
489
|
+
)
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
def to_dict(self) -> dict[str, Any]:
|
|
493
|
+
return {
|
|
494
|
+
"serial": self.serial,
|
|
495
|
+
"apk": self.apk,
|
|
496
|
+
"sha256": self.sha256,
|
|
497
|
+
"size": self.size,
|
|
498
|
+
"output": self.output,
|
|
499
|
+
"warnings": list(self.warnings),
|
|
500
|
+
"evidence": self.evidence.to_dict(),
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
@dataclass(frozen=True)
|
|
505
|
+
class AdbScreencapResult:
|
|
506
|
+
"""Record of one ``exec-out screencap -p`` capture written to disk."""
|
|
507
|
+
|
|
508
|
+
serial: str
|
|
509
|
+
path: str
|
|
510
|
+
size: int
|
|
511
|
+
sha256: str
|
|
512
|
+
is_png: bool
|
|
513
|
+
warnings: list[str] = field(default_factory=list)
|
|
514
|
+
evidence: Evidence = field(
|
|
515
|
+
default_factory=lambda: Evidence(
|
|
516
|
+
source=EVIDENCE_SCREENCAP,
|
|
517
|
+
method="exec-out screencap -p",
|
|
518
|
+
confidence=Confidence.CERTAIN,
|
|
519
|
+
)
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
def to_dict(self) -> dict[str, Any]:
|
|
523
|
+
return {
|
|
524
|
+
"serial": self.serial,
|
|
525
|
+
"path": self.path,
|
|
526
|
+
"size": self.size,
|
|
527
|
+
"sha256": self.sha256,
|
|
528
|
+
"is_png": self.is_png,
|
|
529
|
+
"warnings": list(self.warnings),
|
|
530
|
+
"evidence": self.evidence.to_dict(),
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
@dataclass(frozen=True)
|
|
535
|
+
class AdbBugreportResult:
|
|
536
|
+
"""Record of one ``adb bugreport`` zip written into a caller directory."""
|
|
537
|
+
|
|
538
|
+
serial: str
|
|
539
|
+
path: str
|
|
540
|
+
size: int
|
|
541
|
+
warnings: list[str] = field(default_factory=list)
|
|
542
|
+
evidence: Evidence = field(
|
|
543
|
+
default_factory=lambda: Evidence(
|
|
544
|
+
source=EVIDENCE_BUGREPORT,
|
|
545
|
+
method="bugreport",
|
|
546
|
+
confidence=Confidence.CERTAIN,
|
|
547
|
+
)
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
def to_dict(self) -> dict[str, Any]:
|
|
551
|
+
return {
|
|
552
|
+
"serial": self.serial,
|
|
553
|
+
"path": self.path,
|
|
554
|
+
"size": self.size,
|
|
555
|
+
"warnings": list(self.warnings),
|
|
556
|
+
"evidence": self.evidence.to_dict(),
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _as_text(raw: Any) -> str:
|
|
561
|
+
"""Return ``raw`` as transcript text, refusing anything that is not ``str``."""
|
|
562
|
+
if not isinstance(raw, str):
|
|
563
|
+
raise AnalysisError(f"adb transcript must be a string, got {type(raw).__name__}")
|
|
564
|
+
return raw
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _note(warnings: list[str], message: str) -> None:
|
|
568
|
+
"""Append ``message``, bounded so one noisy transcript cannot flood memory."""
|
|
569
|
+
if len(warnings) < MAX_WARNINGS:
|
|
570
|
+
warnings.append(message)
|
|
571
|
+
elif len(warnings) == MAX_WARNINGS:
|
|
572
|
+
warnings.append(f"further warnings suppressed (limit {MAX_WARNINGS})")
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _evidence_for(
|
|
576
|
+
source: str,
|
|
577
|
+
method: str,
|
|
578
|
+
count: int,
|
|
579
|
+
warnings: list[str],
|
|
580
|
+
detail: str | None = None,
|
|
581
|
+
) -> Evidence:
|
|
582
|
+
"""Build the parse's evidence record.
|
|
583
|
+
|
|
584
|
+
Direct, unambiguous rows parsed from a transcript are ``CERTAIN``; rows
|
|
585
|
+
parsed *with* warnings drop to ``MEDIUM``; an empty parse reports
|
|
586
|
+
``UNKNOWN`` rather than dressing absence up as a finding.
|
|
587
|
+
"""
|
|
588
|
+
if count <= 0:
|
|
589
|
+
confidence = Confidence.UNKNOWN
|
|
590
|
+
elif warnings:
|
|
591
|
+
confidence = Confidence.MEDIUM
|
|
592
|
+
else:
|
|
593
|
+
confidence = Confidence.CERTAIN
|
|
594
|
+
if detail is None:
|
|
595
|
+
detail = f"{count} record{'s' if count == 1 else ''} parsed" if count else "nothing parsed"
|
|
596
|
+
return Evidence(
|
|
597
|
+
source=source,
|
|
598
|
+
method=method,
|
|
599
|
+
confidence=confidence,
|
|
600
|
+
detail=detail,
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _check_timeout(value: Any) -> float:
|
|
605
|
+
"""Validate one wall-clock budget; every wrapper shares the bound."""
|
|
606
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
607
|
+
raise AnalysisError(f"timeout must be a number of seconds, got {value!r}")
|
|
608
|
+
timeout = float(value)
|
|
609
|
+
if not 1.0 <= timeout <= MAX_TIMEOUT:
|
|
610
|
+
raise AnalysisError(
|
|
611
|
+
f"timeout {timeout} is out of range; expected 1 .. {MAX_TIMEOUT} seconds"
|
|
612
|
+
)
|
|
613
|
+
return timeout
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _check_serial(serial: Any) -> str:
|
|
617
|
+
"""Validate one device serial before it reaches argv."""
|
|
618
|
+
if not isinstance(serial, str):
|
|
619
|
+
raise AnalysisError(f"serial must be a string, got {type(serial).__name__}")
|
|
620
|
+
text = serial.strip()
|
|
621
|
+
if not text:
|
|
622
|
+
raise AnalysisError("serial must not be empty")
|
|
623
|
+
if len(text) > 256:
|
|
624
|
+
raise AnalysisError(f"serial is {len(text)} characters long; expected at most 256")
|
|
625
|
+
if not _SERIAL_CHARS.match(text):
|
|
626
|
+
raise AnalysisError(
|
|
627
|
+
f"serial {text!r} contains characters adb does not accept; "
|
|
628
|
+
"expected alphanumerics plus . _ : -"
|
|
629
|
+
)
|
|
630
|
+
return text
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _tail(text: str, limit: int = 400) -> str:
|
|
634
|
+
"""Return the last ``limit`` characters of ``text``, whitespace-flattened."""
|
|
635
|
+
flat = " ".join(text.split())
|
|
636
|
+
return flat[-limit:] if len(flat) > limit else flat
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _adb_path() -> str | None:
|
|
640
|
+
"""Probe ``PATH`` for adb exactly once per call; nothing else can fail."""
|
|
641
|
+
return shutil.which("adb")
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _require_adb() -> None:
|
|
645
|
+
"""Raise the typed skip-gate failure when adb is absent."""
|
|
646
|
+
if _adb_path() is None:
|
|
647
|
+
raise AnalysisError(ADB_MISSING_MESSAGE)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _run_adb(
|
|
651
|
+
args: Sequence[str],
|
|
652
|
+
*,
|
|
653
|
+
timeout: float,
|
|
654
|
+
runner: Runner | None = None,
|
|
655
|
+
binary: bool = False,
|
|
656
|
+
) -> tuple[int, str | bytes, str]:
|
|
657
|
+
"""Run ``adb ...`` once, bounded, and return ``(returncode, stdout, stderr)``.
|
|
658
|
+
|
|
659
|
+
``binary=True`` captures raw bytes (:func:`screencap`); every other caller
|
|
660
|
+
gets text. With the default runner the typed adb-missing failure fires
|
|
661
|
+
before anything is spawned; an injected ``runner`` stands in for
|
|
662
|
+
``subprocess.run`` entirely, so the PATH probe is skipped and the pipeline
|
|
663
|
+
runs offline against canned output (its own ``FileNotFoundError`` still
|
|
664
|
+
surfaces as the same typed error).
|
|
665
|
+
"""
|
|
666
|
+
if runner is None:
|
|
667
|
+
_require_adb()
|
|
668
|
+
checked = _check_timeout(timeout)
|
|
669
|
+
command = ("adb", *args)
|
|
670
|
+
execute = runner if runner is not None else subprocess.run
|
|
671
|
+
try:
|
|
672
|
+
outcome = execute(
|
|
673
|
+
command,
|
|
674
|
+
capture_output=True,
|
|
675
|
+
text=not binary,
|
|
676
|
+
timeout=checked,
|
|
677
|
+
)
|
|
678
|
+
except subprocess.TimeoutExpired as exc:
|
|
679
|
+
raise AnalysisError(
|
|
680
|
+
f"adb {args[0]} exceeded its timeout of {checked} seconds and was "
|
|
681
|
+
"terminated; raise timeout or check the device state with adb devices"
|
|
682
|
+
) from exc
|
|
683
|
+
except FileNotFoundError as exc:
|
|
684
|
+
raise AnalysisError(
|
|
685
|
+
f"{ADB_MISSING_MESSAGE}; the runner raised FileNotFoundError, so the "
|
|
686
|
+
"adb executable vanished between the PATH probe and the call"
|
|
687
|
+
) from exc
|
|
688
|
+
returncode = getattr(outcome, "returncode", None)
|
|
689
|
+
stdout = getattr(outcome, "stdout", None)
|
|
690
|
+
stderr = getattr(outcome, "stderr", None)
|
|
691
|
+
if not isinstance(returncode, int):
|
|
692
|
+
raise AnalysisError(
|
|
693
|
+
"the runner outcome violates the injection contract: returncode must "
|
|
694
|
+
"be an int"
|
|
695
|
+
)
|
|
696
|
+
if binary and not isinstance(stdout, bytes):
|
|
697
|
+
raise AnalysisError(
|
|
698
|
+
"the runner outcome violates the injection contract: binary runs "
|
|
699
|
+
"(screencap) must return stdout as bytes"
|
|
700
|
+
)
|
|
701
|
+
if not binary and isinstance(stdout, bytes):
|
|
702
|
+
stdout = stdout.decode("utf-8", errors="replace")
|
|
703
|
+
stderr_text = stderr if isinstance(stderr, str) else str(stderr or "")
|
|
704
|
+
return returncode, stdout if stdout is not None else "", stderr_text
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _check_exit(command: tuple[str, ...], returncode: int, stderr: str) -> None:
|
|
708
|
+
"""Turn a non-zero adb exit into a typed error naming what adb said."""
|
|
709
|
+
if returncode == 0:
|
|
710
|
+
return
|
|
711
|
+
spoken = _tail(stderr) or "no stderr output"
|
|
712
|
+
raise AnalysisError(f"adb command {' '.join(command)!r} exited with code {returncode}: {spoken}")
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _sha256_file(path: Path) -> tuple[str, int]:
|
|
716
|
+
"""Stream ``path`` and return ``(sha256 hex digest, size in bytes)``."""
|
|
717
|
+
digest = hashlib.sha256()
|
|
718
|
+
size = 0
|
|
719
|
+
with path.open("rb") as handle:
|
|
720
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
721
|
+
digest.update(chunk)
|
|
722
|
+
size += len(chunk)
|
|
723
|
+
return digest.hexdigest(), size
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _device_kind(serial: str, device: str | None) -> str:
|
|
727
|
+
"""Classify a serial as ``emulator`` or ``physical``.
|
|
728
|
+
|
|
729
|
+
The emulator's own serial prefix (``emulator-5554``) and the ``device:``
|
|
730
|
+
codename it reports (``emu64xa``, ``emu64xe``) are the two signals adb
|
|
731
|
+
itself gives us; anything else is treated as a physical device.
|
|
732
|
+
"""
|
|
733
|
+
if serial.startswith("emulator-"):
|
|
734
|
+
return "emulator"
|
|
735
|
+
if (device or "").startswith("emu"):
|
|
736
|
+
return "emulator"
|
|
737
|
+
return "physical"
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def parse_devices(text: str) -> AdbDevicesResult:
|
|
741
|
+
"""Parse ``adb devices -l`` output into bounded typed rows.
|
|
742
|
+
|
|
743
|
+
Daemon chatter (``* daemon not running ...``, ``List of devices attached``)
|
|
744
|
+
is skipped; each remaining line is ``<serial> <state> key:value ...`` where
|
|
745
|
+
``model:`` and ``device:`` carry the marketing model and the codename.
|
|
746
|
+
Lines that parse are kept up to :data:`MAX_DEVICES`; anything past the cap
|
|
747
|
+
sets ``truncated`` and the parse never raises for transcript content.
|
|
748
|
+
"""
|
|
749
|
+
raw = _as_text(text)
|
|
750
|
+
devices: list[AdbDevice] = []
|
|
751
|
+
warnings: list[str] = []
|
|
752
|
+
truncated = False
|
|
753
|
+
for line in raw.splitlines():
|
|
754
|
+
stripped = line.strip()
|
|
755
|
+
if not stripped or stripped.startswith(_DEVICES_SKIP_PREFIXES):
|
|
756
|
+
continue
|
|
757
|
+
if len(devices) >= MAX_DEVICES:
|
|
758
|
+
truncated = True
|
|
759
|
+
_note(warnings, f"device limit {MAX_DEVICES} reached; further rows dropped")
|
|
760
|
+
break
|
|
761
|
+
fields = stripped.split()
|
|
762
|
+
if len(fields) < 2:
|
|
763
|
+
_note(warnings, f"unparsable device row skipped: {stripped!r}")
|
|
764
|
+
continue
|
|
765
|
+
serial, state = fields[0], fields[1]
|
|
766
|
+
model: str | None = None
|
|
767
|
+
device: str | None = None
|
|
768
|
+
for token in fields[2:]:
|
|
769
|
+
if token.startswith("model:"):
|
|
770
|
+
model = token[len("model:") :] or None
|
|
771
|
+
elif token.startswith("device:"):
|
|
772
|
+
device = token[len("device:") :] or None
|
|
773
|
+
devices.append(
|
|
774
|
+
AdbDevice(
|
|
775
|
+
serial=serial,
|
|
776
|
+
state=state,
|
|
777
|
+
kind=_device_kind(serial, device),
|
|
778
|
+
model=model,
|
|
779
|
+
device=device,
|
|
780
|
+
)
|
|
781
|
+
)
|
|
782
|
+
return AdbDevicesResult(
|
|
783
|
+
devices=tuple(devices),
|
|
784
|
+
truncated=truncated,
|
|
785
|
+
warnings=warnings,
|
|
786
|
+
evidence=_evidence_for(EVIDENCE_DEVICES, "devices -l", len(devices), warnings),
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def parse_packages(text: str) -> AdbPackagesResult:
|
|
791
|
+
"""Parse ``pm list packages -3`` into a bounded name -> path mapping.
|
|
792
|
+
|
|
793
|
+
Bare transcripts give ``package:<name>`` rows with no path; ``pm list
|
|
794
|
+
packages -f -3`` transcripts give ``package:<apk>=<name>`` rows whose path
|
|
795
|
+
becomes :attr:`AdbPackageEntry.path`. Junk lines become warnings, never
|
|
796
|
+
errors.
|
|
797
|
+
"""
|
|
798
|
+
raw = _as_text(text)
|
|
799
|
+
packages: list[AdbPackageEntry] = []
|
|
800
|
+
warnings: list[str] = []
|
|
801
|
+
truncated = False
|
|
802
|
+
for line in raw.splitlines():
|
|
803
|
+
stripped = line.strip()
|
|
804
|
+
if not stripped:
|
|
805
|
+
continue
|
|
806
|
+
match = _PACKAGE_LINE.match(stripped)
|
|
807
|
+
if match is None:
|
|
808
|
+
_note(warnings, f"line without the 'package:' prefix skipped: {stripped!r}")
|
|
809
|
+
continue
|
|
810
|
+
if len(packages) >= MAX_PACKAGES:
|
|
811
|
+
truncated = True
|
|
812
|
+
_note(warnings, f"package limit {MAX_PACKAGES} reached; further rows dropped")
|
|
813
|
+
break
|
|
814
|
+
rest = match.group("rest").strip()
|
|
815
|
+
path: str | None = None
|
|
816
|
+
name = rest
|
|
817
|
+
if "=" in rest:
|
|
818
|
+
# The separator is the LAST '=': apk paths themselves carry
|
|
819
|
+
# base64 '~~x==' padding segments (``adb shell pm -f`` transcripts).
|
|
820
|
+
candidate_path, _, candidate_name = rest.rpartition("=")
|
|
821
|
+
path = candidate_path.strip() or None
|
|
822
|
+
name = candidate_name.strip()
|
|
823
|
+
if not name:
|
|
824
|
+
_note(warnings, f"package row with an empty name skipped: {stripped!r}")
|
|
825
|
+
continue
|
|
826
|
+
packages.append(AdbPackageEntry(name=name, path=path))
|
|
827
|
+
return AdbPackagesResult(
|
|
828
|
+
packages=tuple(packages),
|
|
829
|
+
truncated=truncated,
|
|
830
|
+
warnings=warnings,
|
|
831
|
+
evidence=_evidence_for(
|
|
832
|
+
EVIDENCE_PACKAGES, "shell pm list packages -3", len(packages), warnings
|
|
833
|
+
),
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _as_int_or_none(text: str) -> int | None:
|
|
838
|
+
"""Return ``text`` as an ``int`` when it is a plain decimal, else ``None``."""
|
|
839
|
+
return int(text) if text.isdigit() else None
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _ps_header_indexes(line: str) -> dict[str, int] | None:
|
|
843
|
+
"""Return canonical column indexes when ``line`` is a ``ps`` header row."""
|
|
844
|
+
tokens = line.split()
|
|
845
|
+
lowered = [token.lower() for token in tokens]
|
|
846
|
+
if "pid" not in lowered or "name" not in lowered:
|
|
847
|
+
return None
|
|
848
|
+
if not all(token in _PS_COLUMNS for token in lowered):
|
|
849
|
+
return None
|
|
850
|
+
return {token: position for position, token in enumerate(lowered)}
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def parse_processes(text: str) -> AdbProcessesResult:
|
|
854
|
+
"""Parse ``ps -A`` output into bounded typed rows.
|
|
855
|
+
|
|
856
|
+
The header row (``USER PID PPID VSZ RSS WCHAN ADDR S NAME`` modern, or the
|
|
857
|
+
legacy ``USER PID PPID VSIZE RSS WCHAN NAME``) is located and drives the
|
|
858
|
+
column mapping; a transcript without a header row falls back to the modern
|
|
859
|
+
layout with a warning. Process names may contain spaces, so everything from
|
|
860
|
+
the name column onward is joined back into one name.
|
|
861
|
+
"""
|
|
862
|
+
raw = _as_text(text)
|
|
863
|
+
processes: list[AdbProcessEntry] = []
|
|
864
|
+
warnings: list[str] = []
|
|
865
|
+
truncated = False
|
|
866
|
+
indexes: dict[str, int] | None = None
|
|
867
|
+
header_width = 0
|
|
868
|
+
for line in raw.splitlines():
|
|
869
|
+
stripped = line.rstrip("\r")
|
|
870
|
+
if not stripped.strip():
|
|
871
|
+
continue
|
|
872
|
+
if indexes is None:
|
|
873
|
+
candidate = _ps_header_indexes(stripped)
|
|
874
|
+
if candidate is not None:
|
|
875
|
+
indexes = candidate
|
|
876
|
+
header_width = max(candidate.values()) + 1
|
|
877
|
+
continue
|
|
878
|
+
if indexes is None:
|
|
879
|
+
indexes = {"user": 0, "pid": 1, "ppid": 2, "rss": 4, "s": 7, "name": 8}
|
|
880
|
+
header_width = 9
|
|
881
|
+
_note(warnings, "no ps header row found; assumed the modern ps -A column layout")
|
|
882
|
+
if stripped.upper().startswith("USER "):
|
|
883
|
+
continue
|
|
884
|
+
if len(processes) >= MAX_PROCESSES:
|
|
885
|
+
truncated = True
|
|
886
|
+
_note(warnings, f"process limit {MAX_PROCESSES} reached; further rows dropped")
|
|
887
|
+
break
|
|
888
|
+
fields = stripped.split()
|
|
889
|
+
if len(fields) < header_width:
|
|
890
|
+
_note(warnings, f"short ps row skipped: {stripped!r}")
|
|
891
|
+
continue
|
|
892
|
+
pid = _as_int_or_none(fields[indexes["pid"]])
|
|
893
|
+
if pid is None:
|
|
894
|
+
_note(warnings, f"ps row without a numeric pid skipped: {stripped!r}")
|
|
895
|
+
continue
|
|
896
|
+
name = " ".join(fields[indexes["name"] :]).strip()
|
|
897
|
+
if not name:
|
|
898
|
+
_note(warnings, f"ps row without a name skipped: pid {pid}")
|
|
899
|
+
continue
|
|
900
|
+
processes.append(
|
|
901
|
+
AdbProcessEntry(
|
|
902
|
+
user=fields[indexes.get("user", 0)],
|
|
903
|
+
pid=pid,
|
|
904
|
+
ppid=_as_int_or_none(fields[indexes["ppid"]]),
|
|
905
|
+
rss=_as_int_or_none(fields[indexes["rss"]]) if "rss" in indexes else None,
|
|
906
|
+
state=fields[indexes["s"]] if "s" in indexes else None,
|
|
907
|
+
name=name,
|
|
908
|
+
)
|
|
909
|
+
)
|
|
910
|
+
if indexes is None:
|
|
911
|
+
_note(warnings, "no ps rows found in the transcript")
|
|
912
|
+
return AdbProcessesResult(
|
|
913
|
+
processes=tuple(processes),
|
|
914
|
+
truncated=truncated,
|
|
915
|
+
warnings=warnings,
|
|
916
|
+
evidence=_evidence_for(EVIDENCE_PROCESSES, "shell ps -A", len(processes), warnings),
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def parse_services(text: str) -> AdbServicesResult:
|
|
921
|
+
"""Parse ``service list`` rows into bounded typed entries.
|
|
922
|
+
|
|
923
|
+
Each row is ``<number> <name>: [<interface>]``; the interface can be empty
|
|
924
|
+
(``[]``) or absent, and both are stored as ``None``. Unparsable lines become
|
|
925
|
+
warnings, never errors.
|
|
926
|
+
"""
|
|
927
|
+
raw = _as_text(text)
|
|
928
|
+
services: list[AdbServiceEntry] = []
|
|
929
|
+
warnings: list[str] = []
|
|
930
|
+
truncated = False
|
|
931
|
+
for line in raw.splitlines():
|
|
932
|
+
stripped = line.strip()
|
|
933
|
+
if not stripped:
|
|
934
|
+
continue
|
|
935
|
+
match = _SERVICE_LINE.match(stripped)
|
|
936
|
+
if match is None:
|
|
937
|
+
_note(warnings, f"unparsable service row skipped: {stripped!r}")
|
|
938
|
+
continue
|
|
939
|
+
if len(services) >= MAX_SERVICES:
|
|
940
|
+
truncated = True
|
|
941
|
+
_note(warnings, f"service limit {MAX_SERVICES} reached; further rows dropped")
|
|
942
|
+
break
|
|
943
|
+
services.append(
|
|
944
|
+
AdbServiceEntry(
|
|
945
|
+
number=int(match.group("number")),
|
|
946
|
+
name=match.group("name"),
|
|
947
|
+
interface=match.group("interface") or None,
|
|
948
|
+
)
|
|
949
|
+
)
|
|
950
|
+
return AdbServicesResult(
|
|
951
|
+
services=tuple(services),
|
|
952
|
+
truncated=truncated,
|
|
953
|
+
warnings=warnings,
|
|
954
|
+
evidence=_evidence_for(
|
|
955
|
+
EVIDENCE_SERVICES, "shell service list", len(services), warnings
|
|
956
|
+
),
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def _prop_whitelisted(key: str) -> bool:
|
|
961
|
+
"""True for the build-identity keys the lane keeps (everything else drops)."""
|
|
962
|
+
return key in _PROP_KEY_EXACT or key.startswith(_PROP_KEY_PREFIXES)
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def parse_getprop(text: str) -> AdbPropsResult:
|
|
966
|
+
"""Parse a ``getprop`` dump into the whitelisted build identity.
|
|
967
|
+
|
|
968
|
+
Both renderings adb prints are accepted: the bracketed
|
|
969
|
+
``[ro.build.version.release]: [14]`` form and the plain ``key: value`` form.
|
|
970
|
+
Keys outside ``ro.build.version.*``, ``ro.product.*`` and
|
|
971
|
+
``ro.build.fingerprint`` are dropped silently -- the whitelist is the point
|
|
972
|
+
-- and the kept keys are capped at :data:`MAX_PROPS`.
|
|
973
|
+
"""
|
|
974
|
+
raw = _as_text(text)
|
|
975
|
+
props: dict[str, str] = {}
|
|
976
|
+
warnings: list[str] = []
|
|
977
|
+
truncated = False
|
|
978
|
+
for line in raw.splitlines():
|
|
979
|
+
stripped = line.strip()
|
|
980
|
+
if not stripped:
|
|
981
|
+
continue
|
|
982
|
+
key: str | None = None
|
|
983
|
+
value: str | None = None
|
|
984
|
+
bracketed = _PROP_BRACKETED.match(stripped)
|
|
985
|
+
if bracketed is not None:
|
|
986
|
+
key = bracketed.group("key").strip()
|
|
987
|
+
value = bracketed.group("value").strip()
|
|
988
|
+
elif ":" in stripped:
|
|
989
|
+
candidate_key, _, candidate_value = stripped.partition(":")
|
|
990
|
+
key = candidate_key.strip()
|
|
991
|
+
value = candidate_value.strip()
|
|
992
|
+
if not key or value is None:
|
|
993
|
+
_note(warnings, f"unparsable getprop line skipped: {stripped!r}")
|
|
994
|
+
continue
|
|
995
|
+
if not _prop_whitelisted(key):
|
|
996
|
+
continue
|
|
997
|
+
if len(props) >= MAX_PROPS:
|
|
998
|
+
truncated = True
|
|
999
|
+
_note(warnings, f"property limit {MAX_PROPS} reached; further rows dropped")
|
|
1000
|
+
break
|
|
1001
|
+
props[key] = value
|
|
1002
|
+
ordered = {key: props[key] for key in sorted(props)}
|
|
1003
|
+
return AdbPropsResult(
|
|
1004
|
+
props=ordered,
|
|
1005
|
+
truncated=truncated,
|
|
1006
|
+
warnings=warnings,
|
|
1007
|
+
evidence=_evidence_for(
|
|
1008
|
+
EVIDENCE_GETPROP, "shell getprop", len(ordered), warnings
|
|
1009
|
+
),
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def parse_dumpsys_package(text: str) -> AdbPackageInfoResult:
|
|
1014
|
+
"""Decode one package's identity from a ``dumpsys package`` transcript.
|
|
1015
|
+
|
|
1016
|
+
Kept: the ``Package [name]`` header, ``versionName``, ``installer`` (or the
|
|
1017
|
+
legacy ``installerPackageName``), ``firstInstallTime`` and the count of the
|
|
1018
|
+
``requested permissions`` block. The count is what matters -- permission
|
|
1019
|
+
lists are unbounded, so they are never stored.
|
|
1020
|
+
"""
|
|
1021
|
+
raw = _as_text(text)
|
|
1022
|
+
package: str | None = None
|
|
1023
|
+
version_name: str | None = None
|
|
1024
|
+
installer: str | None = None
|
|
1025
|
+
first_install: str | None = None
|
|
1026
|
+
permission_count = 0
|
|
1027
|
+
in_requested = False
|
|
1028
|
+
warnings: list[str] = []
|
|
1029
|
+
for line in raw.splitlines():
|
|
1030
|
+
stripped = line.strip()
|
|
1031
|
+
header = _DUMPSYS_PACKAGE_HEADER.match(stripped)
|
|
1032
|
+
if header is not None and package is None:
|
|
1033
|
+
package = header.group("name")
|
|
1034
|
+
if not stripped:
|
|
1035
|
+
in_requested = False
|
|
1036
|
+
continue
|
|
1037
|
+
if stripped.endswith(":"):
|
|
1038
|
+
in_requested = stripped == "requested permissions:"
|
|
1039
|
+
continue
|
|
1040
|
+
if in_requested:
|
|
1041
|
+
permission_count += 1
|
|
1042
|
+
continue
|
|
1043
|
+
if "=" not in stripped:
|
|
1044
|
+
continue
|
|
1045
|
+
key, _, value = stripped.partition("=")
|
|
1046
|
+
key = key.strip()
|
|
1047
|
+
value = value.strip()
|
|
1048
|
+
if key == "versionName" and version_name is None:
|
|
1049
|
+
version_name = value
|
|
1050
|
+
elif key == "firstInstallTime" and first_install is None:
|
|
1051
|
+
first_install = value
|
|
1052
|
+
elif key == "installer" and installer is None:
|
|
1053
|
+
installer = value
|
|
1054
|
+
elif key == "installerPackageName" and installer is None:
|
|
1055
|
+
installer = value
|
|
1056
|
+
if permission_count == 0:
|
|
1057
|
+
_note(warnings, "no 'requested permissions' block found in the transcript")
|
|
1058
|
+
fields_found = sum(
|
|
1059
|
+
1 for value in (package, version_name, installer, first_install) if value is not None
|
|
1060
|
+
)
|
|
1061
|
+
return AdbPackageInfoResult(
|
|
1062
|
+
package=package,
|
|
1063
|
+
version_name=version_name,
|
|
1064
|
+
installer=installer,
|
|
1065
|
+
first_install_time=first_install,
|
|
1066
|
+
requested_permissions_count=permission_count,
|
|
1067
|
+
warnings=warnings,
|
|
1068
|
+
evidence=_evidence_for(
|
|
1069
|
+
EVIDENCE_DUMPSYS_PACKAGE,
|
|
1070
|
+
"shell dumpsys package",
|
|
1071
|
+
fields_found,
|
|
1072
|
+
warnings,
|
|
1073
|
+
detail=(
|
|
1074
|
+
f"package {package!r}; versionName {version_name!r}; "
|
|
1075
|
+
f"{permission_count} requested permissions"
|
|
1076
|
+
),
|
|
1077
|
+
),
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
def _focus_component(value: str) -> str | None:
|
|
1082
|
+
"""Extract ``package/component`` from a focus value, ``None`` when absent."""
|
|
1083
|
+
info = _COMPONENT_INFO.search(value)
|
|
1084
|
+
if info is not None:
|
|
1085
|
+
return info.group("component")
|
|
1086
|
+
token = _FOCUS_COMPONENT.search(value)
|
|
1087
|
+
if token is not None:
|
|
1088
|
+
return token.group("component")
|
|
1089
|
+
return None
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def parse_window_focus(text: str) -> AdbWindowFocusResult:
|
|
1093
|
+
"""Decode the focused window/app from a ``dumpsys window`` transcript.
|
|
1094
|
+
|
|
1095
|
+
``mCurrentFocus`` is the window with input focus; ``mFocusedApp`` is the
|
|
1096
|
+
activity the framework considers foreground. ``mFocusedWindow`` is kept as
|
|
1097
|
+
a fallback for builds that only print the latter pair. The raw values are
|
|
1098
|
+
preserved and ``package/component`` is extracted where adb's ``u0`` token
|
|
1099
|
+
or a ``ComponentInfo{...}`` carries one.
|
|
1100
|
+
"""
|
|
1101
|
+
raw = _as_text(text)
|
|
1102
|
+
current_focus: str | None = None
|
|
1103
|
+
focused_window: str | None = None
|
|
1104
|
+
focused_app: str | None = None
|
|
1105
|
+
warnings: list[str] = []
|
|
1106
|
+
for line in raw.splitlines():
|
|
1107
|
+
stripped = line.strip()
|
|
1108
|
+
if stripped.startswith("mCurrentFocus="):
|
|
1109
|
+
current_focus = stripped[len("mCurrentFocus=") :].strip()
|
|
1110
|
+
elif stripped.startswith("mFocusedWindow=") and focused_window is None:
|
|
1111
|
+
focused_window = stripped[len("mFocusedWindow=") :].strip()
|
|
1112
|
+
elif stripped.startswith("mFocusedApp="):
|
|
1113
|
+
focused_app = stripped[len("mFocusedApp=") :].strip()
|
|
1114
|
+
effective_focus = current_focus or focused_window
|
|
1115
|
+
if effective_focus is None and focused_app is None:
|
|
1116
|
+
_note(warnings, "no mCurrentFocus/mFocusedWindow/mFocusedApp line in the transcript")
|
|
1117
|
+
return AdbWindowFocusResult(
|
|
1118
|
+
current_focus=effective_focus,
|
|
1119
|
+
focused_app=focused_app,
|
|
1120
|
+
current_focus_component=_focus_component(effective_focus) if effective_focus else None,
|
|
1121
|
+
focused_app_component=_focus_component(focused_app) if focused_app else None,
|
|
1122
|
+
warnings=warnings,
|
|
1123
|
+
evidence=_evidence_for(
|
|
1124
|
+
EVIDENCE_WINDOW,
|
|
1125
|
+
"shell dumpsys window",
|
|
1126
|
+
sum(1 for value in (effective_focus, focused_app) if value is not None),
|
|
1127
|
+
warnings,
|
|
1128
|
+
),
|
|
1129
|
+
)
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def adb_available() -> bool:
|
|
1133
|
+
"""Report whether adb is on ``PATH``; the only gate a caller needs."""
|
|
1134
|
+
return _adb_path() is not None
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def list_devices(
|
|
1138
|
+
*,
|
|
1139
|
+
runner: Runner | None = None,
|
|
1140
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
1141
|
+
) -> AdbDevicesResult:
|
|
1142
|
+
"""Run ``adb devices -l`` and return the parsed, bounded device list."""
|
|
1143
|
+
code, out, err = _run_adb(("devices", "-l"), timeout=timeout, runner=runner)
|
|
1144
|
+
_check_exit(("adb", "devices", "-l"), code, err)
|
|
1145
|
+
return parse_devices(out if isinstance(out, str) else "")
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def inspect_device(
|
|
1149
|
+
serial: str,
|
|
1150
|
+
*,
|
|
1151
|
+
runner: Runner | None = None,
|
|
1152
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
1153
|
+
) -> AdbPropsResult:
|
|
1154
|
+
"""Run ``adb -s <serial> shell getprop`` and keep the build identity."""
|
|
1155
|
+
checked = _check_serial(serial)
|
|
1156
|
+
code, out, err = _run_adb(
|
|
1157
|
+
("-s", checked, "shell", "getprop"), timeout=timeout, runner=runner
|
|
1158
|
+
)
|
|
1159
|
+
_check_exit(("adb", "-s", checked, "shell", "getprop"), code, err)
|
|
1160
|
+
return parse_getprop(out if isinstance(out, str) else "")
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def list_packages(
|
|
1164
|
+
serial: str,
|
|
1165
|
+
*,
|
|
1166
|
+
runner: Runner | None = None,
|
|
1167
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
1168
|
+
) -> AdbPackagesResult:
|
|
1169
|
+
"""Run ``adb -s <serial> shell pm list packages -3`` (third-party only)."""
|
|
1170
|
+
checked = _check_serial(serial)
|
|
1171
|
+
code, out, err = _run_adb(
|
|
1172
|
+
("-s", checked, "shell", "pm", "list", "packages", "-3"),
|
|
1173
|
+
timeout=timeout,
|
|
1174
|
+
runner=runner,
|
|
1175
|
+
)
|
|
1176
|
+
_check_exit(("adb", "-s", checked, "shell", "pm list packages -3"), code, err)
|
|
1177
|
+
return parse_packages(out if isinstance(out, str) else "")
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def list_processes(
|
|
1181
|
+
serial: str,
|
|
1182
|
+
*,
|
|
1183
|
+
runner: Runner | None = None,
|
|
1184
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
1185
|
+
) -> AdbProcessesResult:
|
|
1186
|
+
"""Run ``adb -s <serial> shell ps -A`` and return the bounded process rows."""
|
|
1187
|
+
checked = _check_serial(serial)
|
|
1188
|
+
code, out, err = _run_adb(
|
|
1189
|
+
("-s", checked, "shell", "ps", "-A"), timeout=timeout, runner=runner
|
|
1190
|
+
)
|
|
1191
|
+
_check_exit(("adb", "-s", checked, "shell", "ps -A"), code, err)
|
|
1192
|
+
return parse_processes(out if isinstance(out, str) else "")
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def install_apk(
|
|
1196
|
+
serial: str,
|
|
1197
|
+
apk_path: str | Path,
|
|
1198
|
+
*,
|
|
1199
|
+
confirm: bool = False,
|
|
1200
|
+
runner: Runner | None = None,
|
|
1201
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
1202
|
+
) -> AdbInstallResult:
|
|
1203
|
+
"""Install ``apk_path`` on ``serial`` -- refuses without ``confirm=True``.
|
|
1204
|
+
|
|
1205
|
+
The host-side ``sha256`` and size are recorded *before* adb is invoked, so
|
|
1206
|
+
the result always names exactly which bytes the device was asked to accept.
|
|
1207
|
+
An adb failure (non-zero exit, or the ``Failure [...]`` line adb prints with
|
|
1208
|
+
a zero exit on some builds) raises a typed error carrying adb's output.
|
|
1209
|
+
"""
|
|
1210
|
+
checked = _check_serial(serial)
|
|
1211
|
+
if confirm is not True:
|
|
1212
|
+
raise AnalysisError(
|
|
1213
|
+
"install_apk requires confirm=True; refusing to change the device "
|
|
1214
|
+
"without an explicit confirmation (CLI: pass --yes)"
|
|
1215
|
+
)
|
|
1216
|
+
candidate = Path(apk_path).expanduser()
|
|
1217
|
+
if not candidate.exists():
|
|
1218
|
+
raise AnalysisError(f"APK not found: {candidate}")
|
|
1219
|
+
if not candidate.is_file():
|
|
1220
|
+
raise AnalysisError(f"not a regular file: {candidate}")
|
|
1221
|
+
size = candidate.stat().st_size
|
|
1222
|
+
if size <= 0:
|
|
1223
|
+
raise AnalysisError(f"APK {candidate} is empty")
|
|
1224
|
+
if size > MAX_APK_BYTES:
|
|
1225
|
+
raise AnalysisError(
|
|
1226
|
+
f"APK {candidate} is {size} bytes; the install budget is {MAX_APK_BYTES} bytes"
|
|
1227
|
+
)
|
|
1228
|
+
digest, measured = _sha256_file(candidate)
|
|
1229
|
+
command_tail = ("-s", checked, "install", "-r", str(candidate))
|
|
1230
|
+
code, out, err = _run_adb(command_tail, timeout=timeout, runner=runner)
|
|
1231
|
+
spoken = out if isinstance(out, str) else ""
|
|
1232
|
+
if code != 0 or "Failure" in spoken:
|
|
1233
|
+
detail = _tail(spoken) or _tail(err) or "no adb output"
|
|
1234
|
+
raise AnalysisError(
|
|
1235
|
+
f"adb install of {candidate} (sha256 {digest}) failed on {checked}: {detail}"
|
|
1236
|
+
)
|
|
1237
|
+
return AdbInstallResult(
|
|
1238
|
+
serial=checked,
|
|
1239
|
+
apk=str(candidate),
|
|
1240
|
+
sha256=digest,
|
|
1241
|
+
size=measured,
|
|
1242
|
+
output=_tail(spoken),
|
|
1243
|
+
evidence=Evidence(
|
|
1244
|
+
source=EVIDENCE_INSTALL,
|
|
1245
|
+
method="install -r",
|
|
1246
|
+
confidence=Confidence.CERTAIN,
|
|
1247
|
+
detail=f"sha256 {digest}",
|
|
1248
|
+
extra={"size": measured, "serial": checked},
|
|
1249
|
+
),
|
|
1250
|
+
)
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
def screencap(
|
|
1254
|
+
serial: str,
|
|
1255
|
+
out_path: str | Path,
|
|
1256
|
+
*,
|
|
1257
|
+
runner: Runner | None = None,
|
|
1258
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
1259
|
+
) -> AdbScreencapResult:
|
|
1260
|
+
"""Capture the screen as a PNG via ``exec-out screencap -p``.
|
|
1261
|
+
|
|
1262
|
+
``exec-out`` keeps the bytes binary-clean (the ``adb shell screencap`` path
|
|
1263
|
+
mangles ``\\n`` on some transports). The file is written, hashed and checked
|
|
1264
|
+
for the PNG magic; a non-PNG capture is written anyway and flagged as a
|
|
1265
|
+
warning rather than discarded.
|
|
1266
|
+
"""
|
|
1267
|
+
checked = _check_serial(serial)
|
|
1268
|
+
target = Path(out_path).expanduser()
|
|
1269
|
+
parent = target.parent
|
|
1270
|
+
if str(parent):
|
|
1271
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
1272
|
+
code, data, err = _run_adb(
|
|
1273
|
+
("-s", checked, "exec-out", "screencap", "-p"),
|
|
1274
|
+
timeout=timeout,
|
|
1275
|
+
runner=runner,
|
|
1276
|
+
binary=True,
|
|
1277
|
+
)
|
|
1278
|
+
_check_exit(("adb", "-s", checked, "exec-out screencap -p"), code, err)
|
|
1279
|
+
if not isinstance(data, bytes) or not data:
|
|
1280
|
+
raise AnalysisError(
|
|
1281
|
+
f"screencap on {checked} returned no bytes; is the screen accessible?"
|
|
1282
|
+
)
|
|
1283
|
+
target.write_bytes(data)
|
|
1284
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
1285
|
+
warnings: list[str] = []
|
|
1286
|
+
is_png = data[:8] == b"\x89PNG\r\n\x1a\n"
|
|
1287
|
+
if not is_png:
|
|
1288
|
+
_note(warnings, f"capture is not a PNG (starts {data[:8].hex()}); written anyway")
|
|
1289
|
+
return AdbScreencapResult(
|
|
1290
|
+
serial=checked,
|
|
1291
|
+
path=str(target),
|
|
1292
|
+
size=len(data),
|
|
1293
|
+
sha256=digest,
|
|
1294
|
+
is_png=is_png,
|
|
1295
|
+
warnings=warnings,
|
|
1296
|
+
evidence=Evidence(
|
|
1297
|
+
source=EVIDENCE_SCREENCAP,
|
|
1298
|
+
method="exec-out screencap -p",
|
|
1299
|
+
confidence=Confidence.CERTAIN,
|
|
1300
|
+
detail=f"sha256 {digest}",
|
|
1301
|
+
extra={"size": len(data), "serial": checked},
|
|
1302
|
+
),
|
|
1303
|
+
)
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
def bugreport(
|
|
1307
|
+
serial: str,
|
|
1308
|
+
out_dir: str | Path,
|
|
1309
|
+
*,
|
|
1310
|
+
runner: Runner | None = None,
|
|
1311
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
1312
|
+
) -> AdbBugreportResult:
|
|
1313
|
+
"""Write one ``adb bugreport`` zip into ``out_dir``.
|
|
1314
|
+
|
|
1315
|
+
The target file is ``codevanta-bugreport-<sanitised serial>.zip``; the
|
|
1316
|
+
directory is created when missing. A full bugreport on real hardware often
|
|
1317
|
+
needs more than the 20 s default, so pass a larger ``timeout`` (bounded by
|
|
1318
|
+
:data:`MAX_TIMEOUT`) for live devices.
|
|
1319
|
+
"""
|
|
1320
|
+
checked = _check_serial(serial)
|
|
1321
|
+
directory = Path(out_dir).expanduser()
|
|
1322
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
1323
|
+
safe = re.sub(r"[^A-Za-z0-9._-]", "_", checked)
|
|
1324
|
+
target = directory / f"codevanta-bugreport-{safe}.zip"
|
|
1325
|
+
command_tail = ("-s", checked, "bugreport", str(target))
|
|
1326
|
+
code, out, err = _run_adb(command_tail, timeout=timeout, runner=runner)
|
|
1327
|
+
_check_exit(("adb", "-s", checked, "bugreport"), code, err)
|
|
1328
|
+
if not target.exists():
|
|
1329
|
+
raise AnalysisError(
|
|
1330
|
+
f"adb bugreport on {checked} exited {code} but wrote nothing to {target}"
|
|
1331
|
+
)
|
|
1332
|
+
size = target.stat().st_size
|
|
1333
|
+
warnings: list[str] = []
|
|
1334
|
+
if size == 0:
|
|
1335
|
+
_note(warnings, f"bugreport zip at {target} is empty")
|
|
1336
|
+
return AdbBugreportResult(
|
|
1337
|
+
serial=checked,
|
|
1338
|
+
path=str(target),
|
|
1339
|
+
size=size,
|
|
1340
|
+
warnings=warnings,
|
|
1341
|
+
evidence=Evidence(
|
|
1342
|
+
source=EVIDENCE_BUGREPORT,
|
|
1343
|
+
method="bugreport",
|
|
1344
|
+
confidence=Confidence.CERTAIN,
|
|
1345
|
+
detail=f"{size} bytes",
|
|
1346
|
+
extra={"serial": checked},
|
|
1347
|
+
),
|
|
1348
|
+
)
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
|