fix(adb): admit canonical device states and preserve malformed output

This commit is contained in:
morluto committed 2026-10-13 14:38:39 +08:00
1 parent 18d56f5321
commit e1cd292aa6
4 files changed
+100 -28

No files matched your search

+20 -2
View File
@@ -84,6 +84,24 @@ describe("parseAdbDevicesOutput", () => {
]);
});
it("accepts complete multiword states and rejects state prefixes", () => {
const output = [
"serial-no-permission no permissions usb:1-1",
"serial-bad-device deviceXYZ ",
"serial-bad-offline offline-ish",
"serial-bad-auth unauthorizedXYZ",
].join("\n");
const { devices, unparsedLines } = parseAdbDevicesOutput(output);
expect(devices.map(({ serial, state }) => [serial, state])).toEqual([
["serial-no-permission", "no permissions"],
]);
expect(unparsedLines).toEqual([
"serial-bad-device deviceXYZ ",
"serial-bad-offline offline-ish",
"serial-bad-auth unauthorizedXYZ",
]);
});
it("returns unrecognized lines instead of dropping them", () => {
const output = [
"List of devices attached",
@@ -194,10 +212,10 @@ describe("parsePackageListOutput", () => {
it("keeps unrecognized lines for partial coverage reporting", () => {
const { packages, unparsedLines } = parsePackageListOutput(
"package:/data/app/x/base.apk=com.ok\ngarbage line\n",
"package:/data/app/x/base.apk=com.ok\n garbage line \n",
);
expect(packages).toHaveLength(1);
expect(unparsedLines).toEqual(["garbage line"]);
expect(unparsedLines).toEqual([" garbage line "]);
});
});
+14 -25
View File
@@ -10,11 +10,15 @@ import {
adbHostFileName,
type AdbHostFileNameReason,
} from "./AdbHostFileName.js";
import {
ADB_DEVICE_STATES,
type AdbDeviceState,
} from "../domain/adb/adbDeviceAnalysis.js";
/** One device exactly as `adb devices -l` reports it. */
export interface AdbDeviceEntry {
readonly serial: string;
readonly state: string;
readonly state: AdbDeviceState;
readonly transport: "usb" | "tcp" | "unknown";
readonly product: string | null;
readonly model: string | null;
@@ -24,25 +28,6 @@ export interface AdbDeviceEntry {
readonly kind_basis: "emulator_serial_prefix" | "usb_transport" | "none";
}
/**
* Device states adb prints after the serial, longest first so two-word
* states match before their single-word prefixes. A line whose remainder
* starts with none of them is not a device line.
*/
const DEVICE_STATES = [
"no permissions",
"unauthorized",
"authorizing",
"offline",
"recovery",
"sideload",
"connecting",
"bootloader",
"device",
"unknown",
"host",
] as const;
const DEVICE_LINE = /^(\S+)\s+(.*)$/u;
const metadataValue = (remainder: string, key: string): string | null => {
@@ -73,11 +58,15 @@ export const parseAdbDevicesOutput = (
continue;
}
const match = DEVICE_LINE.exec(line);
const state = DEVICE_STATES.find((candidate) =>
match?.[2]?.startsWith(candidate),
);
const state = ADB_DEVICE_STATES.find((candidate) => {
const remainder = match?.[2];
if (remainder === undefined || !remainder.startsWith(candidate))
return false;
const next = remainder[candidate.length];
return next === undefined || /\s/u.test(next);
});
if (match === null || state === undefined) {
if (line.trim() !== "") unparsedLines.push(line);
if (rawLine.trim() !== "") unparsedLines.push(rawLine);
continue;
}
const serial = match[1]!;
@@ -192,7 +181,7 @@ export const parsePackageListOutput = (
if (line === "") continue;
const match = PACKAGE_LINE.exec(line);
if (match === null) {
unparsedLines.push(line);
unparsedLines.push(rawLine);
continue;
}
packages.push({
+46
View File
@@ -121,6 +121,52 @@ fi`,
]);
});
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
+20 -1
View File
@@ -30,6 +30,25 @@ const packageNameSchema = z
"Package name must be dot-separated Java identifiers",
);
/** Text states emitted by adb's ConnectionState formatter. */
export const ADB_DEVICE_STATES = [
"no permissions",
"unauthorized",
"authorizing",
"offline",
"recovery",
"sideload",
"connecting",
"bootloader",
"device",
"unknown",
"host",
"rescue",
"detached",
] as const;
export const adbDeviceStateSchema = z.enum(ADB_DEVICE_STATES);
export type AdbDeviceState = (typeof ADB_DEVICE_STATES)[number];
const keyStringSchema = z.string().min(1).max(256);
const keyPattern = /^[A-Za-z0-9._\x2d]+$/u;
@@ -412,7 +431,7 @@ const client = z.strictObject({
const device = z.strictObject({
serial: z.string().min(1),
state: z.string().min(1),
state: adbDeviceStateSchema,
transport: z.enum(["usb", "tcp", "unknown"]),
product: z.string().nullable(),
model: z.string().nullable(),