From e1cd292aa6e8f5f13441d408c66126782c8dbb0d Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:38:39 +0800 Subject: [PATCH] fix(adb): admit canonical device states and preserve malformed output --- src/adb/AdbDeviceOutput.test.ts | 22 ++++++++++++-- src/adb/AdbDeviceOutput.ts | 39 +++++++++--------------- src/adb/AdbProvider.test.ts | 46 +++++++++++++++++++++++++++++ src/domain/adb/adbDeviceAnalysis.ts | 21 ++++++++++++- 4 files changed, 100 insertions(+), 28 deletions(-) diff --git a/src/adb/AdbDeviceOutput.test.ts b/src/adb/AdbDeviceOutput.test.ts index fe11c2a262..12b82ab84a 100644 --- a/src/adb/AdbDeviceOutput.test.ts +++ b/src/adb/AdbDeviceOutput.test.ts @@ -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 "]); }); }); diff --git a/src/adb/AdbDeviceOutput.ts b/src/adb/AdbDeviceOutput.ts index 3380de6088..8d3c74f5fb 100644 --- a/src/adb/AdbDeviceOutput.ts +++ b/src/adb/AdbDeviceOutput.ts @@ -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({ diff --git a/src/adb/AdbProvider.test.ts b/src/adb/AdbProvider.test.ts index d1a1538480..b89ab9dfdc 100644 --- a/src/adb/AdbProvider.test.ts +++ b/src/adb/AdbProvider.test.ts @@ -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 diff --git a/src/domain/adb/adbDeviceAnalysis.ts b/src/domain/adb/adbDeviceAnalysis.ts index 17cdc223ea..c524ebd02e 100644 --- a/src/domain/adb/adbDeviceAnalysis.ts +++ b/src/domain/adb/adbDeviceAnalysis.ts @@ -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(),