fix(adb): report an unparsed push checksum as unverified (#6010)

fix(adb): derive push digest source from the parsed device digest

A zero-exit sha256sum whose output held no recognizable digest produced
device_sha256: null with device_digest_source: "device_sha256sum" and
dropped the not-device-side-verified limitation. The push receipt now
takes one tagged device digest, so the source can only name sha256sum
when a digest was parsed; unrecognized output reports "unavailable",
the unverified limitation, and the raw command output. The sha256sum
parser moves to AdbShellOutput and accepts GNU's escaped-name prefix.

Fixes #5924

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 authored and GitHub committed 2026-10-21 07:56:38 -07:00
1 parent 2319166c2e
commit 1f92c77398
4 files changed
+136 -40

No files matched your search

+3 -1
View File
@@ -172,7 +172,9 @@ and during shutdown.
transfer even with `overwrite`; only a clean `test -e` exit of 1 with no
output is interpreted as absence. The check is not atomic, so a different
process can create the path before `adb push` writes it. When available, the device's
`sha256sum` verifies the transfer; installs preserve the
`sha256sum` verifies the transfer. A missing command or output without a
recognizable digest reports `device_digest_source: "unavailable"` with an
unverified-transfer limitation; installs preserve the
device's `Failure [...]` reason verbatim.
- **Producer identity stays attached.** Every result embeds the producing
adb client (`binary_path`, `path_source`, `version`, `installed_path`).
+69 -25
View File
@@ -1049,34 +1049,78 @@ exit 0`,
},
);
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
const PUSHED_DIGEST =
"8748b5d17d1ebcec7d46674fa6a8b92c0f95598a552f27c1c8a132eb58c04043";
it.each([
{
answer: "a digest line",
stdout: `${PUSHED_DIGEST} /data/local/tmp/payload.bin\n`,
exit: 0,
verified: true,
},
{
// GNU sha256sum prefixes a line whose echoed name it escaped.
answer: "an escaped digest line",
stdout: `\\${PUSHED_DIGEST} /data/local/tmp/pay\\\\load.bin\n`,
exit: 0,
verified: true,
},
{ answer: "empty output", stdout: "", exit: 0, verified: false },
{
answer: "a malformed line",
stdout: `${PUSHED_DIGEST.slice(1)} /data/local/tmp/payload.bin\n`,
exit: 0,
verified: false,
},
{ answer: "a failed command", stdout: "", exit: 127, verified: false },
])(
"derives the push digest source from $answer",
async ({ stdout, exit, verified }) => {
const { binary, root } = await writeAdbStub(
(stubRoot) => `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
if [ "$1" = "-s" ] && [ "$4" = "sha256sum" ]; then cat "${join(stubRoot, "sha256sum.out")}"; exit ${exit}; 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",
});
});
);
await writeFile(join(root, "sha256sum.out"), stdout);
const localFile = join(root, "payload.bin");
await writeFile(localFile, "push-payload");
const result = await providerOver(binary).execute({
operation: "push_adb_file",
input: {
serial: "emulator-5554",
local_path: localFile,
device_path: "/data/local/tmp/payload.bin",
overwrite: false,
},
});
if (!result.ok) throw new Error(result.error.message);
expect(result.value.result).toMatchObject({
bytes: 12,
sha256: PUSHED_DIGEST,
overwritten: false,
...(verified
? {
device_sha256: PUSHED_DIGEST,
device_digest_source: "device_sha256sum",
}
: { device_sha256: null, device_digest_source: "unavailable" }),
});
expect(
result.value.limitations.some((line) =>
line.includes("not device-side verified"),
),
).toBe(!verified);
// Unrecognized checksum output stays inspectable instead of being dropped.
expect(result.value.rawResult).toEqual(
!verified && exit === 0
? { device_sha256sum: { stdout, stderr: "" } }
: null,
);
},
);
it("pushes the same private bytes that its reported digest identifies", async () => {
const { binary, root } = await writeAdbStub(
+56 -14
View File
@@ -117,6 +117,7 @@ import {
parseResolveActivityOutput,
parseServiceListOutput,
parseSettingsGetOutput,
parseSha256sumOutput,
parseWindowFocusOutput,
parseWmDensityOutput,
parseWmSizeOutput,
@@ -272,6 +273,21 @@ const adbDigestError = (
type AdbFileDigest = Awaited<ReturnType<typeof hashStableFile>>;
/**
* A pushed file's device-side digest together with its source, so a receipt
* cannot name `sha256sum` as the source of a digest that was never parsed.
*/
type AdbDeviceDigest =
| {
readonly device_sha256: string;
readonly device_digest_source: "device_sha256sum";
}
| {
readonly device_sha256: null;
readonly device_digest_source: "unavailable";
readonly reason: string;
};
const partialObservationWithDigest = (
observation:
| AdbAcquiredFilePartialObservation
@@ -2436,9 +2452,13 @@ export class AdbProvider {
);
if (!pushed.ok) return pushed;
const receipt = (
deviceSha256: string | null,
source: "device_sha256sum" | "unavailable",
extra: readonly string[] = [],
deviceDigest: AdbDeviceDigest,
rawResult?: {
readonly device_sha256sum: {
readonly stdout: string;
readonly stderr: string;
};
},
) =>
createAnalysisExecution(
{
@@ -2448,15 +2468,18 @@ export class AdbProvider {
device_path: input.device_path,
bytes: digest.bytes,
sha256: digest.sha256,
device_sha256: deviceSha256,
device_sha256: deviceDigest.device_sha256,
overwritten: deviceExists,
device_digest_source: source,
device_digest_source: deviceDigest.device_digest_source,
},
provider,
{
...(rawResult === undefined ? {} : { rawResult }),
limitations: [
"Push writes a private snapshot of the caller's file to the device",
...extra,
...(deviceDigest.device_digest_source === "unavailable"
? [deviceDigest.reason]
: []),
],
},
);
@@ -2471,11 +2494,13 @@ export class AdbProvider {
if (!deviceDigest.ok) {
const cancelled =
deviceDigest.error instanceof AnalysisCancelledError;
const completed = receipt(null, "unavailable", [
cancelled
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",
]);
});
if (cancelled)
deviceDigest.error.retainPartialObservation(
createEvidence(
@@ -2494,11 +2519,28 @@ export class AdbProvider {
);
return cancelled ? err(deviceDigest.error) : ok(completed);
}
const digestMatch = /^([a-f0-9]{64})\s/u.exec(
deviceDigest.value.stdout,
);
const completed = receipt(digestMatch?.[1] ?? null, "device_sha256sum");
if (digestMatch !== null && digestMatch[1] !== digest.sha256)
const parsedDigest = parseSha256sumOutput(deviceDigest.value.stdout);
const completed =
parsedDigest === null
? receipt(
{
device_sha256: null,
device_digest_source: "unavailable",
reason:
"The device's sha256sum output contained no recognizable digest, so the transfer is not device-side verified",
},
{
device_sha256sum: {
stdout: deviceDigest.value.stdout,
stderr: deviceDigest.value.stderr,
},
},
)
: receipt({
device_sha256: parsedDigest,
device_digest_source: "device_sha256sum",
});
if (parsedDigest !== null && parsedDigest !== digest.sha256)
return err(
new AnalysisArtifactChangedError(
operation,
+8
View File
@@ -381,6 +381,14 @@ export const parseBugreportCopiedPath = (stdout: string): string | null => {
return match === null ? null : match[1]!;
};
/**
* Read the digest from one `sha256sum FILE` line, or null when the output
* names none. GNU sha256sum starts the line with a backslash when it escaped
* the echoed name; the digest itself is never escaped.
*/
export const parseSha256sumOutput = (stdout: string): string | null =>
/^\\?([a-f0-9]{64}) [ *]/u.exec(stdout)?.[1] ?? null;
/** Read the device's `Failure [REASON]` verdict from package-change output. */
export const packageChangeFailureReason = (output: string): string | null =>
/Failure \[([^\]]+)\]/u.exec(output)?.[1] ?? null;