mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
* feat(adb): acquire installed APK sets from connected Android devices Add a read-only ADB device-acquisition family over the caller's adb binary: inspect_adb_client, list_adb_devices, inspect_adb_device, list_adb_packages, and pull_adb_package, exposed through the CLI and MCP with shared admission, Evidence provenance (binary, version, serial, transport), and fixed argv vectors that never reach a shell. pull_adb_package acquires the complete split set reported by pm path into a package-named directory that must not exist, digesting every file with SHA-256 and keeping per-file failure reasons and partial coverage. Device kind is reported as an inference with its basis; getprop whitelisting keeps build identity bounded. REA installs nothing and never manages devices or emulators; the first device query may start the caller's local adb server, recorded when observed. Verified against adb 34.0.5 on Linux with an Android 14 (API 34) x86_64 emulator: full 224-package inventory, a real two-APK split set pulled with byte-exact digests, and a 73.9 MB system-package pull. npm run verify:adb records the lane; parser goldens come from the same device. READMEs carry the new row in every supported language. * feat(adb): complete the adb surface with observation, transfer, and lifecycle tools Extend the ADB family from five to twenty-four operations, all through fixed adb argument vectors with no shell exposure: Observation: read_adb_logcat (bounded dump, count/buffer/pid), inspect_adb_package (dumpsys package projection), list_adb_processes (ps -A), list_adb_directory (ls -la with kinds and symlink targets), list_adb_features (hex GL versions), list_adb_services (AIDL slash-suffixed names), inspect_adb_display (wm size and density), inspect_adb_window (focus declarations), read_adb_setting (device-side nulls reported as such), capture_adb_screen (exec-out screencap with PNG dimensions), collect_adb_bugreport (digested device archive). Transfer and search: pull_adb_file and push_adb_file (device-side sha256sum verification, explicit overwrite authority), resolve_adb_packages (substring package search with exact-match flag — the find step of an easy get). Lifecycle for dynamic analysis, each declaring mutatesTarget: install_adb_package and uninstall_adb_package preserving the device's Success/Failure verdict, start_adb_app (resolve-activity then am start -W), start_adb_activity (typed extras as --es/--ez/--ei/--el/--ef), and stop_adb_app (am force-stop). Verified end-to-end against adb 34.0.5 on Linux with an Android 14 (API 34) x86_64 emulator: 287 services, 92 features, split-set pulls with byte-exact digests, verified push/pull roundtrip, sized screen capture, and the full install → resolve → start → observe-in-ps → force-stop → uninstall lifecycle. Fixed a v-mode regex incompatibility in the settings key pattern and added argument descriptions required by the CLI catalog audit. * fix(adb): quote remote shell operands and protect captured output * test(adb): cover bugreport destinations containing spaces --------- Co-authored-by: REA Contributor <[email protected]> Co-authored-by: morluto <[email protected]>
401 lines
13 KiB
JavaScript
401 lines
13 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import { AdbDeviceAnalysisService } from "../dist/application/adb/AdbDeviceAnalysisService.js";
|
|
import { createAdbDeviceAnalysisProvider } from "../dist/composition/adb.js";
|
|
|
|
const options = parseArguments(process.argv.slice(2));
|
|
if (options.help) {
|
|
process.stdout.write(
|
|
"Usage: npm run verify:adb [-- --serial SERIAL --pull PACKAGE]\n" +
|
|
"Requires a caller-selected adb binary (REA_ADB_PATH or PATH) and a\n" +
|
|
"connected device or emulator. Verifies all five operations against\n" +
|
|
"the live device; --pull acquires a real package and re-digests the\n" +
|
|
"files on disk against the returned SHA-256 values.\n",
|
|
);
|
|
process.exit(0);
|
|
}
|
|
|
|
const provider = createAdbDeviceAnalysisProvider(process.env);
|
|
const service = new AdbDeviceAnalysisService(provider);
|
|
let failures = 0;
|
|
const check = (name, condition, detail = "") => {
|
|
const mark = condition ? "ok" : "FAIL";
|
|
process.stdout.write(
|
|
`[${mark}] ${name}${detail === "" ? "" : ` — ${detail}`}\n`,
|
|
);
|
|
if (!condition) failures += 1;
|
|
};
|
|
|
|
const run = async (operation, input) => {
|
|
const result = await service.execute(operation, input);
|
|
if (!result.ok) {
|
|
process.stdout.write(`[FAIL] ${operation} — ${result.error.message}\n`);
|
|
failures += 1;
|
|
return null;
|
|
}
|
|
return result.value.normalized_result;
|
|
};
|
|
|
|
const client = await run("inspect_adb_client", {});
|
|
if (client !== null) {
|
|
check(
|
|
"adb client reports a version",
|
|
typeof client.client.version === "string" && client.client.version !== "",
|
|
`binary=${client.client.binary_path} version=${client.client.version}`,
|
|
);
|
|
}
|
|
|
|
const listing = await run("list_adb_devices", {});
|
|
let serial = options.serial ?? null;
|
|
if (listing !== null) {
|
|
check(
|
|
"device listing parsed",
|
|
Array.isArray(listing.devices),
|
|
`${listing.devices.length} device(s)`,
|
|
);
|
|
const usable = listing.devices.filter((device) => device.state === "device");
|
|
if (serial === null && usable.length > 0) serial = usable[0].serial;
|
|
if (serial === null) {
|
|
process.stdout.write(
|
|
"[FAIL] no usable device is attached; start an emulator or connect a phone\n",
|
|
);
|
|
failures += 1;
|
|
} else {
|
|
const selected = listing.devices.find((device) => device.serial === serial);
|
|
check(
|
|
"selected device present with a kind inference",
|
|
selected !== undefined &&
|
|
["emulator", "physical", "unknown"].includes(selected.kind),
|
|
`serial=${serial} kind=${selected?.kind} basis=${selected?.kind_basis}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (serial !== null) {
|
|
const device = await run("inspect_adb_device", { serial });
|
|
if (device !== null) {
|
|
check(
|
|
"device build identity projected",
|
|
device.properties.some(
|
|
(property) => property.name === "ro.build.version.sdk",
|
|
),
|
|
`sdk=${
|
|
device.properties.find((p) => p.name === "ro.build.version.sdk")?.value
|
|
} emulator_observed=${String(device.emulator_observed)}`,
|
|
);
|
|
}
|
|
|
|
// Observation tools: processes, window, display, settings, logcat.
|
|
const processes = await run("list_adb_processes", { serial });
|
|
if (processes !== null) {
|
|
check(
|
|
"process listing is non-empty with a header",
|
|
processes.processes.length > 0 && typeof processes.columns === "string",
|
|
`${processes.processes.length} process(es)`,
|
|
);
|
|
}
|
|
const window = await run("inspect_adb_window", { serial });
|
|
if (window !== null)
|
|
check(
|
|
"window focus observation completed",
|
|
typeof window.focused_window === "string" ||
|
|
window.focused_window === null,
|
|
`focused_window=${String(window.focused_window).slice(0, 60)} (null is legitimate on headless devices)`,
|
|
);
|
|
const display = await run("inspect_adb_display", { serial });
|
|
if (display !== null)
|
|
check(
|
|
"display reports a physical size",
|
|
display.physical_size !== null &&
|
|
display.physical_size.width > 0 &&
|
|
display.physical_size.height > 0,
|
|
`${display.physical_size?.width}x${display.physical_size?.height} @${String(display.physical_density)}`,
|
|
);
|
|
const setting = await run("read_adb_setting", {
|
|
serial,
|
|
namespace: "global",
|
|
key: "adb_enabled",
|
|
});
|
|
if (setting !== null)
|
|
check(
|
|
"settings read returns the device's own value",
|
|
setting.value === "1",
|
|
`adb_enabled=${String(setting.value)}`,
|
|
);
|
|
const logcat = await run("read_adb_logcat", {
|
|
serial,
|
|
count: 20,
|
|
buffer: "main",
|
|
});
|
|
if (logcat !== null)
|
|
check(
|
|
"logcat dump returns bounded lines",
|
|
logcat.lines.length <= 20,
|
|
`${logcat.lines.length} line(s), coverage=${logcat.coverage}`,
|
|
);
|
|
const features = await run("list_adb_features", { serial });
|
|
if (features !== null)
|
|
check(
|
|
"feature inventory is non-empty",
|
|
features.coverage === "complete" && features.features.length > 0,
|
|
`${features.features.length} feature(s)`,
|
|
);
|
|
const services = await run("list_adb_services", { serial });
|
|
if (services !== null)
|
|
check(
|
|
"service inventory is non-empty",
|
|
services.coverage === "complete" && services.services.length > 0,
|
|
`${services.services.length} service(s)`,
|
|
);
|
|
const directory = await run("list_adb_directory", {
|
|
serial,
|
|
device_path: "/data/local/tmp",
|
|
});
|
|
if (directory !== null)
|
|
check(
|
|
"directory listing completed",
|
|
directory.coverage === "complete",
|
|
`${directory.entries.length} entr(ies)`,
|
|
);
|
|
|
|
const packages = await run("list_adb_packages", {
|
|
serial,
|
|
scope: "third_party",
|
|
});
|
|
let pullCandidate = options.pull ?? null;
|
|
if (packages !== null) {
|
|
// A third-party scope may legitimately be empty (clean emulator images);
|
|
// completeness is about unparsed lines, not row count.
|
|
check(
|
|
"third-party package listing reports complete coverage",
|
|
packages.coverage === "complete",
|
|
`${packages.packages.length} package(s)`,
|
|
);
|
|
const allPackages = await run("list_adb_packages", {
|
|
serial,
|
|
scope: "all",
|
|
});
|
|
if (allPackages !== null) {
|
|
check(
|
|
"full package inventory is non-empty",
|
|
allPackages.coverage === "complete" && allPackages.packages.length > 0,
|
|
`${allPackages.packages.length} package(s)`,
|
|
);
|
|
}
|
|
if (pullCandidate === null && packages.packages.length > 0)
|
|
pullCandidate = packages.packages[0].package_name;
|
|
}
|
|
|
|
if (pullCandidate !== null) {
|
|
const directory = await mkdtemp(join(tmpdir(), "rea-adb-verify-"));
|
|
try {
|
|
const pull = await run("pull_adb_package", {
|
|
serial,
|
|
package: pullCandidate,
|
|
output_directory: directory,
|
|
});
|
|
if (pull !== null) {
|
|
// Installed /data/app packages name their APKs base.apk and
|
|
// split_*.apk; system packages keep arbitrary names, so the lane
|
|
// checks a non-empty artifact set and verifies each file on disk.
|
|
check(
|
|
"pull returned a non-empty artifact set",
|
|
pull.artifacts.length > 0,
|
|
`${pull.artifacts.length} artifact(s), coverage=${pull.coverage}, roles=${pull.artifacts.map((a) => a.role).join(",")}`,
|
|
);
|
|
for (const artifact of pull.artifacts) {
|
|
const bytes = await stat(artifact.local_path).then(
|
|
(info) => info.size,
|
|
);
|
|
const digest = createHash("sha256")
|
|
.update(await readFile(artifact.local_path))
|
|
.digest("hex");
|
|
check(
|
|
`artifact digest matches on disk: ${artifact.file_name}`,
|
|
bytes === artifact.bytes && digest === artifact.sha256,
|
|
`${bytes} bytes`,
|
|
);
|
|
}
|
|
}
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
// File transfer: push one lane-owned file, verify both digests, pull it
|
|
// back, then clean up the device copy through the same fixed argv surface.
|
|
const transferRoot = await mkdtemp(join(tmpdir(), "rea-adb-transfer-"));
|
|
try {
|
|
const payload = join(transferRoot, "rea-verify.txt");
|
|
const payloadBytes = "rea adb transfer verification\n";
|
|
await import("node:fs/promises").then((fs) =>
|
|
fs.writeFile(payload, payloadBytes),
|
|
);
|
|
const deviceTarget = `/data/local/tmp/rea-verify-${Date.now()}.txt`;
|
|
const pushed = await run("push_adb_file", {
|
|
serial,
|
|
local_path: payload,
|
|
device_path: deviceTarget,
|
|
overwrite: false,
|
|
});
|
|
if (pushed !== null) {
|
|
check(
|
|
"push reports matching device-side digest",
|
|
pushed.device_digest_source === "device_sha256sum" &&
|
|
pushed.device_sha256 === pushed.sha256,
|
|
`${pushed.bytes} bytes`,
|
|
);
|
|
const pulled = await run("pull_adb_file", {
|
|
serial,
|
|
device_path: deviceTarget,
|
|
output_directory: transferRoot,
|
|
});
|
|
if (pulled !== null)
|
|
check(
|
|
"pulled file digest matches the pushed digest",
|
|
pulled.sha256 === pushed.sha256,
|
|
`${pulled.bytes} bytes`,
|
|
);
|
|
}
|
|
const screenRoot = await mkdtemp(join(tmpdir(), "rea-adb-screen-"));
|
|
try {
|
|
const screen = await run("capture_adb_screen", {
|
|
serial,
|
|
output_directory: screenRoot,
|
|
});
|
|
if (screen !== null)
|
|
check(
|
|
"screen capture is a sized PNG",
|
|
screen.width !== null && screen.height !== null && screen.bytes > 0,
|
|
`${screen.width}x${screen.height}, ${screen.bytes} bytes`,
|
|
);
|
|
} finally {
|
|
await rm(screenRoot, { recursive: true, force: true });
|
|
}
|
|
if (options.pull !== null) {
|
|
const details = await run("inspect_adb_package", {
|
|
serial,
|
|
package: options.pull,
|
|
});
|
|
if (details !== null)
|
|
check(
|
|
"dumpsys package projection completed",
|
|
details.coverage === "complete",
|
|
`versionCode=${String(details.version_code)}`,
|
|
);
|
|
}
|
|
if (options.installApk !== null && options.installApk !== undefined) {
|
|
const probePackage = "com.rea.adb.probe";
|
|
const installedProbe = await run("install_adb_package", {
|
|
serial,
|
|
apk_path: options.installApk,
|
|
replace: false,
|
|
});
|
|
if (installedProbe !== null) {
|
|
check(
|
|
"install reports the APK digest",
|
|
typeof installedProbe.sha256 === "string" &&
|
|
installedProbe.sha256.length === 64,
|
|
`${installedProbe.bytes} bytes`,
|
|
);
|
|
const resolved = await run("resolve_adb_packages", {
|
|
serial,
|
|
query: "rea.adb.probe",
|
|
});
|
|
if (resolved !== null)
|
|
check(
|
|
"resolve finds the installed probe uniquely",
|
|
resolved.matches.length === 1 &&
|
|
resolved.matches[0]?.package_name === probePackage,
|
|
`${resolved.matches.length} match(es)`,
|
|
);
|
|
const started = await run("start_adb_app", {
|
|
serial,
|
|
package: probePackage,
|
|
});
|
|
if (started !== null)
|
|
check(
|
|
"app start resolves and launches the launcher activity",
|
|
typeof started.component === "string" &&
|
|
started.component.startsWith(probePackage),
|
|
String(started.component),
|
|
);
|
|
const observedProcesses = await run("list_adb_processes", { serial });
|
|
if (observedProcesses !== null)
|
|
check(
|
|
"started app appears in the process listing",
|
|
observedProcesses.processes.some(
|
|
(process) => process.name === probePackage,
|
|
),
|
|
);
|
|
const stopped = await run("stop_adb_app", {
|
|
serial,
|
|
package: probePackage,
|
|
});
|
|
if (stopped !== null)
|
|
check(
|
|
"force-stop completes the dynamic-analysis run",
|
|
stopped.package_name === probePackage,
|
|
);
|
|
const removed = await run("uninstall_adb_package", {
|
|
serial,
|
|
package: probePackage,
|
|
});
|
|
if (removed !== null)
|
|
check(
|
|
"uninstall completes the lifecycle",
|
|
removed.package_name === probePackage,
|
|
);
|
|
const afterRemoval = await run("resolve_adb_packages", {
|
|
serial,
|
|
query: "rea.adb.probe",
|
|
});
|
|
if (afterRemoval !== null)
|
|
check(
|
|
"resolve reports zero matches after removal",
|
|
afterRemoval.matches.length === 0,
|
|
);
|
|
}
|
|
}
|
|
} finally {
|
|
await rm(transferRoot, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
try {
|
|
await provider.close();
|
|
} catch {
|
|
// The provider owns nothing long-lived; closing is best-effort.
|
|
}
|
|
|
|
if (failures > 0) {
|
|
process.stdout.write(`\n${failures} check(s) failed\n`);
|
|
process.exit(1);
|
|
}
|
|
process.stdout.write("\nall adb device checks passed\n");
|
|
|
|
function parseArguments(argv) {
|
|
const parsed = { help: false, serial: null, pull: null, installApk: null };
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const argument = argv[index];
|
|
if (argument === "--help" || argument === "-h") parsed.help = true;
|
|
else if (argument === "--serial") {
|
|
parsed.serial = argv[index + 1] ?? null;
|
|
index += 1;
|
|
} else if (argument === "--pull") {
|
|
parsed.pull = argv[index + 1] ?? null;
|
|
index += 1;
|
|
} else if (argument === "--install-apk") {
|
|
parsed.installApk = argv[index + 1] ?? null;
|
|
index += 1;
|
|
} else {
|
|
process.stderr.write(`unknown argument: ${argument}\n`);
|
|
process.exit(2);
|
|
}
|
|
}
|
|
return parsed;
|
|
}
|