Files
rea/src/adb/AdbProvider.test.ts
T
fef6f73064 feat(adb): complete ADB device support for Android reverse engineering (#1552)
* 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]>
2026-10-10 23:01:24 +08:00

961 lines
32 KiB
TypeScript

import { createHash } from "node:crypto";
import {
chmod,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { expect, onTestFinished, test } from "vitest";
import { AdbProvider } from "./AdbProvider.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { AdbRequest } from "../domain/adb/adbDeviceAnalysis.js";
const it = test.skipIf(process.platform === "win32");
const VERSION_BLOCK =
"printf 'Android Debug Bridge version 1.0.41\\nVersion 34.0.5-stub\\nInstalled as %s\\n' \"$0\"";
const writeAdbStub = async (
body: (root: string) => string,
): Promise<{ readonly binary: string; readonly root: string }> => {
const root = await mkdtemp(join("/tmp", "rea-adb-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const binary = join(root, "adb");
await writeFile(binary, `#!/bin/sh\n${body(root)}\n`, { mode: 0o755 });
await chmod(binary, 0o755);
return { binary, root };
};
const providerOver = (binary: string): AdbProvider =>
new AdbProvider({ environment: { REA_ADB_PATH: binary } });
const evidenceResult = async (
provider: AdbProvider,
request: AdbRequest,
): Promise<Record<string, unknown>> => {
const result = await provider.execute(request);
if (!result.ok) throw new Error(result.error.message);
return result.value.result as Record<string, unknown>;
};
const refusedBy = async (
provider: AdbProvider,
request: AdbRequest,
): Promise<ReturnType<typeof projectAnalysisError>> => {
const result = await provider.execute(request);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected a refusal");
return projectAnalysisError(result.error);
};
it("inspects the adb client without contacting the server", async () => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; fi`,
);
const result = await evidenceResult(providerOver(binary), {
operation: "inspect_adb_client",
input: {},
});
expect(result.client).toMatchObject({
path_source: "environment",
version: "34.0.5-stub",
installed_path: binary,
});
expect(result.contacted_adb_server).toBe(false);
});
it("lists devices with inferred kinds and observed daemon startup", async () => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "devices" ]; then
printf '* daemon not running; starting now at tcp:5037\\n* daemon started successfully\\n'
printf 'List of devices attached\\n'
printf 'emulator-5554 device product:sdk_gphone64_x86_64 model:sdk_gphone64_x86_64 device:emu64xa transport_id:1\\n'
exit 0
fi`,
);
const result = await evidenceResult(providerOver(binary), {
operation: "list_adb_devices",
input: {},
});
expect(result.may_have_started_adb_server).toBe(true);
expect(result.devices).toEqual([
{
serial: "emulator-5554",
state: "device",
transport: "tcp",
product: "sdk_gphone64_x86_64",
model: "sdk_gphone64_x86_64",
device: "emu64xa",
transport_id: 1,
kind: "emulator",
kind_basis: "emulator_serial_prefix",
},
]);
});
it("inspects a device through the getprop whitelist", async () => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "shell" ] && [ "$4" = "getprop" ]; then
printf '[ro.build.version.sdk]: [34]\\n[ro.kernel.qemu]: [1]\\n[sys.private]: [x]\\n'
exit 0
fi`,
);
const result = await evidenceResult(providerOver(binary), {
operation: "inspect_adb_device",
input: { serial: "emulator-5554" },
});
expect(result.emulator_observed).toBe(true);
expect(result.properties).toEqual([
{ name: "ro.build.version.sdk", value: "34" },
{ name: "ro.kernel.qemu", value: "1" },
]);
});
it("passes the caller scope as an exact fixed argv vector", async () => {
const { binary, root } = await writeAdbStub(
(root) => `printf '%s\\n' "$@" >> ${join(root, "argv.log")}
if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "shell" ]; then printf 'package:/data/app/x/base.apk=com.example\\n'; exit 0; fi`,
);
const result = await evidenceResult(providerOver(binary), {
operation: "list_adb_packages",
input: { serial: "emulator-5554", scope: "third_party" },
});
expect(result.coverage).toBe("complete");
expect(result.packages).toEqual([
{
package_name: "com.example",
base_apk_device_path: "/data/app/x/base.apk",
},
]);
const logged = (await readFile(join(root, "argv.log"), "utf8"))
.trim()
.split("\n");
expect(logged).toEqual([
"version",
"-s",
"emulator-5554",
"shell",
"pm",
"list",
"packages",
"-f",
"-3",
]);
});
const pullStub = (
paths: string,
pullBody: string,
): string => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$4" = "pm" ] && [ "$5" = "path" ]; then
printf '%s\\n' ${paths}
exit 0
fi
if [ "$1" = "-s" ] && [ "$3" = "pull" ]; then
${pullBody}
exit 0
fi
printf 'unexpected arguments: %s\\n' "$@"
exit 1`;
/** Stub dispatching one canned stdout per first shell word (and optional second). */
const shellStub = (
dispatch: Readonly<Record<string, string>>,
): ((root: string) => string) => {
const cases = Object.entries(dispatch)
.map(([key, output]) => {
const [first, second] = key.split(":");
const guard =
second === undefined
? `[ "$4" = "${first}" ]`
: `[ "$4" = "${first}" ] && [ "$5" = "${second}" ]`;
return ` if ${guard}; then printf '%b' '${output.replace(/'/gu, "'\\''")}'; exit 0; fi`;
})
.join("\n");
return () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "shell" ]; then
${cases}
printf 'unexpected shell command: %s %s %s\\n' "$4" "$5" "$6" >&2
exit 1
fi
printf 'unexpected arguments: %s\\n' "$@"
exit 1`;
};
it("pulls a complete split set with digests into a new package directory", async () => {
const { binary, root } = await writeAdbStub(() =>
pullStub(
"'package:/data/app/~~a==/com.example.b==/base.apk' 'package:/data/app/~~a==/com.example.b==/split_config.en.apk'",
` printf 'apk-bytes-for-testing' > "$5"
printf '1 file pulled, 0 skipped.\\n'`,
),
);
const output = join(root, "pulls");
const result = await evidenceResult(providerOver(binary), {
operation: "pull_adb_package",
input: {
serial: "emulator-5554",
package: "com.example",
output_directory: output,
},
});
expect(result.coverage).toBe("complete");
expect(result.failures).toEqual([]);
const artifacts = result.artifacts as {
file_name: string;
local_path: string;
sha256: string;
role: string;
}[];
expect(
artifacts.map((artifact) => [artifact.file_name, artifact.role]),
).toEqual([
["base.apk", "base"],
["split_config.en.apk", "split"],
]);
const digest = createHash("sha256")
.update("apk-bytes-for-testing")
.digest("hex");
for (const artifact of artifacts) expect(artifact.sha256).toBe(digest);
const pulled = await readFile(
join(output, "com.example", "base.apk"),
"utf8",
);
expect(pulled).toBe("apk-bytes-for-testing");
});
it("keeps successful artifacts when one split pull fails", async () => {
const { binary, root } = await writeAdbStub(() =>
pullStub(
"'package:/data/app/y/base.apk' 'package:/data/app/y/split_dpi.apk'",
`case "$4" in
*/split_dpi.apk) printf 'error: closed\\n' >&2; exit 1;;
*) printf 'apk-bytes-for-testing' > "$5"; printf '1 file pulled.\\n';;
esac`,
),
);
const result = await evidenceResult(providerOver(binary), {
operation: "pull_adb_package",
input: {
serial: "emulator-5554",
package: "com.partial",
output_directory: join(root, "pulls"),
},
});
expect(result.coverage).toBe("partial");
expect(result.artifacts).toHaveLength(1);
expect(result.failures).toEqual([
{
device_path: "/data/app/y/split_dpi.apk",
stage: "pull",
message: expect.stringContaining("closed") as unknown,
},
]);
});
it("refuses to reuse an existing package output directory", async () => {
const { binary, root } = await writeAdbStub(() =>
pullStub("'package:/data/app/z/base.apk'", ` printf 'x' > "$5"`),
);
const output = join(root, "existing");
await mkdir(join(output, "com.example"), { recursive: true });
const provider = providerOver(binary);
const result = await provider.execute({
operation: "pull_adb_package",
input: {
serial: "emulator-5554",
package: "com.example",
output_directory: output,
},
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(projectAnalysisError(result.error)).toMatchObject({
code: "unreadable_output",
});
expect(result.error.message).toContain("already exists");
});
it("reports an uninstalled package from empty pm path output", async () => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$4" = "pm" ] && [ "$5" = "path" ]; then exit 0; fi`,
);
const projected = await refusedBy(providerOver(binary), {
operation: "pull_adb_package",
input: {
serial: "emulator-5554",
package: "com.absent",
output_directory: "/tmp/rea-adb-absent",
},
});
expect(projected).toMatchObject({ code: "unsupported_target" });
expect(projected.message).toContain("no APK paths");
});
it.each([
["not found", "error: device 'emulator-9' not found", "not found"],
["offline", "error: device offline", "offline"],
["unauthorized", "error: device unauthorized", "unauthorized"],
] as const)(
"reports the %s device state as a target refusal",
async (_, stderr, excerpt) => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ]; then printf '%s\\n' "${stderr.replace(/'/gu, "'\\''")}" >&2; exit 1; fi`,
);
const projected = await refusedBy(providerOver(binary), {
operation: "inspect_adb_device",
input: { serial: "emulator-5554" },
});
expect(projected).toMatchObject({ code: "unsupported_target" });
expect(projected.message).toContain(excerpt);
},
);
it("reports a missing selected binary as a capability gap with recovery", async () => {
const provider = new AdbProvider({
environment: { REA_ADB_PATH: "/nonexistent/adb" },
});
const availability = await provider.inspectAvailability();
expect(availability).toMatchObject({ status: "unavailable" });
if (availability.status !== "unavailable") return;
expect(availability.diagnostics.remediation).toContain("REA_ADB_PATH");
const result = await provider.execute({
operation: "list_adb_devices",
input: {},
});
expect(result.ok).toBe(false);
if (result.ok) return;
const projected = projectAnalysisError(result.error);
expect(projected).toMatchObject({ code: "capability_unavailable" });
expect(projected.details).toMatchObject({
provider_id: "adb",
reason: expect.stringContaining("not found") as unknown,
});
});
it("cancels before spawning anything", async () => {
const { binary, root } = await writeAdbStub(
(root) => `: > ${join(root, "spawned")}
if [ "$1" = "version" ]; then ${VERSION_BLOCK}; fi`,
);
const controller = new AbortController();
controller.abort();
const cancelled = await providerOver(binary).execute(
{ operation: "list_adb_devices", input: {} },
{ signal: controller.signal },
);
expect(cancelled.ok).toBe(false);
if (cancelled.ok) return;
expect(projectAnalysisError(cancelled.error)).toMatchObject({
code: "cancelled",
});
expect(
await readFile(join(root, "spawned")).then(
() => true,
() => false,
),
).toBe(false);
});
it("reads a bounded logcat dump with the caller count and buffer", async () => {
const { binary } = await writeAdbStub(
shellStub({
logcat:
"08-10 13:00:00.000 I/Tag( 123): first\\n08-10 13:00:01.000 I/Tag( 123): second\\n",
}),
);
const result = await evidenceResult(providerOver(binary), {
operation: "read_adb_logcat",
input: { serial: "emulator-5554", count: 500, buffer: "main" },
});
expect(result.lines).toEqual([
"08-10 13:00:00.000 I/Tag( 123): first",
"08-10 13:00:01.000 I/Tag( 123): second",
]);
});
it("projects package details from dumpsys package output", async () => {
const { binary } = await writeAdbStub(
shellStub({
"dumpsys:package": `Packages:
Package [com.example] (abcdef):
userId=10234
versionName=1.2.3
versionCode=45 minSdk=24 targetSdk=34
firstInstallTime=2026-01-02 03:04:05
lastUpdateTime=2026-02-03 04:05:06
installerPackageName=com.android.vending
pkgFlags=[ HAS_CODE ALLOW_CLEAR_USER_DATA ]
requested permissions:
android.permission.INTERNET
android.permission.CAMERA
`,
}),
);
const result = await evidenceResult(providerOver(binary), {
operation: "inspect_adb_package",
input: { serial: "emulator-5554", package: "com.example" },
});
expect(result).toMatchObject({
version_name: "1.2.3",
version_code: 45,
first_install_time: "2026-01-02 03:04:05",
last_update_time: "2026-02-03 04:05:06",
installer_package_name: "com.android.vending",
user_id: 10234,
requested_permissions_count: 2,
coverage: "complete",
});
});
it("reports an uninstalled package from dumpsys refusal output", async () => {
const { binary } = await writeAdbStub(
shellStub({ "dumpsys:package": "Unable to find package: com.absent\n" }),
);
const projected = await refusedBy(providerOver(binary), {
operation: "inspect_adb_package",
input: { serial: "emulator-5554", package: "com.absent" },
});
expect(projected).toMatchObject({ code: "unsupported_target" });
});
it("pulls one device file with a digest", async () => {
const { binary, root } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "pull" ]; then printf 'file-bytes' > "$5"; exit 0; fi
exit 1`,
);
const output = join(root, "pulls");
const result = await evidenceResult(providerOver(binary), {
operation: "pull_adb_file",
input: {
serial: "emulator-5554",
device_path: "/data/local/tmp/notes.txt",
output_directory: output,
},
});
const digest = createHash("sha256").update("file-bytes").digest("hex");
expect(result).toMatchObject({
device_path: "/data/local/tmp/notes.txt",
local_path: join(output, "notes.txt"),
bytes: 10,
sha256: digest,
});
});
it("pushes a file and verifies the device-side digest", async () => {
const { binary, root } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$4" = "test" ]; then exit 1; fi
if [ "$1" = "-s" ] && [ "$3" = "push" ]; then exit 0; fi
if [ "$1" = "-s" ] && [ "$4" = "sha256sum" ]; then printf '%s %s\\n' "8748b5d17d1ebcec7d46674fa6a8b92c0f95598a552f27c1c8a132eb58c04043" "$5"; exit 0; fi
exit 1`,
);
const localFile = join(root, "payload.bin");
await writeFile(localFile, "push-payload");
const digest = createHash("sha256").update("push-payload").digest("hex");
const pushed = await evidenceResult(providerOver(binary), {
operation: "push_adb_file",
input: {
serial: "emulator-5554",
local_path: localFile,
device_path: "/data/local/tmp/payload.bin",
overwrite: false,
},
});
expect(pushed).toMatchObject({
bytes: 12,
sha256: digest,
device_sha256: digest,
overwritten: false,
device_digest_source: "device_sha256sum",
});
});
it("reports a digest mismatch after push as a changed artifact", async () => {
const { binary, root } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$4" = "test" ]; then exit 1; fi
if [ "$1" = "-s" ] && [ "$3" = "push" ]; then exit 0; fi
if [ "$1" = "-s" ] && [ "$4" = "sha256sum" ]; then printf '%s %s\\n' "$(printf 'a%.0s' 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64)" "$5"; exit 0; fi
exit 1`,
);
const localFile = join(root, "payload.bin");
await writeFile(localFile, "push-payload");
const projected = await refusedBy(providerOver(binary), {
operation: "push_adb_file",
input: {
serial: "emulator-5554",
local_path: localFile,
device_path: "/data/local/tmp/payload.bin",
overwrite: false,
},
});
expect(projected).toMatchObject({ code: "artifact_changed" });
});
it("refuses a push onto an existing device path without overwrite", async () => {
const { binary, root } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$4" = "test" ]; then exit 0; fi
exit 1`,
);
const localFile = join(root, "payload.bin");
await writeFile(localFile, "x");
const result = await providerOver(binary).execute({
operation: "push_adb_file",
input: {
serial: "emulator-5554",
local_path: localFile,
device_path: "/data/local/tmp/exists.bin",
overwrite: false,
},
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(projectAnalysisError(result.error)).toMatchObject({
code: "unreadable_output",
});
expect(result.error.message).toContain("overwrite");
});
it("reports a local push source that does not exist", async () => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
exit 1`,
);
const projected = await refusedBy(providerOver(binary), {
operation: "push_adb_file",
input: {
serial: "emulator-5554",
local_path: "/nonexistent/payload.bin",
device_path: "/data/local/tmp/payload.bin",
overwrite: false,
},
});
expect(projected).toMatchObject({ code: "invalid_request" });
});
it("captures the screen as a digested PNG with its dimensions", async () => {
const { binary, root } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "exec-out" ]; then
printf '\\211PNG\\r\\n\\032\\n\\0\\0\\0\\rIHDR\\0\\0\\0\\004\\0\\0\\0\\003'
exit 0
fi
exit 1`,
);
const output = join(root, "captures");
const result = await evidenceResult(providerOver(binary), {
operation: "capture_adb_screen",
input: { serial: "emulator-5554", output_directory: output },
});
expect(result).toMatchObject({ width: 4, height: 3 });
expect(
await readFile(join(output, "screen.png")).then(
(bytes) => bytes.subarray(1, 4).toString("latin1") === "PNG",
() => false,
),
).toBe(true);
});
it("lists processes, features, and services through fixed commands", async () => {
const { binary } = await writeAdbStub(
shellStub({
ps: "USER PID PPID VSZ RSS WCHAN ADDR S NAME\nu0_a1 1234 567 123456 7890 0 0 S com.example.app\n",
"pm:list":
"feature:android.hardware.camera\nfeature:android.hardware.nfc=1\n",
"service:list":
"Found 2 services:\n 1 package: [android.content.pm.IPackageManager]\n 2 activity: [android.app.IActivityManager]\n",
}),
);
const processes = await evidenceResult(providerOver(binary), {
operation: "list_adb_processes",
input: { serial: "emulator-5554" },
});
expect(processes.processes).toEqual([
{
user: "u0_a1",
pid: 1234,
ppid: 567,
rss: 7890,
state: "S",
name: "com.example.app",
},
]);
const features = await evidenceResult(providerOver(binary), {
operation: "list_adb_features",
input: { serial: "emulator-5554" },
});
expect(features.features).toEqual([
{ name: "android.hardware.camera", version: null },
{ name: "android.hardware.nfc", version: 1 },
]);
const services = await evidenceResult(providerOver(binary), {
operation: "list_adb_services",
input: { serial: "emulator-5554" },
});
expect(services.services).toEqual([
{ name: "package", interface: "android.content.pm.IPackageManager" },
{ name: "activity", interface: "android.app.IActivityManager" },
]);
});
it("lists a device directory with kinds and symlink targets", async () => {
const { binary } = await writeAdbStub(
shellStub({
ls: "total 4\n-rw-rw---- u0_a1 u0_a1 4096 2026-01-02 11:00 app.db\nlrwxrwxrwx root root 2026-01-01 10:00 lib -> /apex/com.android.art/lib\n",
}),
);
const result = await evidenceResult(providerOver(binary), {
operation: "list_adb_directory",
input: {
serial: "emulator-5554",
device_path: "/data/data/com.example.app",
},
});
expect(result.entries).toEqual([
{
name: "app.db",
kind: "file",
permissions: "-rw-rw----",
owner: "u0_a1",
group: "u0_a1",
bytes: 4096,
date: "2026-01-02 11:00",
link_target: null,
},
{
name: "lib",
kind: "symlink",
permissions: "lrwxrwxrwx",
owner: "root",
group: "root",
bytes: null,
date: "2026-01-01 10:00",
link_target: "/apex/com.android.art/lib",
},
]);
});
it("inspects the display and window focus", async () => {
const { binary } = await writeAdbStub(
shellStub({
"wm:size": "Physical size: 1080x2340\n",
"wm:density": "Physical density: 420\n",
"dumpsys:window":
" mCurrentFocus=Window{abcdef u0 com.example.app/com.example.app.MainActivity}\n",
}),
);
const display = await evidenceResult(providerOver(binary), {
operation: "inspect_adb_display",
input: { serial: "emulator-5554" },
});
expect(display.physical_size).toEqual({ width: 1080, height: 2340 });
expect(display.physical_density).toBe(420);
const window = await evidenceResult(providerOver(binary), {
operation: "inspect_adb_window",
input: { serial: "emulator-5554" },
});
expect(window.focused_window).toBe(
"Window{abcdef u0 com.example.app/com.example.app.MainActivity}",
);
});
it("reads one settings value and reports device-side nulls", async () => {
const { binary } = await writeAdbStub(shellStub({ "settings:get": "1\n" }));
const enabled = await evidenceResult(providerOver(binary), {
operation: "read_adb_setting",
input: {
serial: "emulator-5554",
namespace: "secure",
key: "adb_enabled",
},
});
expect(enabled.value).toBe("1");
const { binary: nullBinary } = await writeAdbStub(
shellStub({ "settings:get": "null\n" }),
);
const absent = await evidenceResult(providerOver(nullBinary), {
operation: "read_adb_setting",
input: {
serial: "emulator-5554",
namespace: "global",
key: "absent_key",
},
});
expect(absent.value).toBeNull();
expect(absent.device_reported_null).toBe(true);
});
it("resolves packages by case-insensitive substring", async () => {
const { binary } = await writeAdbStub(
shellStub({
"pm:list":
"package:/data/app/x/base.apk=com.whatsapp\\npackage:/data/app/y/base.apk=com.whatsapp.w4b\\npackage:/data/app/z/base.apk=com.example\\n",
}),
);
const result = await evidenceResult(providerOver(binary), {
operation: "resolve_adb_packages",
input: { serial: "emulator-5554", query: "WhatsApp" },
});
expect(result.matches).toEqual([
{
package_name: "com.whatsapp",
base_apk_device_path: "/data/app/x/base.apk",
},
{
package_name: "com.whatsapp.w4b",
base_apk_device_path: "/data/app/y/base.apk",
},
]);
expect(result.exact_match).toBe(false);
expect(result.coverage).toBe("complete");
});
it("installs a local APK and preserves the device's failure verdict", async () => {
const { binary, root } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "install" ]; then printf 'Success\\n'; exit 0; fi
exit 1`,
);
const apk = join(root, "app.apk");
await writeFile(apk, "apk");
const digest = createHash("sha256").update("apk").digest("hex");
const installed = await evidenceResult(providerOver(binary), {
operation: "install_adb_package",
input: { serial: "emulator-5554", apk_path: apk, replace: false },
});
expect(installed).toMatchObject({
bytes: 3,
sha256: digest,
replaced: false,
});
const { binary: failing } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "install" ]; then printf 'Failure [INSTALL_FAILED_ALREADY_EXISTS]\\n'; exit 0; fi
exit 1`,
);
const projected = await refusedBy(providerOver(failing), {
operation: "install_adb_package",
input: {
serial: "emulator-5554",
apk_path: apk,
replace: false,
},
});
expect(projected).toMatchObject({ code: "unsupported_target" });
expect(projected.message).toContain("INSTALL_FAILED_ALREADY_EXISTS");
});
it("uninstalls a package and reports the device's refusal for absent ones", async () => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "uninstall" ]; then printf 'Success\\n'; exit 0; fi
exit 1`,
);
const removed = await evidenceResult(providerOver(binary), {
operation: "uninstall_adb_package",
input: { serial: "emulator-5554", package: "com.example" },
});
expect(removed.package_name).toBe("com.example");
const { binary: refusing } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "uninstall" ]; then printf 'Failure [not installed for 0]\\n'; exit 0; fi
exit 1`,
);
const projected = await refusedBy(providerOver(refusing), {
operation: "uninstall_adb_package",
input: { serial: "emulator-5554", package: "com.absent" },
});
expect(projected).toMatchObject({ code: "unsupported_target" });
expect(projected.message).toContain("not installed for 0");
});
it("starts an app through resolved launcher component and force-stops it", async () => {
const { binary } = await writeAdbStub(
shellStub({
"cmd:package":
"priority=0 preferredOrder=0 match=0x00108000 specificIndex=-1 isDefault=true\ncom.example/.Main",
"am:start":
"Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.example/.Main }\n",
"am:force-stop": "",
}),
);
const provider = providerOver(binary);
const started = await evidenceResult(provider, {
operation: "start_adb_app",
input: { serial: "emulator-5554", package: "com.example" },
});
expect(started).toMatchObject({
package_name: "com.example",
component: "com.example/.Main",
started_activity: expect.stringContaining(
"cmp=com.example/.Main",
) as unknown,
});
const stopped = await evidenceResult(provider, {
operation: "stop_adb_app",
input: { serial: "emulator-5554", package: "com.example" },
});
expect(stopped.package_name).toBe("com.example");
});
it("reports an app without a launcher activity as unsupported", async () => {
const { binary } = await writeAdbStub(
shellStub({ "cmd:package": "No activity found\n" }),
);
const projected = await refusedBy(providerOver(binary), {
operation: "start_adb_app",
input: { serial: "emulator-5554", package: "com.library.only" },
});
expect(projected).toMatchObject({ code: "unsupported_target" });
expect(projected.message).toContain("No launcher activity");
});
it("starts an intent with typed extras as a fixed argv vector", async () => {
const { binary, root } = await writeAdbStub(
(root) => `printf '%s\\n' "$@" >> ${join(root, "argv.log")}
${shellStub({ "am:start": "Starting: Intent { act=android.intent.action.VIEW dat=https://example.com/ }\n" })(root)}`,
);
const started = await evidenceResult(providerOver(binary), {
operation: "start_adb_activity",
input: {
serial: "emulator-5554",
action: "android.intent.action.VIEW",
data_uri: "https://example.com/",
extras: [
{ key: "level", type: "int", value: "3" },
{ key: "label", type: "string", value: "probe" },
],
},
});
expect(started.action).toBe("android.intent.action.VIEW");
const logged = (await readFile(join(root, "argv.log"), "utf8"))
.trim()
.split("\n");
expect(logged).toEqual([
"version",
"-s",
"emulator-5554",
"shell",
"am",
"start",
"-W",
"-a",
"android.intent.action.VIEW",
"-d",
"https://example.com/",
"--ei",
"level",
"3",
"--es",
"label",
"probe",
]);
});
it("rejects mistyped intent extras before touching the device", async () => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
: > should-not-exist`,
);
const projected = await refusedBy(providerOver(binary), {
operation: "start_adb_activity",
input: {
serial: "emulator-5554",
action: "com.example.ACTION",
extras: [{ key: "flag", type: "boolean", value: "yes" }],
},
});
expect(projected).toMatchObject({ code: "invalid_request" });
});
it("collects a device bugreport archive with its digest", async () => {
const { binary, root } = await writeAdbStub(
(root) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "bugreport" ]; then
printf 'bugreport-bytes' > "${join(root, "bugreport test.zip")}"
printf 'Bug report copied to ${join(root, "bugreport test.zip")}\\n'
exit 0
fi
exit 1`,
);
const result = await evidenceResult(providerOver(binary), {
operation: "collect_adb_bugreport",
input: { serial: "emulator-5554", output_directory: root },
});
const digest = createHash("sha256").update("bugreport-bytes").digest("hex");
expect(result).toMatchObject({
local_path: join(root, "bugreport test.zip"),
bytes: 15,
sha256: digest,
adb_reported_path: join(root, "bugreport test.zip"),
});
});
it("quotes caller text before adb joins the remote shell command", async () => {
const { binary, root } = await writeAdbStub(
(root) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
shift 3
# Model adb's remote command joining, then execute the resulting shell string.
printf '#!/bin/sh\nprintf "%%s" "$@" > "${join(root, "observed")}"\n' > "${join(root, "ls")}"
chmod +x "${join(root, "ls")}"
PATH="${root}:$PATH" sh -c "$*"
`,
);
const value = `/sdcard/a 'quoted'; touch ${join(root, "injected")}`;
await providerOver(binary).execute({
operation: "list_adb_directory",
input: { serial: "device", device_path: value },
});
expect(await readFile(join(root, "observed"), "utf8")).toContain(value);
await expect(readFile(join(root, "injected"))).rejects.toThrow();
});
it("preserves a screen output created while capture is running", async () => {
const { binary, root } = await writeAdbStub(
(root) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
printf 'existing evidence' > "${join(root, "captures", "screen.png")}"
printf '\\211PNG\\r\\n\\032\\n\\0\\0\\0\\rIHDR\\0\\0\\0\\004\\0\\0\\0\\003'
`,
);
const result = await providerOver(binary).execute({
operation: "capture_adb_screen",
input: { serial: "device", output_directory: join(root, "captures") },
});
expect(result.ok).toBe(false);
expect(await readFile(join(root, "captures", "screen.png"), "utf8")).toBe(
"existing evidence",
);
});
it("reports cancellation arriving during the version probe", async () => {
const { binary } = await writeAdbStub(() => "exec sleep 30");
const controller = new AbortController();
const pending = providerOver(binary).execute(
{ operation: "inspect_adb_client", input: {} },
{ signal: controller.signal },
);
const timer = setTimeout(() => controller.abort(), 50);
try {
const result = await pending;
expect(result.ok).toBe(false);
if (!result.ok)
expect(projectAnalysisError(result.error).code).toBe("cancelled");
} finally {
clearTimeout(timer);
}
});