fix(adb): bind transfer evidence to consumed snapshots

Push and install now consume private copies whose stable hashes identify the
transferred bytes. Preserve original caller paths, remote side-effect receipts
and cancellation through final cleanup. Retain failed snapshot owners in the
existing directory cleanup lifecycle and remove redundant stored identity.

Validated path replacement through real subprocess stubs and a public CLI
negative/positive reproduction, plus capacity, cancellation and cleanup retry.
The small cold/warm CLI comparison does not establish a speed improvement.
Real Android devices and Windows private-root behavior were not exercised.
Refs #4252.
This commit is contained in:
morluto
2026-10-16 05:44:12 +08:00
parent 8873b39770
commit 1342127e91
4 changed files with 655 additions and 196 deletions
+7
View File
@@ -108,6 +108,13 @@ The `replaced` result is `null`: the successful install response does not say
whether the package was installed fresh or replaced, and REA does not query the
device first. The install verdict and host APK digest remain available.
Push and install consume a private snapshot of the selected local input. The
reported size and SHA-256 identify those consumed bytes; `local_path` and
`apk_path` retain the original input path, whose contents may change later.
Snapshot creation needs temporary disk space proportional to the input size.
Failed snapshot cleanup remains owned and is retried before later operations
and during shutdown.
## Interpret the result
- **Directory names retain whitespace and newlines.** Symlink targets do too,
+63
View File
@@ -880,6 +880,42 @@ exit 1`,
});
});
it("binds a push digest to the bytes adb consumes after caller-path replacement", async () => {
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
printf 'replacement-payload' > "${stubRoot}/payload.bin"
cat "$4" > "${stubRoot}/consumed.bin" || exit 1
exit 0
fi
if [ "$1" = "-s" ] && [ "$4" = "sha256sum" ]; then exit 1; fi
exit 1`,
);
const localFile = join(root, "payload.bin");
await writeFile(localFile, "original-payload");
const result = await evidenceResult(providerOver(binary), {
operation: "push_adb_file",
input: {
serial: "emulator-5554",
local_path: localFile,
device_path: "/data/local/tmp/payload.bin",
overwrite: false,
},
});
const consumedDigest = createHash("sha256")
.update(await readFile(join(root, "consumed.bin")))
.digest("hex");
expect(result).toMatchObject({
local_path: localFile,
sha256: consumedDigest,
});
expect(await readFile(localFile, "utf8")).toBe("replacement-payload");
expect(await readFile(join(root, "consumed.bin"), "utf8")).toBe(
"original-payload",
);
});
it("reports a digest mismatch after push as a changed artifact", async () => {
const { binary, root } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
@@ -1279,6 +1315,33 @@ exit 1`,
expect(projected.message).toContain("INSTALL_FAILED_ALREADY_EXISTS");
});
it("binds an install digest to the bytes adb consumes after caller-path replacement", async () => {
const { binary, root } = await writeAdbStub(
(stubRoot) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "install" ]; then
printf 'replacement-apk' > "${stubRoot}/app.apk"
cat "$4" > "${stubRoot}/consumed.bin" || exit 1
printf 'Success\\n'
exit 0
fi
exit 1`,
);
const apk = join(root, "app.apk");
await writeFile(apk, "original-apk");
const result = await evidenceResult(providerOver(binary), {
operation: "install_adb_package",
input: { serial: "emulator-5554", apk_path: apk, replace: false },
});
const consumedDigest = createHash("sha256")
.update(await readFile(join(root, "consumed.bin")))
.digest("hex");
expect(result).toMatchObject({ apk_path: apk, sha256: consumedDigest });
expect(await readFile(apk, "utf8")).toBe("replacement-apk");
expect(await readFile(join(root, "consumed.bin"), "utf8")).toBe(
"original-apk",
);
});
it("uninstalls a package and reports the device's refusal for absent ones", async () => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
+359 -196
View File
@@ -30,6 +30,7 @@ import type {
import { adbResultSchemas } from "../domain/adb/adbDeviceAnalysis.js";
import {
AnalysisArtifactChangedError,
AnalysisCapabilityUnavailableError,
AnalysisCancelledError,
AnalysisInputError,
AnalysisOutputError,
@@ -38,11 +39,14 @@ import {
AnalysisTimeoutError,
AnalysisUnsupportedTargetError,
} from "../domain/analysisErrorCore.js";
import { createEvidence } from "../domain/evidence.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { err, ok, type Result } from "../domain/result.js";
import { hashStableFile } from "../filesystem/StableFileHash.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
import { PrivateRuntimeRootUnavailableError } from "../process/PrivateRuntimeRootUnavailableError.js";
import {
execFileBytes,
execFileOutputFailure,
@@ -373,27 +377,30 @@ export interface AdbProviderOptions {
readonly environment?: Readonly<Record<string, string | undefined>>;
}
interface PendingOutputDirectoryCleanup {
readonly operation: "pull_adb_package" | "collect_adb_bugreport";
interface PendingAdbDirectoryCleanup {
readonly operation:
| "pull_adb_package"
| "collect_adb_bugreport"
| "push_adb_file"
| "install_adb_package";
readonly path: string;
readonly identity: OutputDirectoryIdentity | undefined;
readonly primary: AnalysisError;
readonly remove: () => Promise<void>;
}
interface OutputDirectoryIdentity {
interface OwnedDirectoryIdentity {
readonly dev: number;
readonly ino: number;
readonly mode: number;
}
const removeOwnedOutputDirectory = async (
const verifyOwnedDirectory = async (
path: string,
identity: OutputDirectoryIdentity | undefined,
identity: OwnedDirectoryIdentity | undefined,
): Promise<void> => {
if (identity === undefined)
throw new Error(
`Cannot verify the created output directory identity; refusing to remove ${path}`,
`Cannot verify the created directory identity; refusing to remove ${path}`,
);
let current: Awaited<ReturnType<typeof lstat>>;
try {
@@ -411,11 +418,32 @@ const removeOwnedOutputDirectory = async (
current.mode !== identity.mode
)
throw new Error(
`The created output directory was replaced or changed; refusing to remove ${path}`,
`The created directory was replaced or changed; refusing to remove ${path}`,
);
};
const removeOwnedDirectory = async (
path: string,
identity: OwnedDirectoryIdentity | undefined,
): Promise<void> => {
await verifyOwnedDirectory(path, identity);
await rm(path, { recursive: true, force: true });
};
const executionEvidence = (
execution: AnalysisExecution,
operation: "push_adb_file" | "install_adb_package" | "capture_adb_screen",
parameters: Readonly<Record<string, string | boolean>>,
) =>
createEvidence(execution.subject ?? undefined, execution.provider, {
operation,
parameters,
result: execution.result,
confidence: "observed",
locations: execution.locations,
limitations: execution.limitations,
});
const reservePackageDestinations = async (
operation: string,
packageName: string,
@@ -434,7 +462,7 @@ const reservePackageDestinations = async (
readonly path: string;
readonly file_name: string;
readonly device_path: string;
readonly identity: OutputDirectoryIdentity;
readonly identity: OwnedDirectoryIdentity;
}[] = [];
for (const entry of entries) {
const path = join(packageDirectory, entry.file_name);
@@ -533,12 +561,11 @@ const reservePackageDestinations = async (
*/
export class AdbProvider {
private readonly environment: Readonly<Record<string, string | undefined>>;
readonly #pendingOutputDirectoryCleanup =
new Set<PendingOutputDirectoryCleanup>();
readonly #pendingDirectoryCleanup = new Set<PendingAdbDirectoryCleanup>();
readonly #activeOperations = new Set<Promise<void>>();
#closing = false;
#closePromise: Promise<void> | undefined;
#outputCleanupPromise: Promise<void> | undefined;
#directoryCleanupPromise: Promise<void> | undefined;
constructor(options: AdbProviderOptions = {}) {
this.environment = options.environment ?? process.env;
@@ -571,13 +598,13 @@ export class AdbProvider {
}
}
/** Retry removal of owned output directories retained after failed cleanup. */
/** Retry removal of owned ADB directories retained after failed cleanup. */
close(): Promise<void> {
this.#closing = true;
if (this.#closePromise !== undefined) return this.#closePromise;
const closing = (async () => {
await Promise.all(this.#activeOperations);
await this.#retryOutputDirectoryCleanup();
await this.#retryDirectoryCleanup();
})().finally(() => {
if (this.#closePromise === closing) this.#closePromise = undefined;
});
@@ -585,23 +612,23 @@ export class AdbProvider {
return this.#closePromise;
}
#retryOutputDirectoryCleanup(): Promise<void> {
if (this.#outputCleanupPromise !== undefined)
return this.#outputCleanupPromise;
const cleanup = this.#performOutputDirectoryCleanup().finally(() => {
if (this.#outputCleanupPromise === cleanup)
this.#outputCleanupPromise = undefined;
#retryDirectoryCleanup(): Promise<void> {
if (this.#directoryCleanupPromise !== undefined)
return this.#directoryCleanupPromise;
const cleanup = this.#performDirectoryCleanup().finally(() => {
if (this.#directoryCleanupPromise === cleanup)
this.#directoryCleanupPromise = undefined;
});
this.#outputCleanupPromise = cleanup;
this.#directoryCleanupPromise = cleanup;
return cleanup;
}
async #performOutputDirectoryCleanup(): Promise<void> {
async #performDirectoryCleanup(): Promise<void> {
const failures: AnalysisError[] = [];
for (const owner of this.#pendingOutputDirectoryCleanup) {
for (const owner of this.#pendingDirectoryCleanup) {
try {
await owner.remove();
this.#pendingOutputDirectoryCleanup.delete(owner);
this.#pendingDirectoryCleanup.delete(owner);
} catch (cause: unknown) {
const cleanup = new ProviderCleanupError(
ADB_PROVIDER_IDENTITY.id,
@@ -624,10 +651,10 @@ export class AdbProvider {
if (failures.length > 1)
throw new ProviderCleanupError(
ADB_PROVIDER_IDENTITY.id,
[...this.#pendingOutputDirectoryCleanup].map(({ path }) => path),
[...this.#pendingDirectoryCleanup].map(({ path }) => path),
{
reason: "Failed ADB output directories remain after cleanup retry",
operations: [...this.#pendingOutputDirectoryCleanup].map(
reason: "Failed ADB directories remain after cleanup retry",
operations: [...this.#pendingDirectoryCleanup].map(
({ operation }) => operation,
),
failures: failures.map(({ message, cleanupResources }) => ({
@@ -639,12 +666,137 @@ export class AdbProvider {
operation: "close_binary",
cause: new AggregateError(
failures,
"Multiple ADB output directory cleanups failed",
"Multiple ADB directory cleanups failed",
),
},
);
}
private async withInputSnapshot(
operation: "push_adb_file" | "install_adb_package",
sourcePath: string,
parameters: Readonly<Record<string, string | boolean>>,
signal: AbortSignal | undefined,
consume: (
path: string,
digest: Awaited<ReturnType<typeof hashAdbFile>>,
) => Promise<Result<AnalysisExecution, AnalysisError>>,
): Promise<Result<AnalysisExecution, AnalysisError>> {
let root: PrivateRuntimeRoot | undefined;
let identity: OwnedDirectoryIdentity | undefined;
let result: Result<AnalysisExecution, AnalysisError>;
try {
signal?.throwIfAborted();
root = await PrivateRuntimeRoot.create({ prefix: "rea-adb-input-" });
const created = await lstat(root.path);
if (!created.isDirectory() || created.isSymbolicLink())
throw new Error(
`The created input snapshot root is not a directory: ${root.path}`,
);
identity = {
dev: created.dev,
ino: created.ino,
mode: created.mode,
};
const snapshotPath = join(root.path, basename(sourcePath));
await copyFile(sourcePath, snapshotPath, fsConstants.COPYFILE_EXCL);
const digest = await hashAdbFile(snapshotPath, operation, signal);
signal?.throwIfAborted();
result = await consume(snapshotPath, digest);
} catch (cause: unknown) {
result = err(
cause instanceof AnalysisError
? cause
: cause instanceof PrivateRuntimeRootUnavailableError
? new AnalysisCapabilityUnavailableError(
ADB_PROVIDER_IDENTITY.id,
operation,
cause.message,
{ cause, retryable: cause.retryable },
)
: signal?.aborted
? new AnalysisCancelledError(operation, { cause })
: new AnalysisOutputError(
operation,
`The caller input could not be staged for adb: ${sourcePath}`,
{ cause },
),
);
}
if (root !== undefined) {
const remove = async () => {
await verifyOwnedDirectory(root!.path, identity);
await root!.close();
};
try {
await remove();
} catch (cause: unknown) {
const cleanupReason =
cause instanceof Error ? cause.message : String(cause);
const cleanup = new ProviderCleanupError(
ADB_PROVIDER_IDENTITY.id,
[root.path],
{ reason: cleanupReason },
{ operation, cause },
);
const partialObservation = result.ok
? executionEvidence(result.value, operation, parameters)
: result.error.partialObservation;
const primary =
result.ok && signal?.aborted === true
? new AnalysisCancelledError(operation, {
cause,
partialObservation,
cleanup: {
reason: cleanupReason,
resources: [root.path],
},
})
: result.ok
? new AnalysisOutputError(
operation,
"The adb operation completed, but its private input snapshot could not be removed",
{ cause, partialObservation },
)
: result.error instanceof AnalysisCancelledError
? new AnalysisCancelledError(operation, {
cause: result.error,
partialObservation,
cleanup: {
reason: cleanupReason,
resources: [root.path],
},
})
: result.error;
const cleanupOwner: PendingAdbDirectoryCleanup = {
operation,
path: root.path,
primary,
remove,
};
this.#pendingDirectoryCleanup.add(cleanupOwner);
return err(
primary instanceof AnalysisCancelledError
? primary
: analysisErrorWithCleanupFailure(primary, cleanup, operation),
);
}
}
if (result.ok && signal?.aborted === true)
return err(
new AnalysisCancelledError(operation, {
partialObservation: executionEvidence(
result.value,
operation,
parameters,
),
}),
);
return result;
}
async execute(
request: AdbRequest,
options?: ExecutionOptions,
@@ -657,18 +809,16 @@ export class AdbProvider {
});
this.#activeOperations.add(active);
try {
if (this.#pendingOutputDirectoryCleanup.size > 0) {
if (this.#pendingDirectoryCleanup.size > 0) {
try {
await this.#retryOutputDirectoryCleanup();
await this.#retryDirectoryCleanup();
} catch (cause: unknown) {
return err(
cause instanceof AnalysisError
? cause
: new ProviderCleanupError(
ADB_PROVIDER_IDENTITY.id,
[...this.#pendingOutputDirectoryCleanup].map(
({ path }) => path,
),
[...this.#pendingDirectoryCleanup].map(({ path }) => path),
{
reason:
cause instanceof Error ? cause.message : String(cause),
@@ -1394,7 +1544,7 @@ export class AdbProvider {
if (!directory.ok) return directory;
const packageDirectory = join(directory.value, packageName);
let packageDirectoryCreated = false;
let packageDirectoryIdentity: OutputDirectoryIdentity | undefined;
let packageDirectoryIdentity: OwnedDirectoryIdentity | undefined;
try {
if (
await lstat(packageDirectory).then(
@@ -1424,18 +1574,14 @@ export class AdbProvider {
{ cause },
);
if (!packageDirectoryCreated) return err(primary);
const owner: PendingOutputDirectoryCleanup = {
const owner: PendingAdbDirectoryCleanup = {
operation,
path: packageDirectory,
identity: packageDirectoryIdentity,
primary,
remove: () =>
removeOwnedOutputDirectory(
packageDirectory,
packageDirectoryIdentity,
),
removeOwnedDirectory(packageDirectory, packageDirectoryIdentity),
};
this.#pendingOutputDirectoryCleanup.add(owner);
this.#pendingDirectoryCleanup.add(owner);
const cleanup = new ProviderCleanupError(
ADB_PROVIDER_IDENTITY.id,
[packageDirectory],
@@ -1449,21 +1595,17 @@ export class AdbProvider {
const failOwnedPackage = async (
primary: AnalysisError,
): Promise<Result<AnalysisExecution, AnalysisError>> => {
const owner: PendingOutputDirectoryCleanup = {
const owner: PendingAdbDirectoryCleanup = {
operation,
path: packageDirectory,
identity: packageDirectoryIdentity,
primary,
remove: () =>
removeOwnedOutputDirectory(
packageDirectory,
packageDirectoryIdentity,
),
removeOwnedDirectory(packageDirectory, packageDirectoryIdentity),
};
try {
await owner.remove();
} catch (cause: unknown) {
this.#pendingOutputDirectoryCleanup.add(owner);
this.#pendingDirectoryCleanup.add(owner);
const cleanup = new ProviderCleanupError(
ADB_PROVIDER_IDENTITY.id,
[packageDirectory],
@@ -1718,105 +1860,127 @@ export class AdbProvider {
cause: new Error(`The local file does not exist: ${localPath}`),
}),
);
let digest: Awaited<ReturnType<typeof hashAdbFile>>;
try {
digest = await hashAdbFile(localPath, operation, signal);
} catch (cause: unknown) {
if (cause instanceof AnalysisCancelledError) return err(cause);
throw cause;
}
const existsProbe = await runAdb(
return this.withInputSnapshot(
operation,
input.device_path,
selection.binary,
adbArguments(input.serial, ["shell", "test", "-e", input.device_path]),
{ timeoutMs: SETTINGS_TIMEOUT_MS, maxOutputBytes: 1024 },
localPath,
input,
signal,
);
// `test -e` exits nonzero with no adb diagnosis when the path is absent.
// Any other probe failure leaves existence unknown, so it is reported as
// is rather than read as an existing path or followed by a push.
if (
!existsProbe.ok &&
!(existsProbe.error instanceof AnalysisProtocolError)
)
return existsProbe;
const deviceExists = existsProbe.ok;
if (deviceExists && !input.overwrite)
return err(
new AnalysisOutputError(
async (snapshotPath, digest) => {
const existsProbe = await runAdb(
operation,
`The device path already exists and overwrite was not requested: ${input.device_path}`,
),
);
const pushed = await runAdb(
operation,
input.device_path,
selection.binary,
adbArguments(input.serial, ["push", localPath, input.device_path]),
{ timeoutMs: PULL_TIMEOUT_MS, maxOutputBytes: PULL_MAX_OUTPUT_BYTES },
signal,
);
if (!pushed.ok) return pushed;
const deviceDigest = await runAdb(
operation,
input.device_path,
selection.binary,
adbArguments(input.serial, ["shell", "sha256sum", input.device_path]),
{ timeoutMs: SHELL_TIMEOUT_MS, maxOutputBytes: 1024 },
signal,
);
if (deviceDigest.ok) {
const digestMatch = /^([a-f0-9]{64})\s/u.exec(deviceDigest.value.stdout);
if (digestMatch !== null && digestMatch[1] !== digest.sha256)
return err(
new AnalysisArtifactChangedError(
operation,
input.device_path,
selection.binary,
adbArguments(input.serial, [
"shell",
"test",
"-e",
input.device_path,
`The device-reported digest does not match the pushed local file: ${localPath}`,
),
]),
{ timeoutMs: SETTINGS_TIMEOUT_MS, maxOutputBytes: 1024 },
signal,
);
return ok(
createAnalysisExecution(
{
client,
serial: input.serial,
local_path: localPath,
device_path: input.device_path,
bytes: digest.bytes,
sha256: digest.sha256,
device_sha256: digestMatch?.[1] ?? null,
overwritten: deviceExists,
device_digest_source: "device_sha256sum",
},
provider,
{
limitations: ["Push writes the caller's file to the device"],
},
),
);
}
return ok(
createAnalysisExecution(
{
client,
serial: input.serial,
local_path: localPath,
device_path: input.device_path,
bytes: digest.bytes,
sha256: digest.sha256,
device_sha256: null,
overwritten: deviceExists,
device_digest_source: "unavailable",
},
provider,
{
limitations: [
"The device did not answer sha256sum, so the transfer is not device-side verified",
"Push writes the caller's file to the device",
],
},
),
// `test -e` exits nonzero with no adb diagnosis when the path is absent.
// Any other probe failure leaves existence unknown, so it is reported as
// is rather than read as an existing path or followed by a push.
if (
!existsProbe.ok &&
!(existsProbe.error instanceof AnalysisProtocolError)
)
return existsProbe;
const deviceExists = existsProbe.ok;
if (deviceExists && !input.overwrite)
return err(
new AnalysisOutputError(
operation,
`The device path already exists and overwrite was not requested: ${input.device_path}`,
),
);
const pushed = await runAdb(
operation,
input.device_path,
selection.binary,
adbArguments(input.serial, ["push", snapshotPath, input.device_path]),
{ timeoutMs: PULL_TIMEOUT_MS, maxOutputBytes: PULL_MAX_OUTPUT_BYTES },
signal,
);
if (!pushed.ok) return pushed;
const createPushReceipt = (
deviceSha256: string | null,
source: "device_sha256sum" | "unavailable",
additionalLimitations: readonly string[] = [],
) =>
createAnalysisExecution(
{
client,
serial: input.serial,
local_path: localPath,
device_path: input.device_path,
bytes: digest.bytes,
sha256: digest.sha256,
device_sha256: deviceSha256,
overwritten: deviceExists,
device_digest_source: source,
},
provider,
{
limitations: [
"Push writes the caller's file to the device",
...additionalLimitations,
],
},
);
const deviceDigest = await runAdb(
operation,
input.device_path,
selection.binary,
adbArguments(input.serial, ["shell", "sha256sum", input.device_path]),
{ timeoutMs: SHELL_TIMEOUT_MS, maxOutputBytes: 1024 },
signal,
);
if (!deviceDigest.ok) {
const cancelled =
deviceDigest.error instanceof AnalysisCancelledError;
const receipt = createPushReceipt(null, "unavailable", [
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(
executionEvidence(receipt, operation, input),
);
return cancelled ? err(deviceDigest.error) : ok(receipt);
}
const digestMatch = /^([a-f0-9]{64})\s/u.exec(
deviceDigest.value.stdout,
);
const receipt = createPushReceipt(
digestMatch?.[1] ?? null,
"device_sha256sum",
digestMatch !== null && digestMatch[1] !== digest.sha256
? [
"The device-reported digest differs from the staged input digest",
]
: [],
);
if (digestMatch !== null && digestMatch[1] !== digest.sha256) {
return err(
new AnalysisArtifactChangedError(
operation,
input.device_path,
`The device-reported digest does not match the pushed local file: ${localPath}`,
{
partialObservation: executionEvidence(
receipt,
operation,
input,
),
},
),
);
}
return ok(receipt);
},
);
}
@@ -1843,58 +2007,59 @@ export class AdbProvider {
cause: new Error(`The local APK does not exist: ${apkPath}`),
}),
);
let digest: Awaited<ReturnType<typeof hashAdbFile>>;
try {
digest = await hashAdbFile(apkPath, operation, signal);
} catch (cause: unknown) {
if (cause instanceof AnalysisCancelledError) return err(cause);
throw cause;
}
const installed = await runAdb(
return this.withInputSnapshot(
operation,
input.serial,
selection.binary,
adbArguments(input.serial, [
"install",
...(input.replace ? ["-r"] : []),
apkPath,
]),
{ timeoutMs: PULL_TIMEOUT_MS, maxOutputBytes: PULL_MAX_OUTPUT_BYTES },
apkPath,
input,
signal,
);
if (!installed.ok)
return (
packageChangeRefusal(operation, input.serial, installed.error) ??
installed
);
const verdict = parsePackageChangeOutput(installed.value.stdout);
if (verdict.status === "failure")
return err(
new AnalysisUnsupportedTargetError(
async (snapshotPath, digest) => {
const installed = await runAdb(
operation,
input.serial,
verdict.reason ?? "the device refused the installation",
),
);
return ok(
createAnalysisExecution(
{
client,
serial: input.serial,
apk_path: apkPath,
bytes: digest.bytes,
sha256: digest.sha256,
replace_requested: input.replace,
replaced: null,
},
provider,
{
limitations: [
"Install changes device state; the installed APK's digest is the host file's digest",
"Whether an existing package was replaced is unknown; replace_requested records only the adb install mode",
],
},
),
selection.binary,
adbArguments(input.serial, [
"install",
...(input.replace ? ["-r"] : []),
snapshotPath,
]),
{ timeoutMs: PULL_TIMEOUT_MS, maxOutputBytes: PULL_MAX_OUTPUT_BYTES },
signal,
);
if (!installed.ok)
return (
packageChangeRefusal(operation, input.serial, installed.error) ??
installed
);
const verdict = parsePackageChangeOutput(installed.value.stdout);
if (verdict.status === "failure")
return err(
new AnalysisUnsupportedTargetError(
operation,
input.serial,
verdict.reason ?? "the device refused the installation",
),
);
return ok(
createAnalysisExecution(
{
client,
serial: input.serial,
apk_path: apkPath,
bytes: digest.bytes,
sha256: digest.sha256,
replace_requested: input.replace,
replaced: null,
},
provider,
{
limitations: [
"Install changes device state; the installed APK's digest is the host file's digest",
"Whether an existing package was replaced is unknown; replace_requested records only the adb install mode",
],
},
),
);
},
);
}
@@ -2270,7 +2435,7 @@ export class AdbProvider {
);
if (!directory.ok) return directory;
let stagingDirectory: string;
let stagingIdentity: OutputDirectoryIdentity | undefined;
let stagingIdentity: OwnedDirectoryIdentity | undefined;
let retainedObservation: AdbAcquiredFilePartialObservation | undefined;
let publishedPath: string | undefined;
try {
@@ -2400,7 +2565,7 @@ export class AdbProvider {
if (retainedObservation !== undefined && publishedPath === undefined)
return collectedResult;
try {
await removeOwnedOutputDirectory(stagingDirectory, stagingIdentity);
await removeOwnedDirectory(stagingDirectory, stagingIdentity);
} catch (cause) {
const primary = collectedResult.ok
? new AnalysisOutputError(
@@ -2410,13 +2575,11 @@ export class AdbProvider {
: collectedResult.error;
if (retainedObservation !== undefined)
primary.retainPartialObservation(retainedObservation);
this.#pendingOutputDirectoryCleanup.add({
this.#pendingDirectoryCleanup.add({
operation,
path: stagingDirectory,
identity: stagingIdentity,
primary,
remove: () =>
removeOwnedOutputDirectory(stagingDirectory, stagingIdentity),
remove: () => removeOwnedDirectory(stagingDirectory, stagingIdentity),
});
return err(
analysisErrorWithCleanupFailure(
@@ -0,0 +1,226 @@
import { createHash } from "node:crypto";
import {
copyFile,
lstat,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import { afterEach, expect, onTestFinished, test, vi } from "vitest";
import { AdbProvider } from "../../../../src/adb/AdbProvider.js";
import {
execFileBytes,
execFileOutput,
} from "../../../../src/process/ExecFileOutput.js";
import { parseEvidence } from "../../../../src/domain/evidence.js";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
vi.mock(
"../../../../src/process/ExecFileOutput.js",
async (importOriginal) => ({
...(await importOriginal<
typeof import("../../../../src/process/ExecFileOutput.js")
>()),
execFileBytes: vi.fn(),
execFileOutput: vi.fn(),
}),
);
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return {
...actual,
copyFile: vi.fn(actual.copyFile),
mkdtemp: vi.fn(actual.mkdtemp),
rm: vi.fn(actual.rm),
};
});
afterEach(() => vi.restoreAllMocks());
const fixture = async () => {
const root = await createTestTempDirectory("rea-adb-input-owner-");
const binary = join(root, "adb");
const source = join(root, "payload.bin");
const bytes = Buffer.from("immutable payload");
await writeFile(source, bytes);
const digest = createHash("sha256").update(bytes).digest("hex");
let snapshotPath = "";
let consumedBytes: Buffer | undefined;
const output = (stdout: string) => ({
stdout,
stderr: "",
stdoutBytes: Buffer.byteLength(stdout),
stderrBytes: 0,
});
vi.mocked(execFileOutput).mockImplementation(async (_binary, args) => {
if (args[0] === "version")
return output(
`Android Debug Bridge version 1.0.41\nVersion 34.0.5-stub\nInstalled as ${binary}\n`,
);
return output("");
});
vi.mocked(execFileBytes).mockImplementation(async (_binary, args) => {
if (args[2] === "push") {
snapshotPath = args[3]!;
consumedBytes = await readFile(snapshotPath);
if (consumedBytes.byteLength === 0)
throw new Error("adb received an empty local snapshot");
}
if (args[3] === "sha256sum") {
if (consumedBytes === undefined)
throw new Error("checksum requested before a push was consumed");
const consumedDigest = createHash("sha256")
.update(consumedBytes)
.digest("hex");
return {
stdout: Buffer.from(`${consumedDigest} /data/local/tmp/payload.bin\n`),
stderr: Buffer.alloc(0),
};
}
return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) };
});
const provider = new AdbProvider({ environment: { REA_ADB_PATH: binary } });
onTestFinished(() => provider.close());
return {
root,
source,
digest,
snapshotPath: () => snapshotPath,
provider,
request: {
operation: "push_adb_file" as const,
input: {
serial: "device",
local_path: source,
device_path: "/data/local/tmp/payload.bin",
overwrite: true,
},
},
};
};
test("retains failed snapshot cleanup ownership and retries it on close", async () => {
const f = await fixture();
const actualFs =
await vi.importActual<typeof import("node:fs/promises")>(
"node:fs/promises",
);
vi.mocked(rm).mockImplementationOnce(async (path, options) => {
if (basename(String(path)).startsWith("rea-adb-input-"))
throw new Error("snapshot cleanup denied");
return actualFs.rm(path, options);
});
const result = await f.provider.execute(f.request);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected snapshot cleanup failure");
expect(result.error.cleanupResources).toContain(dirname(f.snapshotPath()));
const evidence = parseEvidence(result.error.partialObservation);
expect(evidence).toMatchObject({
provider: { id: "adb" },
operation: "push_adb_file",
confidence: "observed",
parameters: { local_path: f.source },
normalized_result: {
local_path: f.source,
bytes: 17,
sha256: f.digest,
},
});
await expect(readFile(f.snapshotPath())).resolves.toEqual(
await readFile(f.source),
);
await f.provider.close();
await expect(lstat(dirname(f.snapshotPath()))).rejects.toMatchObject({
code: "ENOENT",
});
});
test.each([false, true])(
"preserves cancellation and push receipt during final snapshot cleanup (cleanup failure: %s)",
async (failCleanup) => {
const f = await fixture();
const controller = new AbortController();
const actualFs =
await vi.importActual<typeof import("node:fs/promises")>(
"node:fs/promises",
);
vi.mocked(rm).mockImplementationOnce(async (path, options) => {
if (basename(String(path)).startsWith("rea-adb-input-")) {
controller.abort();
if (failCleanup) throw new Error("snapshot cleanup denied");
}
return actualFs.rm(path, options);
});
const result = await f.provider.execute(f.request, {
signal: controller.signal,
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
const evidence = parseEvidence(result.error.partialObservation);
expect(evidence).toMatchObject({
provider: { id: "adb" },
operation: "push_adb_file",
confidence: "observed",
parameters: { local_path: f.source },
normalized_result: {
local_path: f.source,
bytes: 17,
sha256: f.digest,
device_path: "/data/local/tmp/payload.bin",
},
});
if (failCleanup)
expect(result.error.cleanupResources).toContain(
dirname(f.snapshotPath()),
);
else expect(result.error.cleanupIncomplete).toBe(false);
expect(await readFile(f.source)).toEqual(Buffer.from("immutable payload"));
await f.provider.close();
},
);
test("cancels after the input snapshot copy and before contacting the device", async () => {
const f = await fixture();
const controller = new AbortController();
const actualFs =
await vi.importActual<typeof import("node:fs/promises")>(
"node:fs/promises",
);
vi.mocked(copyFile).mockImplementationOnce(async (...args) => {
await actualFs.copyFile(...args);
controller.abort();
});
const result = await f.provider.execute(f.request, {
signal: controller.signal,
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(execFileOutput).toHaveBeenCalledTimes(1);
expect(await readFile(f.source, "utf8")).toBe("immutable payload");
await f.provider.close();
});
test("reports input snapshot capacity failures before contacting the device", async () => {
const f = await fixture();
const capacityFailure = Object.assign(new Error("no space left"), {
code: "ENOSPC",
});
vi.mocked(copyFile).mockRejectedValueOnce(capacityFailure);
const result = await f.provider.execute(f.request);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected snapshot capacity failure");
expect(result.error._tag).toBe("AnalysisOutputError");
expect(result.error.cause).toBe(capacityFailure);
expect(execFileOutput).toHaveBeenCalledTimes(1);
await f.provider.close();
});