1457 lines
49 KiB
TypeScript
1457 lines
49 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import {
|
|
chmod,
|
|
lstat,
|
|
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);
|
|
};
|
|
|
|
const namesAliasOnVolume = async (
|
|
parent: string,
|
|
firstName: string,
|
|
secondName: string,
|
|
): Promise<boolean> => {
|
|
const probe = join(parent, "destination-name-probe");
|
|
await mkdir(probe);
|
|
try {
|
|
await mkdir(join(probe, firstName));
|
|
try {
|
|
await mkdir(join(probe, secondName));
|
|
return false;
|
|
} catch (cause) {
|
|
if (cause instanceof Error && "code" in cause && cause.code === "EEXIST")
|
|
return true;
|
|
throw cause;
|
|
}
|
|
} finally {
|
|
await rm(probe, { recursive: true, force: true });
|
|
}
|
|
};
|
|
|
|
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("refuses device listings with an invalid state token", async () => {
|
|
const malformedLine = "serial deviceXYZ ";
|
|
const { binary } = await writeAdbStub(
|
|
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
|
if [ "$1" = "devices" ]; then
|
|
printf 'List of devices attached\\n${malformedLine}\\n'
|
|
exit 0
|
|
fi`,
|
|
);
|
|
const result = await providerOver(binary).execute({
|
|
operation: "list_adb_devices",
|
|
input: {},
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.error._tag).toBe("AnalysisProtocolError");
|
|
expect(result.error.message).toContain(malformedLine);
|
|
});
|
|
|
|
it("lists rescue and detached transports alongside online devices", async () => {
|
|
const { binary } = await writeAdbStub(
|
|
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
|
if [ "$1" = "devices" ]; then
|
|
printf 'List of devices attached\\n'
|
|
printf 'serial-online device transport_id:1\\n'
|
|
printf 'serial-rescue rescue transport_id:2\\n'
|
|
printf 'serial-detached detached transport_id:3\\n'
|
|
exit 0
|
|
fi`,
|
|
);
|
|
const result = await evidenceResult(providerOver(binary), {
|
|
operation: "list_adb_devices",
|
|
input: {},
|
|
});
|
|
expect(result.devices).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ serial: "serial-online", state: "device" }),
|
|
expect.objectContaining({ serial: "serial-rescue", state: "rescue" }),
|
|
expect.objectContaining({
|
|
serial: "serial-detached",
|
|
state: "detached",
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
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, third] = key.split(":");
|
|
const guard = [
|
|
`[ "$4" = "${first}" ]`,
|
|
...(second === undefined ? [] : [`[ "$5" = "${second}" ]`]),
|
|
...(third === undefined ? [] : [`[ "$6" = "${third}" ]`]),
|
|
].join(" && ");
|
|
return ` if ${guard}; then printf '%b' '${output.replaceAll("\\", "\\\\").replaceAll("\0", "\\000").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.each([
|
|
["an empty filename", "package:/data/app/x/"],
|
|
[
|
|
"duplicate local filenames",
|
|
"package:/data/app/x/base.apk\npackage:/data/app/y/base.apk",
|
|
],
|
|
])("refuses %s before creating output or pulling", async (_, pmOutput) => {
|
|
const { binary, root } = await writeAdbStub(
|
|
(root) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
|
if [ "$1" = "-s" ] && [ "$4" = "pm" ]; then printf '%b' '${pmOutput.replaceAll("'", "'\\''").replaceAll("\n", "\\n")}\\n'; exit 0; fi
|
|
if [ "$1" = "-s" ] && [ "$3" = "pull" ]; then : > '${join(root, "pull-ran")}'; exit 0; fi
|
|
exit 1`,
|
|
);
|
|
const output = join(root, "pulls");
|
|
const result = await providerOver(binary).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(result.error.capturedOutput?.stdout).toContain(
|
|
pmOutput.split("\n")[0],
|
|
);
|
|
const projected = projectAnalysisError(result.error);
|
|
const duplicate = pmOutput.includes("\n");
|
|
expect(projected).toMatchObject({
|
|
code: "unreadable_output",
|
|
message: expect.stringContaining("pm path for com.example"),
|
|
remediation: { action: expect.stringContaining("details.diagnostics") },
|
|
details: {
|
|
reason: duplicate ? "duplicate_local_destination" : "unsafe_device_path",
|
|
diagnostics: {
|
|
package_name: "com.example",
|
|
...(duplicate
|
|
? { device_paths: ["/data/app/x/base.apk", "/data/app/y/base.apk"] }
|
|
: {
|
|
rejected_paths: [
|
|
{
|
|
device_path: "/data/app/x/",
|
|
reason: "empty_file_name",
|
|
},
|
|
],
|
|
}),
|
|
},
|
|
captured_output: { stdout: expect.stringContaining("package:") },
|
|
},
|
|
});
|
|
await expect(readFile(join(root, "pull-ran"))).rejects.toThrow();
|
|
await expect(lstat(output)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
|
|
it.each([
|
|
["case-only names", "base.apk", "BASE.APK"],
|
|
["normalization-equivalent names", "caf\u00e9.apk", "cafe\u0301.apk"],
|
|
])(
|
|
"checks %s against the actual destination filesystem before pulling",
|
|
async (_, firstName, secondName) => {
|
|
const { binary, root } = await writeAdbStub((root) =>
|
|
pullStub(
|
|
`'package:/data/app/x/${firstName}' 'package:/data/app/y/${secondName}'`,
|
|
`printf 'pulled\\n' >> '${join(root, "pull.log")}'
|
|
printf 'apk' > "$5"`,
|
|
),
|
|
);
|
|
const aliases = await namesAliasOnVolume(root, firstName, secondName);
|
|
const result = await providerOver(binary).execute({
|
|
operation: "pull_adb_package",
|
|
input: {
|
|
serial: "emulator-5554",
|
|
package: "com.example",
|
|
output_directory: root,
|
|
},
|
|
});
|
|
const pullLog = join(root, "pull.log");
|
|
const packageDirectory = join(root, "com.example");
|
|
if (aliases) {
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
|
message: expect.stringContaining("unique local destination"),
|
|
details: {
|
|
reason: "local_destination_unavailable",
|
|
diagnostics: {
|
|
device_path: `/data/app/y/${secondName}`,
|
|
},
|
|
},
|
|
});
|
|
await expect(readFile(pullLog)).rejects.toThrow();
|
|
await expect(lstat(packageDirectory)).rejects.toThrow();
|
|
return;
|
|
}
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) throw new Error(result.error.message);
|
|
expect(
|
|
(
|
|
result.value.result as { artifacts: { file_name: string }[] }
|
|
).artifacts.map((artifact) => artifact.file_name),
|
|
).toEqual([firstName, secondName]);
|
|
expect((await readFile(pullLog, "utf8")).trim().split("\n")).toHaveLength(
|
|
2,
|
|
);
|
|
},
|
|
);
|
|
|
|
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.message).toContain("REA_ADB_PATH");
|
|
expect(projected.remediation).toMatchObject({
|
|
action: expect.stringContaining("REA_ADB_PATH") as unknown,
|
|
});
|
|
expect(projected.details).toMatchObject({
|
|
provider_id: "adb",
|
|
reason: expect.stringContaining("not found") as unknown,
|
|
});
|
|
});
|
|
|
|
it("reports relative adb configuration as an actionable refusal", async () => {
|
|
const provider = new AdbProvider({
|
|
environment: { REA_ADB_PATH: "relative/adb" },
|
|
});
|
|
const availability = await provider.inspectAvailability();
|
|
expect(availability).toMatchObject({
|
|
status: "unavailable",
|
|
reason: expect.stringContaining("REA_ADB_PATH") as unknown,
|
|
});
|
|
const projected = await refusedBy(provider, {
|
|
operation: "list_adb_devices",
|
|
input: {},
|
|
});
|
|
expect(projected).toMatchObject({
|
|
code: "capability_unavailable",
|
|
message: expect.stringContaining("REA_ADB_PATH") as unknown,
|
|
remediation: {
|
|
action: expect.stringContaining("REA_ADB_PATH") 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("reports UTF-8 byte counts in total_bytes for non-ASCII logcat output", async () => {
|
|
const { binary } = await writeAdbStub(
|
|
shellStub({
|
|
logcat:
|
|
"08-10 13:00:00.000 I/Tag( 1): 中文\n08-10 13:00:00.000 I/Tag( 1): 😀\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( 1): 中文",
|
|
"08-10 13:00:00.000 I/Tag( 1): 😀",
|
|
]);
|
|
// 30-byte prefix + 6-byte CJK + LF = 37; 30-byte prefix + 4-byte emoji + LF = 35.
|
|
expect(result.total_bytes).toBe(72);
|
|
});
|
|
|
|
it("counts raw captured bytes in total_bytes for malformed UTF-8 logcat output", async () => {
|
|
const { binary } = await writeAdbStub(
|
|
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
|
if [ "$1" = "-s" ] && [ "$3" = "shell" ]; then
|
|
printf '%b' '08-10 13:00:00.000 I/Tag( 1): A\\377B\\n'
|
|
exit 0
|
|
fi
|
|
printf 'unexpected arguments: %s\\n' "$@"
|
|
exit 1`,
|
|
);
|
|
const result = await evidenceResult(providerOver(binary), {
|
|
operation: "read_adb_logcat",
|
|
input: { serial: "emulator-5554", count: 500, buffer: "main" },
|
|
});
|
|
// 30-byte prefix + "A" + one raw 0xff byte + "B" + LF = 34 captured bytes.
|
|
// Re-encoding the decoded U+FFFD replacement would report 36.
|
|
expect(result.total_bytes).toBe(34);
|
|
});
|
|
|
|
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",
|
|
});
|
|
});
|
|
|
|
// Android 10 and 11 append ": restricted=true" to hard- and soft-restricted
|
|
// entries of the requested-permissions list (Settings.dumpPackageLPr).
|
|
it("counts requested permissions that the device marks as restricted", async () => {
|
|
const { binary } = await writeAdbStub(
|
|
shellStub({
|
|
"dumpsys:package": `Packages:
|
|
Package [com.example] (abcdef):
|
|
userId=10234
|
|
versionName=1.2.3
|
|
requested permissions:
|
|
android.permission.INTERNET
|
|
android.permission.WRITE_EXTERNAL_STORAGE: restricted=true
|
|
android.permission.CAMERA
|
|
install permissions:
|
|
android.permission.INTERNET: granted=true
|
|
`,
|
|
}),
|
|
);
|
|
const result = await evidenceResult(providerOver(binary), {
|
|
operation: "inspect_adb_package",
|
|
input: { serial: "emulator-5554", package: "com.example" },
|
|
});
|
|
expect(result).toMatchObject({
|
|
requested_permissions_count: 3,
|
|
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/My Notes:r*sumé.txt",
|
|
output_directory: output,
|
|
},
|
|
});
|
|
const digest = createHash("sha256").update("file-bytes").digest("hex");
|
|
expect(result).toMatchObject({
|
|
device_path: "/data/local/tmp/My Notes:r*sumé.txt",
|
|
local_path: join(output, "My Notes:r*sumé.txt"),
|
|
bytes: 10,
|
|
sha256: digest,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["/data/local/tmp/..", "dot_segment"],
|
|
["/data/local/tmp/", "empty_file_name"],
|
|
] as const)(
|
|
"refuses the unsafe device path %s before pulling",
|
|
async (devicePath, reason) => {
|
|
const { binary, root } = await writeAdbStub(
|
|
(root) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
|
: > "${join(root, "pull-ran")}"
|
|
exit 0`,
|
|
);
|
|
const output = join(root, "pulls");
|
|
const result = await providerOver(binary).execute({
|
|
operation: "pull_adb_file",
|
|
input: {
|
|
serial: "emulator-5554",
|
|
device_path: devicePath,
|
|
output_directory: output,
|
|
},
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
const projected = projectAnalysisError(result.error);
|
|
expect(projected).toMatchObject({ code: "invalid_request" });
|
|
expect(projected.details).toMatchObject({
|
|
issues: [
|
|
{
|
|
path: ["device_path"],
|
|
reason: "invalid_value",
|
|
message: expect.stringContaining(
|
|
`${JSON.stringify(devicePath)} does not name`,
|
|
),
|
|
},
|
|
],
|
|
});
|
|
expect(
|
|
(projected.details as { issues: { message: string }[] }).issues[0]!
|
|
.message,
|
|
).toContain(reason);
|
|
// No host directory and no adb invocation are effects of a refused pull.
|
|
await expect(readFile(join(root, "pull-ran"))).rejects.toThrow();
|
|
await expect(lstat(output)).rejects.toMatchObject({ code: "ENOENT" });
|
|
},
|
|
);
|
|
|
|
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.each([
|
|
["offline", false, "error: device offline"],
|
|
["unauthorized", false, "error: device unauthorized"],
|
|
["offline", true, "error: device offline"],
|
|
] as const)(
|
|
"reports the %s device state when the push existence probe cannot run (overwrite %s)",
|
|
async (state, overwrite, stderr) => {
|
|
const { binary, root } = await writeAdbStub(
|
|
(stubRoot) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
|
if [ "$1" = "-s" ]; then printf '%s\\n' "$3" >> "${stubRoot}/calls"; printf '%s\\n' "${stderr}" >&2; exit 1; fi`,
|
|
);
|
|
const localFile = join(root, "payload.bin");
|
|
await writeFile(localFile, "x");
|
|
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,
|
|
},
|
|
});
|
|
// Existence is unknown, so nothing is claimed about the path and no push runs.
|
|
expect(projected).toMatchObject({ code: "unsupported_target" });
|
|
expect(projected.message).toContain(state);
|
|
expect(projected.message).not.toContain("already exists");
|
|
expect(await readFile(join(root, "calls"), "utf8")).toBe("shell\n");
|
|
},
|
|
);
|
|
|
|
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.each(["complete", "truncated"] as const)(
|
|
"preserves framed directory identities with %s output",
|
|
async (variant) => {
|
|
const date = "2026-01-02 11:00:00.000000000 +0000";
|
|
const names = [
|
|
"app.db",
|
|
" app.db ",
|
|
" ",
|
|
"one -> two -> three",
|
|
"line\r",
|
|
"line\u2028break",
|
|
"foo\n-rw-rw---- root root 0 2026-01-01 10:00 ghost",
|
|
"literal\\n",
|
|
];
|
|
const links = [
|
|
["lib", "/apex/com.android.art/lib"],
|
|
[" lib ", " /apex/lib "],
|
|
["one -> two\n", "three -> four\n\n"],
|
|
] as const;
|
|
const truncated = `-rw-rw----|u0_a1|u0_a1|4096|${date}\0unfinished`;
|
|
const output = [
|
|
...names.map(
|
|
(name) => `-rw-rw----|u0_a1|u0_a1|4096|${date}\0${name}\0\0`,
|
|
),
|
|
...links.map(
|
|
([name, target]) =>
|
|
`lrwxrwxrwx|root|root|${Buffer.byteLength(target)}|${date}\0${name}\0${target}\0`,
|
|
),
|
|
...(variant === "truncated" ? [truncated] : []),
|
|
].join("");
|
|
const { binary } = await writeAdbStub(shellStub({ sh: output }));
|
|
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([
|
|
...names.map((name) => ({
|
|
name,
|
|
kind: "file",
|
|
permissions: "-rw-rw----",
|
|
owner: "u0_a1",
|
|
group: "u0_a1",
|
|
bytes: 4096,
|
|
date,
|
|
link_target: null,
|
|
})),
|
|
...links.map(([name, target]) => ({
|
|
name,
|
|
kind: "symlink",
|
|
permissions: "lrwxrwxrwx",
|
|
owner: "root",
|
|
group: "root",
|
|
bytes: Buffer.byteLength(target),
|
|
date,
|
|
link_target: target,
|
|
})),
|
|
]);
|
|
expect(result.unparsed_lines).toEqual(
|
|
variant === "truncated" ? [truncated] : [],
|
|
);
|
|
expect(result.coverage).toBe(
|
|
variant === "truncated" ? "partial" : "complete",
|
|
);
|
|
},
|
|
);
|
|
|
|
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 Android 16 focus declarations from displays when windows omits them", async () => {
|
|
const { binary } = await writeAdbStub(
|
|
shellStub({
|
|
"dumpsys:window:windows": "Window state has no focus fields\\n",
|
|
"dumpsys:window:displays":
|
|
"mCurrentFocus=Window{abc u0 com.example/.Main}\nmFocusedApp=ActivityRecord{def u0 com.example/.Main t1}\n",
|
|
}),
|
|
);
|
|
const result = await evidenceResult(providerOver(binary), {
|
|
operation: "inspect_adb_window",
|
|
input: { serial: "emulator-5554" },
|
|
});
|
|
expect(result).toMatchObject({
|
|
focused_window: "Window{abc u0 com.example/.Main}",
|
|
focused_app: "ActivityRecord{def u0 com.example/.Main t1}",
|
|
observed_lines: [
|
|
"Window{abc u0 com.example/.Main}",
|
|
"ActivityRecord{def u0 com.example/.Main t1}",
|
|
],
|
|
});
|
|
const execution = await providerOver(binary).execute({
|
|
operation: "inspect_adb_window",
|
|
input: { serial: "emulator-5554" },
|
|
});
|
|
expect(execution.ok).toBe(true);
|
|
if (!execution.ok) throw execution.error;
|
|
expect(execution.value.limitations).toContainEqual(
|
|
expect.stringContaining("dumpsys window displays"),
|
|
);
|
|
});
|
|
|
|
it("describes missing window focus as unknown after both sections omit declarations", async () => {
|
|
const { binary } = await writeAdbStub(
|
|
shellStub({
|
|
"dumpsys:window:windows": "no focus\\n",
|
|
"dumpsys:window:displays": "no focus\\n",
|
|
}),
|
|
);
|
|
const result = await evidenceResult(providerOver(binary), {
|
|
operation: "inspect_adb_window",
|
|
input: { serial: "emulator-5554" },
|
|
});
|
|
expect(result).toMatchObject({ focused_window: null, focused_app: null });
|
|
const execution = await providerOver(binary).execute({
|
|
operation: "inspect_adb_window",
|
|
input: { serial: "emulator-5554" },
|
|
});
|
|
expect(execution.ok).toBe(true);
|
|
if (!execution.ok) throw execution.error;
|
|
expect(execution.value.limitations).toContainEqual(
|
|
expect.stringContaining("focus is unknown"),
|
|
);
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
// adb exits 1 on a refused package change: `adb install` after printing the
|
|
// status line to stderr, `cmd package uninstall` after printing it to stdout.
|
|
it("preserves the device's install refusal when adb exits nonzero", async () => {
|
|
const { binary, root } = await writeAdbStub(
|
|
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
|
if [ "$1" = "-s" ] && [ "$3" = "install" ]; then
|
|
printf 'Performing Streamed Install\\n'
|
|
printf 'adb: failed to install app.apk: Failure [INSTALL_FAILED_ALREADY_EXISTS: Attempt to re-install com.example without first uninstalling.]\\n' >&2
|
|
exit 1
|
|
fi
|
|
exit 1`,
|
|
);
|
|
const apk = join(root, "app.apk");
|
|
await writeFile(apk, "apk");
|
|
const projected = await refusedBy(providerOver(binary), {
|
|
operation: "install_adb_package",
|
|
input: { serial: "emulator-5554", apk_path: apk, replace: false },
|
|
});
|
|
expect(projected).toMatchObject({
|
|
code: "unsupported_target",
|
|
details: {
|
|
reason:
|
|
"INSTALL_FAILED_ALREADY_EXISTS: Attempt to re-install com.example without first uninstalling.",
|
|
},
|
|
});
|
|
expect(projected.message).toContain(
|
|
"INSTALL_FAILED_ALREADY_EXISTS: Attempt to re-install com.example without first uninstalling.",
|
|
);
|
|
});
|
|
|
|
it("preserves the device's uninstall refusal when adb exits nonzero", async () => {
|
|
const { binary } = 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 1; fi
|
|
exit 1`,
|
|
);
|
|
const projected = await refusedBy(providerOver(binary), {
|
|
operation: "uninstall_adb_package",
|
|
input: { serial: "emulator-5554", package: "com.absent" },
|
|
});
|
|
expect(projected).toMatchObject({
|
|
code: "unsupported_target",
|
|
details: { reason: "not installed for 0" },
|
|
});
|
|
expect(projected.message).toContain("not installed for 0");
|
|
});
|
|
|
|
it("keeps a nonzero install without a device verdict as an unreadable result", async () => {
|
|
const { binary, root } = await writeAdbStub(
|
|
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
|
if [ "$1" = "-s" ] && [ "$3" = "install" ]; then printf 'adb: failed to install app.apk: closed\\n' >&2; exit 1; fi
|
|
exit 1`,
|
|
);
|
|
const apk = join(root, "app.apk");
|
|
await writeFile(apk, "apk");
|
|
const projected = await refusedBy(providerOver(binary), {
|
|
operation: "install_adb_package",
|
|
input: { serial: "emulator-5554", apk_path: apk, replace: false },
|
|
});
|
|
expect(projected).toMatchObject({ code: "unreadable_output" });
|
|
});
|
|
|
|
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(
|
|
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
|
if [ "$1" = "-s" ] && [ "$3" = "bugreport" ]; then
|
|
printf 'bugreport-bytes' > "$4/bugreport test.zip"
|
|
printf 'Bug report copied to %s/bugreport test.zip\\n' "$4"
|
|
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: expect.stringContaining(".rea-bugreport-"),
|
|
});
|
|
});
|
|
|
|
it("quotes caller text before adb joins the remote shell command", async () => {
|
|
const { binary, root } = await writeAdbStub(
|
|
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
|
shift 3
|
|
# Model adb's remote command joining, then execute the resulting shell string.
|
|
sh -c "$*"
|
|
`,
|
|
);
|
|
const value = join(root, `device 'quoted'; touch ${join(root, "injected")}`);
|
|
await mkdir(value, { recursive: true });
|
|
const result = await evidenceResult(providerOver(binary), {
|
|
operation: "list_adb_directory",
|
|
input: { serial: "device", device_path: value },
|
|
});
|
|
expect(result).toMatchObject({
|
|
device_path: value,
|
|
entries: [],
|
|
coverage: "complete",
|
|
});
|
|
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);
|
|
}
|
|
});
|