Files
reverse-engineering-anything/src/adb/AdbShellOutput.ts
T
morluto ab44487ff8 fix(adb): preserve process names and pin fixture interpreters
Consume Toybox’s single NAME separator without stripping source whitespace. Keep MCP subprocess fixtures independent of ambient PATH and validate calls through the existing named-schema helper.

Validation: focused ADB provider/parser and MCP tests passed; no real Android device was used.
2026-10-26 23:50:10 +08:00

635 lines
21 KiB
TypeScript

import { collectNumericIssues } from "./AdbNumericIssues.js";
import type {
AdbDisplayNumericIssues,
AdbPackageNumericIssues,
AdbWindowFocusContext,
AdbWindowFocusObservation,
} from "../domain/adb/adbDeviceAnalysis.js";
import {
collectUnparsedLines,
type AdbUnparsedLines,
} from "./AdbUnparsedLines.js";
/**
* Parsers for fixed observation commands REA runs through `adb shell`.
*
* Formats follow toybox output on Android 8+ (verified against an Android 14
* emulator). Unparseable lines are sampled and counted rather than dropped so
* callers can see the format drift instead of silently losing rows.
*/
export interface AdbProcessEntry {
readonly user: string;
readonly pid: number;
readonly ppid: number | null;
readonly rss: number | null;
readonly state: string | null;
readonly name: string;
}
// Toybox emits one separator before the unpadded final NAME column.
// Remaining whitespace belongs to argv[0], not the table layout.
const PROCESS_LINE =
/^(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+) (.+)$/u;
const safeInteger = (text: string): number | null => {
const value = Number(text);
return Number.isSafeInteger(value) ? value : null;
};
/** Parse `ps -A` output; the header line is preserved verbatim as `columns`. */
export const parseProcessListOutput = (
stdout: string,
): {
columns: string | null;
processes: readonly AdbProcessEntry[];
unparsedLines: AdbUnparsedLines;
} => {
const processes: AdbProcessEntry[] = [];
const unparsed = collectUnparsedLines();
let columns: string | null = null;
for (const line of stdout.split(/\r?\n/u)) {
if (line.trim() === "") continue;
if (/^USER\s+PID\s+PPID\b/u.test(line)) {
columns = line.trim();
continue;
}
const match = PROCESS_LINE.exec(line);
if (match === null) {
unparsed.add(line);
continue;
}
const pid = safeInteger(match[2]!);
const ppid = safeInteger(match[3]!);
const rss = safeInteger(match[5]!);
if (pid === null || ppid === null || rss === null) unparsed.add(line);
if (pid === null) continue;
processes.push({
user: match[1]!,
pid,
ppid,
rss,
state: match[8]!,
name: match[9]!,
});
}
return { columns, processes, unparsedLines: unparsed.facts() };
};
export interface AdbFeatureEntry {
readonly name: string;
readonly version: number | null;
}
const FEATURE_LINE = /^feature:([^=\s]+)(?:=(0x[0-9a-fA-F]+|\d+))?$/u;
/** Parse `pm list features` output, including hex GL versions. */
export const parseFeatureListOutput = (
stdout: string,
): {
features: readonly AdbFeatureEntry[];
unparsedLines: AdbUnparsedLines;
} => {
const features: AdbFeatureEntry[] = [];
const unparsed = collectUnparsedLines();
for (const rawLine of stdout.split(/\r?\n/u)) {
const line = rawLine.trim();
if (line === "") continue;
const match = FEATURE_LINE.exec(line);
if (match === null) {
unparsed.add(rawLine);
continue;
}
const version = match[2] === undefined ? null : safeInteger(match[2]);
if (match[2] !== undefined && version === null) unparsed.add(rawLine);
features.push({
name: match[1]!,
version,
});
}
return { features, unparsedLines: unparsed.facts() };
};
export interface AdbServiceEntry {
readonly name: string;
readonly interface: string | null;
}
const SERVICE_LINE = /^\s*(?:\d+\s+)?([A-Za-z0-9._$/-]+):\s*\[([^\]]*)\]\s*$/u;
/** Parse `service list` output, tolerating numbered and plain formats. */
export const parseServiceListOutput = (
stdout: string,
): {
services: readonly AdbServiceEntry[];
unparsedLines: AdbUnparsedLines;
} => {
const services: AdbServiceEntry[] = [];
const unparsed = collectUnparsedLines();
for (const rawLine of stdout.split(/\r?\n/u)) {
const line = rawLine.trimEnd();
if (line.trim() === "" || /^Found \d+ services:/u.test(line.trim()))
continue;
const match = SERVICE_LINE.exec(line);
if (match === null) {
if (line.trim() !== "") unparsed.add(line.trim());
continue;
}
services.push({
name: match[1]!,
interface: match[2] === "" ? null : match[2]!,
});
}
return { services, unparsedLines: unparsed.facts() };
};
const SIZE_DECLARATION = /^(Physical|Override) size:(.*)$/u;
const SIZE_VALUE = /^\s+(\d+)x(\d+)$/u;
const DENSITY_DECLARATION = /^(Physical|Override) density:(.*)$/u;
const DENSITY_VALUE = /^\s+(\d+)$/u;
const DENSITY_BARE = /^(\d+)$/u;
type DisplayNumericIssue = AdbDisplayNumericIssues["sample"][number];
/** Parse declarations before admission so a malformed last declaration stays owned. */
export const parseWmSizeOutput = (
stdout: string,
): {
physical: { readonly width: number; readonly height: number } | null;
override: { readonly width: number; readonly height: number } | null;
physicalDeclared: boolean;
numericIssues: AdbDisplayNumericIssues;
} => {
let physical = null;
let override = null;
let physicalDeclared = false;
const issues = collectNumericIssues<DisplayNumericIssue>();
for (const rawLine of stdout.split(/\r?\n/u)) {
const declaration = SIZE_DECLARATION.exec(rawLine.trim());
if (declaration === null) continue;
const field =
declaration[1] === "Physical" ? "physical_size" : "override_size";
if (field === "physical_size") physicalDeclared = true;
const match = SIZE_VALUE.exec(declaration[2]!);
const width = match === null ? null : safeInteger(match[1]!);
const height = match === null ? null : safeInteger(match[2]!);
const reason =
match === null
? "invalid_format"
: width === null || height === null
? "unsafe_integer"
: width <= 0 || height <= 0
? "nonpositive"
: null;
const value =
reason === null && width !== null && height !== null
? { width, height }
: null;
if (reason !== null)
issues.add({ command: "wm size", field, reason, raw_line: rawLine });
if (field === "physical_size") physical = value;
else override = value;
}
return {
physical,
override,
physicalDeclared,
numericIssues: issues.facts(),
};
};
/** Labeled physical density dominates the first bare declaration, even when malformed. */
export const parseWmDensityOutput = (
stdout: string,
): {
physical: number | null;
override: number | null;
numericIssues: AdbDisplayNumericIssues;
} => {
let physical = null;
let override = null;
let bare = null;
let physicalDeclared = false;
let bareDeclared = false;
const issues = collectNumericIssues<DisplayNumericIssue>();
for (const rawLine of stdout.split(/\r?\n/u)) {
const trimmed = rawLine.trim();
const declaration = DENSITY_DECLARATION.exec(trimmed);
const bareMatch = declaration === null ? DENSITY_BARE.exec(trimmed) : null;
if (declaration === null && (bareMatch === null || bareDeclared)) continue;
const field =
declaration?.[1] === "Override" ? "override_density" : "physical_density";
const match =
declaration === null ? bareMatch : DENSITY_VALUE.exec(declaration[2]!);
const value = match === null ? null : safeInteger(match[1]!);
const reason =
match === null
? "invalid_format"
: value === null
? "unsafe_integer"
: value <= 0
? "nonpositive"
: null;
const admitted = reason === null ? value : null;
if (reason !== null)
issues.add({ command: "wm density", field, reason, raw_line: rawLine });
if (declaration === null) {
bareDeclared = true;
bare = admitted;
} else if (field === "physical_density") {
physicalDeclared = true;
physical = admitted;
} else override = admitted;
}
return {
physical: physicalDeclared ? physical : bare,
override,
numericIssues: issues.facts(),
};
};
/** Own declaration identity and omission before deriving a singular summary. */
export const parseWindowFocusOutput = (
stdout: string,
): AdbWindowFocusObservation => {
const contexts: AdbWindowFocusContext[] = [];
const observedLines: string[] = [];
let current: AdbWindowFocusContext | undefined;
for (const line of stdout.split(/\r?\n/u)) {
const trimmed = line.trim();
if (trimmed.startsWith("Display:")) {
current = {
scope: "display",
display_id:
/^Display:\s+mDisplayId=(-?[0-9]+)(?:\s|$)/u.exec(trimmed)?.[1] ??
null,
header_line: line,
declarations: [],
};
contexts.push(current);
observedLines.push(line);
continue;
}
const field = trimmed.startsWith("mCurrentFocus=")
? "mCurrentFocus"
: trimmed.startsWith("mFocusedApp=")
? "mFocusedApp"
: undefined;
if (field === undefined) continue;
if (current === undefined) {
current = {
scope: "unscoped",
display_id: null,
header_line: null,
declarations: [],
};
contexts.push(current);
}
const value = trimmed.slice(field.length + 1).trim();
current.declarations.push({
field,
value: value === "null" ? null : value,
raw_line: line,
});
observedLines.push(line);
}
const windows =
contexts.length === 1
? contexts[0]!.declarations.filter(
({ field }) => field === "mCurrentFocus",
)
: [];
const apps =
contexts.length === 1
? contexts[0]!.declarations.filter(({ field }) => field === "mFocusedApp")
: [];
const declared = contexts.some(({ declarations }) => declarations.length > 0);
const summary = !declared
? "unavailable"
: contexts.length === 1 && windows.length <= 1 && apps.length <= 1
? "single_context"
: "ambiguous";
return {
focused_window:
summary === "single_context" ? (windows[0]?.value ?? null) : null,
focused_app: summary === "single_context" ? (apps[0]?.value ?? null) : null,
focus_summary: summary,
focus_contexts: contexts,
observed_lines: observedLines,
};
};
export interface AdbPackageDetails {
readonly versionName: string | null;
/** Signed 64-bit long version code exactly as Package Manager prints it. */
readonly versionCode: string | null;
readonly firstInstallTime: string | null;
readonly lastUpdateTime: string | null;
readonly installerPackageName: string | null;
readonly userId: number | null;
readonly pkgFlags: string | null;
readonly requestedPermissions: number | null;
readonly numericIssues: AdbPackageNumericIssues;
}
const DETAIL_LINE = /^\s+([A-Za-z][A-Za-z0-9_]*)=(.*)$/u;
/**
* Package Manager prints its `String` fields by concatenation, so an absent
* `installerPackageName` appears as the literal text `null`
* (morluto/rea#3943). No installer can be named "null", so an empty value or
* that sentinel is the absence of the fact and becomes the nullable result's
* null. `versionName` keeps the printed text: a manifest may declare the
* string "null" on purpose, and the parser cannot tell the two apart.
*/
const absentInstallerOrName = (value: string): string | null =>
value === "" || value === "null" ? null : value;
/**
* Project `dumpsys package PACKAGE` output. Only the listed fields are
* extracted; the input is version-dependent and everything else is ignored
* by design rather than guessed.
*/
export const parseDumpsysPackageOutput = (
stdout: string,
): AdbPackageDetails => {
const details: {
versionName: string | null;
versionCode: string | null;
firstInstallTime: string | null;
lastUpdateTime: string | null;
installerPackageName: string | null;
userId: number | null;
pkgFlags: string | null;
} = {
versionName: null,
versionCode: null,
firstInstallTime: null,
lastUpdateTime: null,
installerPackageName: null,
userId: null,
pkgFlags: null,
};
const issues =
collectNumericIssues<AdbPackageNumericIssues["sample"][number]>();
let permissionsIndent: number | null = null;
let hasPermissionsSection = false;
let permissions = 0;
let parsedPackageAvailable: boolean | null = null;
for (const line of stdout.split(/\r?\n/u)) {
// Hidden system packages describe a separate section, not the requested package.
if (/^\s*Hidden system packages:\s*$/u.test(line)) break;
const permissionsHeader = /^(\s*)requested permissions:\s*$/u.exec(line);
if (permissionsHeader !== null) {
hasPermissionsSection = true;
permissionsIndent = permissionsHeader[1]!.length;
continue;
}
if (permissionsIndent !== null) {
const indentation = /^\s*/u.exec(line)![0].length;
if (line.trim() !== "" && indentation > permissionsIndent) {
// Android 10 and 11 may append ": restricted=true" to a row. The
// section is indentation-delimited; permission strings are arbitrary.
permissions += 1;
continue;
}
if (line.trim() === "" && indentation > permissionsIndent) continue;
permissionsIndent = null;
}
const match = DETAIL_LINE.exec(line);
if (match === null) continue;
const key = match[1]!;
// versionName is an arbitrary manifest string, printed verbatim by AOSP.
// Only the fields with their own structured grammar are normalized below.
const rawValue = match[2]!;
const value = rawValue.trim();
if (key === "pkg" && parsedPackageAvailable === null) {
if (value === "null") parsedPackageAvailable = false;
else if (value.startsWith("Package{") && value.endsWith("}"))
parsedPackageAvailable = true;
} else if (key === "versionName" && details.versionName === null)
details.versionName = rawValue;
else if (key === "versionCode" && details.versionCode === null) {
const parsed = /^(-?\d+)/u.exec(value);
if (parsed !== null) details.versionCode = parsed[1]!;
} else if (key === "firstInstallTime" && details.firstInstallTime === null)
details.firstInstallTime = value === "" ? null : value;
else if (key === "lastUpdateTime" && details.lastUpdateTime === null)
details.lastUpdateTime = value === "" ? null : value;
else if (
key === "installerPackageName" &&
details.installerPackageName === null
)
details.installerPackageName = absentInstallerOrName(value);
else if (key === "userId" || key === "appId") {
const parsed = /^(\d+)/u.exec(value);
const admitted = parsed === null ? null : safeInteger(parsed[1]!);
if (admitted === null)
issues.add({
command: "dumpsys package",
field: "user_id",
reason: parsed === null ? "invalid_format" : "unsafe_integer",
raw_line: line,
});
else if (details.userId === null) details.userId = admitted;
} else if (key === "pkgFlags" && details.pkgFlags === null)
details.pkgFlags = value === "" ? null : value;
}
// `dumpsys` omits this section both for known-empty inventories and when
// Package Manager has no parsed package; only the former is a known zero.
const requestedPermissions =
parsedPackageAvailable === false
? null
: hasPermissionsSection
? permissions
: parsedPackageAvailable === true
? 0
: null;
return { ...details, requestedPermissions, numericIssues: issues.facts() };
};
/**
* Parse `settings get` output. Android prints the literal string `null` for
* absent keys; that is reported as a device-side null observation.
*/
export const parseSettingsGetOutput = (
stdout: string,
): {
value: string | null;
deviceReportedNull: boolean;
} => {
const trimmed = stdout.replace(/\r?\n$/u, "");
if (trimmed === "null") return { value: null, deviceReportedNull: true };
return { value: trimmed, deviceReportedNull: false };
};
/** Parse the host-side completion line from `adb bugreport`. */
export const parseBugreportCopiedPath = (stdout: string): string | null => {
const match = /^Bug report copied to (.+)\r?$/mu.exec(stdout.trim());
return match === null ? null : match[1]!;
};
/**
* Read the digest from one `sha256sum FILE` line, or null when the output
* names none. GNU sha256sum starts the line with a backslash when it escaped
* the echoed name; the digest itself is never escaped.
*/
export const parseSha256sumOutput = (stdout: string): string | null =>
/^\\?([a-f0-9]{64}) [ *]/u.exec(stdout)?.[1] ?? null;
/** Read the device's `Failure [REASON]` verdict from package-change output. */
export const packageChangeFailureReason = (output: string): string | null =>
/Failure \[([^\]]+)\]/u.exec(output)?.[1] ?? null;
/**
* Parse `adb install`/`adb uninstall` output. adb can print `Failure [...]`
* with a zero exit code, so the text decides, not the exit status.
*/
export const parsePackageChangeOutput = (
stdout: string,
):
| { readonly status: "success" | "unknown"; readonly reason: null }
| { readonly status: "failure"; readonly reason: string } => {
const text = stdout.trim();
const failure = packageChangeFailureReason(text);
if (failure !== null) return { status: "failure", reason: failure };
if (/^Success$/mu.test(text)) return { status: "success", reason: null };
return { status: "unknown", reason: null };
};
/**
* Parse `cmd package resolve-activity --brief` output: the component is the
* last non-empty line.
*/
export const parseResolveActivityOutput = (stdout: string): string | null => {
const lines = stdout
.split(/\r?\n/u)
.map((line) => line.trim())
.filter((line) => line !== "" && !/^No activity found$/iu.test(line));
return lines.length === 0 ? null : lines[lines.length - 1]!;
};
/** Parsed facts and verdict from the full `am start -W` report. */
export interface AmStartOutput {
readonly status: "started" | "refused";
readonly startedActivity: string | null;
readonly activity: string | null;
readonly reportedStatus: string | null;
readonly reason: string | null;
}
/**
* Parse the complete `am start -W` report. `Starting:` records an attempt;
* hard errors and any non-ok Activity Manager status determine the verdict.
*/
export const parseAmStartOutput = (
stdout: string,
stderr = "",
): AmStartOutput => {
const normalizedStdout = stdout.replaceAll("\r\n", "\n");
const starting = /^Starting:[ \t]+Intent[ \t]*\{(.*)\}[ \t]*$/mu.exec(
normalizedStdout,
);
const startedActivity = starting?.[1]?.trim() ?? null;
const activityMatch = /^Activity:[ \t]*(.*)$/mu.exec(normalizedStdout);
const activity = activityMatch?.[1]?.trim() || null;
const statuses = [...normalizedStdout.matchAll(/^Status:[ \t]*(.*)$/gmu)].map(
(match) => match[1]!.trim(),
);
const nonOkStatus = statuses.find((status) => status.toLowerCase() !== "ok");
const reportedStatus = nonOkStatus ?? statuses[0] ?? null;
const errorLines = [normalizedStdout, stderr.replaceAll("\r\n", "\n")]
.flatMap((stream) => stream.split("\n"))
.map((line) => line.trim())
.map((line) => ({
line,
prefixedMatch: /^(?:Error|Exception)(?::|\b)(.*)$/iu.exec(line),
throwable:
/^(?:(?:[A-Za-z_$][A-Za-z0-9_$]*\.)+(?:[A-Za-z_$][A-Za-z0-9_$]*(?:Exception|Error)|Exception|Error)|[A-Za-z_$][A-Za-z0-9_$]*(?:Exception|Error))(?::\s*.*)?$/u.test(
line,
),
genericHeader:
/^(?:Error\s+type\s+\d+|Exception\s+occurred\s+while\s+executing\b.*)$/iu.test(
line,
),
}));
// Prefer the underlying Java throwable over the generic Activity Manager
// wrapper, regardless of which line or stream printed it first.
const error =
errorLines.find(({ throwable }) => throwable) ??
errorLines.find(
({ prefixedMatch, genericHeader }) =>
prefixedMatch !== null && !genericHeader,
) ??
errorLines.find(({ prefixedMatch }) => prefixedMatch !== null);
const errorReason =
error === undefined
? null
: error.throwable
? error.line
: error.prefixedMatch?.[1]?.trim() || error.line;
const statusReason =
nonOkStatus === ""
? "am start reported an empty status"
: nonOkStatus === undefined
? null
: `am start reported status: ${nonOkStatus}`;
const reason =
errorReason ??
statusReason ??
(/^Warning:/mu.test(normalizedStdout.trim())
? normalizedStdout.trim().slice(0, 512)
: null);
if (errorReason !== null || nonOkStatus !== undefined)
return {
status: "refused",
startedActivity,
activity,
reportedStatus,
reason: reason ?? "am start did not report a successful launch",
};
if (startedActivity !== null)
return {
status: "started",
startedActivity,
activity,
reportedStatus,
reason: null,
};
const text = `${normalizedStdout}\n${stderr.replaceAll("\r\n", "\n")}`.trim();
return {
status: "refused",
startedActivity,
activity,
reportedStatus,
reason:
reason ??
(text === "" ? "am start returned no activity" : text.slice(0, 512)),
};
};
const PNG_SIGNATURE = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]);
/** Read image dimensions from a PNG header, or null for other payloads. */
export const parsePngDimensions = (
bytes: Uint8Array,
): {
width: number | null;
height: number | null;
} => {
if (
bytes.length < 24 ||
!Buffer.from(bytes.subarray(0, 8)).equals(PNG_SIGNATURE)
)
return { width: null, height: null };
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
return {
width: view.getUint32(16),
height: view.getUint32(20),
};
};