diff --git a/docs/adb-device-analysis.md b/docs/adb-device-analysis.md index ac5f475434..7d6721a80d 100644 --- a/docs/adb-device-analysis.md +++ b/docs/adb-device-analysis.md @@ -242,3 +242,13 @@ Record: verified against adb 34.0.5 on Linux against an Android 14 287 services, 92 features, a real two-APK split set pulled with byte-exact digests, a verified push/pull roundtrip, a 1.3 MB screen capture, and the complete package lifecycle; see `docs/testing.md` for the lane summary. + +Intent integer extras use signed decimal text. The ADB argument encoder removes +redundant leading zeros for Android's radix-sensitive `--ei` parser while retained +Evidence keeps the caller's original spelling. Package inspection accepts both +`userId` and Android 16's `appId` spelling for the legacy `user_id` result field. + +A completed push and device digest verification are separate observations. A +verification timeout, cancellation, or output budget failure returns the typed +failure with partial Evidence of the completed transfer. An unavailable checksum +command or unrecognized checksum output leaves verification explicitly unknown. diff --git a/src/adb/AdbConfiguration.ts b/src/adb/AdbConfiguration.ts index 4872bfe531..e8876ca8d7 100644 --- a/src/adb/AdbConfiguration.ts +++ b/src/adb/AdbConfiguration.ts @@ -51,6 +51,7 @@ export const resolveAdbBinary = ( export type AdbConfigurationFailureCode = | "not_absolute" | "binary_missing" + | "binary_not_executable" | "version_unresolved"; export class AdbConfigurationFailure extends Error { @@ -173,6 +174,18 @@ export const inspectAdbClient = async ( }, { cause }, ); + if (failure?.code === "EACCES" || failure?.code === "EPERM") + throw new AdbConfigurationFailure( + "binary_not_executable", + `Execution of the selected adb binary was denied (${failure.code}): ${selection.binary}`, + { + selected_binary: selection.binary, + path_source: selection.pathSource, + filesystem_error: failure.code, + remediation: `Check file type and execute permission for ${ADB_BINARY_ENV} or select an executable adb binary`, + }, + { cause }, + ); throw new AdbConfigurationFailure( "version_unresolved", "The selected adb binary did not report a usable version", @@ -196,7 +209,7 @@ const rejectionCodeOf = ( ): ProviderRejectionCode => code === "binary_missing" ? "executable_missing" - : code === "not_absolute" + : code === "not_absolute" || code === "binary_not_executable" ? "not_configured" : "version_unresolved"; diff --git a/src/adb/AdbProvider.test.ts b/src/adb/AdbProvider.test.ts index 2bba049f61..a59ca7ec08 100644 --- a/src/adb/AdbProvider.test.ts +++ b/src/adb/AdbProvider.test.ts @@ -2013,12 +2013,20 @@ ${shellStub({ "am:start": "Starting: Intent { act=android.intent.action.VIEW dat action: "android.intent.action.VIEW", data_uri: "https://example.com/", extras: [ - { key: "level", type: "int", value: "3" }, + { key: "level", type: "int", value: "010" }, + { key: "negative", type: "int", value: "-010" }, + { key: "eight", type: "int", value: "08" }, { key: "label", type: "string", value: "probe" }, ], }, }); expect(started.action).toBe("android.intent.action.VIEW"); + expect(started.extras).toEqual([ + { key: "level", type: "int", value: "010" }, + { key: "negative", type: "int", value: "-010" }, + { key: "eight", type: "int", value: "08" }, + { key: "label", type: "string", value: "probe" }, + ]); const logged = (await readFile(join(root, "argv.log"), "utf8")) .trim() .split("\n"); @@ -2036,7 +2044,13 @@ ${shellStub({ "am:start": "Starting: Intent { act=android.intent.action.VIEW dat "https://example.com/", "--ei", "level", - "3", + "10", + "--ei", + "negative", + "-10", + "--ei", + "eight", + "8", "--es", "label", "probe", @@ -2122,3 +2136,79 @@ it("reports cancellation arriving during the version probe", async () => { clearTimeout(timer); } }); + +it("preserves the typed offline failure and failed paths when every package pull fails", async () => { + const { binary, root } = await writeAdbStub( + () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +if [ "$3" = "shell" ]; then printf 'package:/data/app/base.apk\\n'; exit 0; fi +if [ "$3" = "pull" ]; then printf 'error: device offline\\n' >&2; exit 1; fi +exit 1`, + ); + const result = await providerOver(binary).execute({ + operation: "pull_adb_package", + input: { + serial: "emulator-5554", + package: "com.example", + output_directory: join(root, "pulls"), + }, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "unsupported_target", + details: { reason: "error: device offline" }, + }); + expect(result.error.partialObservation).toMatchObject({ + result: { + artifacts: [], + failures: [{ device_path: "/data/app/base.apk", stage: "pull" }], + coverage: "partial", + }, + }); +}); + +it("retains a completed push when checksum capture exceeds its budget", async () => { + const { binary, root } = await writeAdbStub( + () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +if [ "$3" = "push" ]; then exit 0; fi +if [ "$4" = "sha256sum" ]; then head -c 2048 /dev/zero | tr '\\000' a; exit 0; fi +exit 1`, + ); + const local = join(root, "payload.bin"); + await writeFile(local, "payload"); + const result = await providerOver(binary).execute({ + operation: "push_adb_file", + input: { + serial: "emulator-5554", + local_path: local, + device_path: "/data/local/tmp/payload.bin", + overwrite: true, + }, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "resource_constraint", + details: { operation: "push_adb_file" }, + }); + expect(result.error.partialObservation).toBeDefined(); +}); + +it("attributes version capture failures to the public operation and retains the prerequisite", async () => { + const { binary } = await writeAdbStub( + () => "head -c 70000 /dev/zero | tr '\\000' a", + ); + const result = await providerOver(binary).execute({ + operation: "list_adb_devices", + input: {}, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "resource_constraint", + details: { + operation: "list_adb_devices", + diagnostics: { prerequisite_operation: "inspect_adb_client" }, + }, + }); +}); diff --git a/src/adb/AdbProvider.ts b/src/adb/AdbProvider.ts index 7db8179767..cfc0c9feae 100644 --- a/src/adb/AdbProvider.ts +++ b/src/adb/AdbProvider.ts @@ -1,3 +1,4 @@ +import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js"; import { createHash } from "node:crypto"; import { constants as fsConstants } from "node:fs"; import { @@ -1325,7 +1326,15 @@ export class AdbProvider { selection = this.selection(); identity = await inspectAdbClient(selection, signal); } catch (cause) { - if (cause instanceof AnalysisError) return err(cause); + if (cause instanceof AnalysisError) + return err( + analysisClientFailureForOperation( + cause, + ADB_PROVIDER_IDENTITY.id, + request.operation, + { prerequisite_operation: "inspect_adb_client" }, + ), + ); if (cause instanceof AdbConfigurationFailure) return err( providerConfigurationRefusal( @@ -2253,15 +2262,32 @@ export class AdbProvider { } } if (artifacts.length === 0 && failures.length > 0) { - const primary = new AnalysisProtocolError( - `Every APK pull failed for ${packageName} on ${serial}: ${failures[0]!.message}`, - { - cause: new AggregateError( - failures.map(({ cause }) => cause), - "APK pulls failed", - ), + const firstCause = failures[0]!.cause; + const primary = + firstCause instanceof AnalysisError + ? firstCause + : new AnalysisProtocolError( + `Every APK pull failed for ${packageName} on ${serial}: ${failures[0]!.message}`, + { + cause: new AggregateError( + failures.map(({ cause }) => cause), + "APK pulls failed", + ), + }, + ); + primary.retainPartialObservation({ + provider_id: "adb", + operation, + result: { + client, + serial, + package_name: packageName, + output_directory: directory.value, + artifacts, + failures: observedFailures(), + coverage: "partial", }, - ); + }); return failOwnedPackage(primary); } return ok( @@ -2377,7 +2403,7 @@ export class AdbProvider { new AnalysisOutputError( operation, `The pulled file could not be read back after pulling: ${localPath}`, - { cause }, + { cause, partialObservation }, ), ); } @@ -2510,14 +2536,20 @@ export class AdbProvider { if (!deviceDigest.ok) { const cancelled = deviceDigest.error instanceof AnalysisCancelledError; + const verificationFailed = + cancelled || + deviceDigest.error instanceof AnalysisResourceConstraintError || + deviceDigest.error instanceof AnalysisTimeoutError; const completed = receipt({ device_sha256: null, device_digest_source: "unavailable", reason: cancelled ? "The device-side digest check was cancelled after push completed" - : "The device did not answer sha256sum, so the transfer is not device-side verified", + : verificationFailed + ? `Device-side digest verification failed after push completed: ${deviceDigest.error.message}` + : "The device did not answer sha256sum, so the transfer is not device-side verified", }); - if (cancelled) + if (verificationFailed) deviceDigest.error.retainPartialObservation( createEvidence( completed.subject ?? undefined, @@ -2533,7 +2565,7 @@ export class AdbProvider { }, ), ); - return cancelled ? err(deviceDigest.error) : ok(completed); + return verificationFailed ? err(deviceDigest.error) : ok(completed); } const parsedDigest = parseSha256sumOutput(deviceDigest.value.stdout); const completed = @@ -2642,6 +2674,20 @@ export class AdbProvider { installed ); const verdict = parsePackageChangeOutput(installed.value.stdout); + if (verdict.status === "unknown") + return err( + new AnalysisOutputError( + operation, + "ADB returned no installation verdict", + { + capturedOutput: { + stdout: installed.value.stdout, + stderr: installed.value.stderr, + truncated: false, + }, + }, + ), + ); if (verdict.status === "failure") return err( new AnalysisUnsupportedTargetError( @@ -2706,6 +2752,16 @@ export class AdbProvider { uninstalled ); const verdict = parsePackageChangeOutput(uninstalled.value.stdout); + if (verdict.status === "unknown") + return err( + new AnalysisOutputError(operation, "ADB returned no removal verdict", { + capturedOutput: { + stdout: uninstalled.value.stdout, + stderr: uninstalled.value.stderr, + truncated: false, + }, + }), + ); if (verdict.status === "failure") return err( new AnalysisUnsupportedTargetError( @@ -2823,7 +2879,11 @@ export class AdbProvider { ...input.extras.flatMap((extra) => [ extraFlags[extra.type], extra.key, - extra.value, + // Android decodes --ei with Integer.decode, whose leading zero is octal. + // Encode decimal intent at this boundary; Evidence retains caller text. + extra.type === "int" + ? extra.value.replace(/^(-?)0+(?=\d)/u, "$1") + : extra.value, ]), ]; return this.runAmStart( diff --git a/src/adb/AdbShellOutput.test.ts b/src/adb/AdbShellOutput.test.ts index e0338854f2..9253e239e3 100644 --- a/src/adb/AdbShellOutput.test.ts +++ b/src/adb/AdbShellOutput.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { parseAmStartOutput, + parsePackageChangeOutput, parseDumpsysPackageOutput, parseWmDensityOutput, parseWindowFocusOutput, @@ -18,6 +19,16 @@ const packageDump = (installerLine: string | undefined): string => ].join("\n"); describe("parseDumpsysPackageOutput", () => { + it.each(["userId", "appId"])( + "retains the printed %s package identifier", + (key) => { + expect( + parseDumpsysPackageOutput( + `Packages:\n Package [com.example] (abcdef):\n ${key}=10234\n`, + ).userId, + ).toBe(10234); + }, + ); it.each(["", " My Build ", " ", "null"])( "preserves the printed version name %j", (versionName) => { @@ -224,6 +235,16 @@ describe("parseWindowFocusOutput declaration presence", () => { }); describe("parseDumpsysPackageOutput", () => { + it.each(["userId", "appId"])( + "retains the printed %s package identifier", + (key) => { + expect( + parseDumpsysPackageOutput( + `Packages:\n Package [com.example] (abcdef):\n ${key}=10234\n`, + ).userId, + ).toBe(10234); + }, + ); it("reports unknown requested permissions when the package is null", () => { expect( parseDumpsysPackageOutput("Package [com.example] (abcdef):\n pkg=null\n") @@ -490,3 +511,25 @@ describe("parseAmStartOutput", () => { }); }); }); + +describe("parsePackageChangeOutput", () => { + it.each(["", "Performing Streamed Install\n", "progress"])( + "keeps an absent package-change verdict unknown: %j", + (text) => { + expect(parsePackageChangeOutput(text)).toEqual({ + status: "unknown", + reason: null, + }); + }, + ); + it("retains explicit failure even alongside success text", () => { + expect(parsePackageChangeOutput("Success\nFailure [DENIED]\n")).toEqual({ + status: "failure", + reason: "DENIED", + }); + expect(parsePackageChangeOutput("Success\n")).toEqual({ + status: "success", + reason: null, + }); + }); +}); diff --git a/src/adb/AdbShellOutput.ts b/src/adb/AdbShellOutput.ts index 16453f226f..8e43b82e51 100644 --- a/src/adb/AdbShellOutput.ts +++ b/src/adb/AdbShellOutput.ts @@ -360,7 +360,7 @@ export const parseDumpsysPackageOutput = ( details.installerPackageName === null ) details.installerPackageName = absentInstallerOrName(value); - else if (key === "userId" && details.userId === null) { + else if ((key === "userId" || key === "appId") && details.userId === null) { const parsed = /^(\d+)/u.exec(value); if (parsed !== null) details.userId = Number(parsed[1]!); } else if (key === "pkgFlags" && details.pkgFlags === null) @@ -419,17 +419,14 @@ export const packageChangeFailureReason = (output: string): string | null => export const parsePackageChangeOutput = ( stdout: string, ): { - readonly status: "success" | "failure"; + readonly status: "success" | "failure" | "unknown"; readonly reason: string | null; } => { const text = stdout.trim(); + const failure = packageChangeFailureReason(text); + if (failure !== null) return { status: "failure", reason: failure }; if (/^Success$/mu.test(text)) return { status: "success", reason: null }; - return { - status: "failure", - reason: - packageChangeFailureReason(text) ?? - (text === "" ? null : text.slice(0, 512)), - }; + return { status: "unknown", reason: null }; }; /** diff --git a/src/adb/AdbVersionProbe.test.ts b/src/adb/AdbVersionProbe.test.ts index ef1048bcda..3748365a18 100644 --- a/src/adb/AdbVersionProbe.test.ts +++ b/src/adb/AdbVersionProbe.test.ts @@ -60,3 +60,22 @@ it("names the adb version-output cap and says it has no runtime override", async expect(projected.remediation.action).toContain("65536 bytes"); expect(projected.remediation.action).toContain("no runtime override"); }); + +it("distinguishes execute denial from missing or unreadable version output", async () => { + vi.mocked(execFileOutput).mockRejectedValueOnce( + Object.assign(new Error("denied"), { + code: "EACCES", + stdout: "", + stderr: "", + signal: null, + killed: false, + }), + ); + await expect(inspectAdbClient(selection)).rejects.toMatchObject({ + code: "binary_not_executable", + diagnostics: { + selected_binary: selection.binary, + filesystem_error: "EACCES", + }, + }); +}); diff --git a/src/domain/providerConfigurationRefusal.test.ts b/src/domain/providerConfigurationRefusal.test.ts new file mode 100644 index 0000000000..63fa64f56f --- /dev/null +++ b/src/domain/providerConfigurationRefusal.test.ts @@ -0,0 +1,25 @@ +import { expect, it } from "vitest"; +import { providerConfigurationRefusal } from "./providerConfigurationRefusal.js"; +import { projectAnalysisError } from "./analysisErrorProjection.js"; + +it("retains provider-owned configuration diagnostics in public refusals", () => { + const failure = Object.assign(new Error("version unreadable"), { + diagnostics: { + selected_command: "/selected/apktool", + command_source: "environment", + remediation: "Inspect the selected launcher", + }, + }); + expect( + projectAnalysisError( + providerConfigurationRefusal( + "apktool", + "inspect_apktool_client", + failure, + ), + ), + ).toMatchObject({ + code: "capability_unavailable", + details: { diagnostics: failure.diagnostics }, + }); +}); diff --git a/src/domain/providerConfigurationRefusal.ts b/src/domain/providerConfigurationRefusal.ts index cda28d8fb9..762cf91dc3 100644 --- a/src/domain/providerConfigurationRefusal.ts +++ b/src/domain/providerConfigurationRefusal.ts @@ -17,5 +17,6 @@ export const providerConfigurationRefusal = ( return new AnalysisCapabilityUnavailableError(providerId, operation, reason, { cause: failure, userMessage: reason, + diagnostics: failure.diagnostics, }); };