diff --git a/docs/guides/android/adb-device-analysis.md b/docs/guides/android/adb-device-analysis.md index 9eef148ede..d70495aee5 100644 --- a/docs/guides/android/adb-device-analysis.md +++ b/docs/guides/android/adb-device-analysis.md @@ -188,7 +188,7 @@ and during shutdown. property value remains available in `properties`. - **Device paths are not host paths.** `base_apk_device_path` values live on the device filesystem. Only pulls and captures produce host-local paths, - each paired with byte size and SHA-256 digest. A pulled file's host name is + with byte size and SHA-256 digest on success. A pulled file's host name is the device path's final POSIX segment. REA preserves the original device path and filename; a missing final segment or `.`/`..` is refused before output creation. Windows hosts follow [Windows filename rules](https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file), @@ -249,6 +249,10 @@ and during shutdown. - Pull and capture outputs are additive: the destination must not already exist. Re-running a completed pull refuses the existing directory instead of overwriting evidence. +- If a single-file pull fails and REA observes a regular file at the destination, + the error includes its `local_path` with `transfer_status: "unconfirmed"` + and `digest_status: "unknown"`. The file remains in place; the receipt does + not establish that the transfer completed. - Cancellation during a package pull preserves caller-selected output. The error includes partial observations identifying completed artifacts and the interrupted local path; remaining APKs were not attempted. diff --git a/src/adb/AdbProvider.ts b/src/adb/AdbProvider.ts index 687a9fb82c..b31a1dfc88 100644 --- a/src/adb/AdbProvider.ts +++ b/src/adb/AdbProvider.ts @@ -2333,7 +2333,26 @@ export class AdbProvider { { timeoutMs: PULL_TIMEOUT_MS, maxOutputBytes: PULL_MAX_OUTPUT_BYTES }, signal, ); - if (!pulled.ok) return pulled; + if (!pulled.ok) { + // A failed adb process can leave caller-visible bytes at the selected + // destination. Report only an observed regular file; its transfer and + // digest are unconfirmed, and the caller owns the output. + const output = await lstat(localPath).catch(() => undefined); + if (output?.isFile() === true && !output.isSymbolicLink()) + pulled.error.retainPartialObservation({ + provider_id: "adb", + operation, + result: { + client, + serial: input.serial, + local_path: localPath, + device_path: input.device_path, + digest_status: "unknown", + transfer_status: "unconfirmed", + }, + }); + return pulled; + } try { const digest = await hashAdbFile( localPath, diff --git a/src/domain/adb/adbDeviceAnalysis.ts b/src/domain/adb/adbDeviceAnalysis.ts index 740e6d41bd..cff26cf708 100644 --- a/src/domain/adb/adbDeviceAnalysis.ts +++ b/src/domain/adb/adbDeviceAnalysis.ts @@ -892,6 +892,7 @@ export type AdbAcquiredFilePartialObservation = readonly local_path: string; readonly device_path: string; readonly digest_status: "unknown"; + readonly transfer_status?: "unconfirmed"; } | z.infer; } diff --git a/tests/boundary/providers/adb/adbSelectedLocalPaths.test.ts b/tests/boundary/providers/adb/adbSelectedLocalPaths.test.ts index 79c453e2b3..d636ffbfac 100644 --- a/tests/boundary/providers/adb/adbSelectedLocalPaths.test.ts +++ b/tests/boundary/providers/adb/adbSelectedLocalPaths.test.ts @@ -11,6 +11,7 @@ import { dirname, join } from "node:path"; import { expect, onTestFinished, test, vi } from "vitest"; import { AdbProvider } from "../../../../src/adb/AdbProvider.js"; +import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; import { AdbDeviceAnalysisService } from "../../../../src/application/adb/AdbDeviceAnalysisService.js"; import { PrivateRuntimeRoot } from "../../../../src/process/PrivateRuntimeRoot.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; @@ -246,3 +247,89 @@ it.each([ } }, ); + +it.each([true, false])( + "reports only observed output when adb pull fails (file created: %s)", + async (created) => { + const root = await createTestTempDirectory("rea-adb-partial-pull-"); + const binary = join(root, "adb"); + await writeFile( + binary, + `#!/bin/sh +if [ "$1" = version ]; then printf 'Android Debug Bridge version 1.0.41\\nVersion 34.0.5-stub\\nInstalled as %s\\n' "$0"; exit 0; fi +if [ "$3" = pull ]; then ${created ? `printf 'partial bytes' > "$5";` : ""} printf 'transfer interrupted\\n' >&2; exit 1; fi +exit 1 +`, + { mode: 0o755 }, + ); + const provider = new AdbProvider({ environment: { REA_ADB_PATH: binary } }); + onTestFinished(() => provider.close()); + const result = await provider.execute({ + operation: "pull_adb_file", + input: { + serial: "fixture", + device_path: "/sdcard/payload.bin", + output_directory: root, + }, + }); + if (result.ok) throw new Error("expected failed pull"); + if (created) { + expect(await readFile(join(root, "payload.bin"), "utf8")).toBe( + "partial bytes", + ); + expect( + projectAnalysisError(result.error).details?.partial_observation, + ).toMatchObject({ + operation: "pull_adb_file", + result: { + local_path: join(root, "payload.bin"), + digest_status: "unknown", + transfer_status: "unconfirmed", + }, + }); + } else { + await expect(access(join(root, "payload.bin"))).rejects.toMatchObject({ + code: "ENOENT", + }); + expect(result.error.partialObservation).toBeUndefined(); + } + }, +); + +it.each(["file", "symlink"] as const)( + "refuses a preexisting %s destination without invoking adb pull", + async (kind) => { + const root = await createTestTempDirectory("rea-adb-existing-pull-"); + const binary = join(root, "adb"); + const destination = join(root, "payload.bin"); + const source = join(root, "original"); + await writeFile(source, "original bytes"); + if (kind === "file") await writeFile(destination, "original bytes"); + else await symlink(source, destination); + await writeFile( + binary, + `#!/bin/sh +if [ "$1" = version ]; then printf 'Android Debug Bridge version 1.0.41\\nVersion 34.0.5-stub\\nInstalled as %s\\n' "$0"; exit 0; fi +if [ "$3" = pull ]; then printf 'invoked' > "$0.called"; exit 1; fi +exit 1 +`, + { mode: 0o755 }, + ); + const provider = new AdbProvider({ environment: { REA_ADB_PATH: binary } }); + onTestFinished(() => provider.close()); + const result = await provider.execute({ + operation: "pull_adb_file", + input: { + serial: "fixture", + device_path: "/sdcard/payload.bin", + output_directory: root, + }, + }); + if (result.ok) throw new Error("expected destination refusal"); + expect(result.error.partialObservation).toBeUndefined(); + expect(await readFile(destination, "utf8")).toBe("original bytes"); + await expect(access(`${binary}.called`)).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +);