mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(adb): preserve numeric intent and typed acquisition outcomes
This commit is contained in:
1 parent
8e57b7672b
commit
57e3e0b04d
9 files changed
+283
-25
No files matched your search
@@ -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.
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
});
|
||||
});
|
||||
+74
-14
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
@@ -17,5 +17,6 @@ export const providerConfigurationRefusal = (
|
||||
return new AnalysisCapabilityUnavailableError(providerId, operation, reason, {
|
||||
cause: failure,
|
||||
userMessage: reason,
|
||||
diagnostics: failure.diagnostics,
|
||||
});
|
||||
};
|
||||
Reference in new issue
Block a user