mirror of
https://github.com/morluto/rea.git
synced 2026-11-02 21:06:04 +00:00
Send shell pm uninstall --user 0 through the shared quoted command boundary. Align tool guidance and Evidence limitations with the selected request scope while preserving device refusal diagnostics. Validation: two provider controls failed before the fix and pass afterward; three public CLI/MCP scenarios capture exact arguments and equivalent success facts; nineteen command/SDK contract checks, check:fast and docs:check pass. No real device, multi-user data preservation, vendor/APEX behavior or performance measurement. Refs #4673.
3219 lines
101 KiB
TypeScript
3219 lines
101 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { constants as fsConstants } from "node:fs";
|
|
import {
|
|
access,
|
|
copyFile,
|
|
lstat,
|
|
link,
|
|
mkdtemp,
|
|
mkdir,
|
|
open,
|
|
rmdir,
|
|
rm,
|
|
stat,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
|
|
import type {
|
|
ExecutionOptions,
|
|
ProviderAvailability,
|
|
} from "../application/AnalysisProvider.js";
|
|
import { providerConfigurationRefusal } from "../domain/providerConfigurationRefusal.js";
|
|
import {
|
|
createAnalysisExecution,
|
|
type AnalysisExecution,
|
|
} from "../application/AnalysisProvider.js";
|
|
import type {
|
|
AdbActivityStartPartialObservation,
|
|
AdbAcquiredFilePartialObservation,
|
|
AdbPackagePullPartialObservation,
|
|
AdbRequest,
|
|
} from "../domain/adb/adbDeviceAnalysis.js";
|
|
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
|
import {
|
|
ArtifactResourceRetryFailure,
|
|
ArtifactResourceScope,
|
|
} from "../artifacts/ArtifactResourceScope.js";
|
|
import {
|
|
AnalysisCleanupLease,
|
|
AnalysisCleanupLeaseGroup,
|
|
} from "../artifacts/AnalysisCleanupLease.js";
|
|
import { adbResultSchemas } from "../domain/adb/adbDeviceAnalysis.js";
|
|
import {
|
|
AnalysisCapabilityUnavailableError,
|
|
AnalysisArtifactChangedError,
|
|
AnalysisCancelledError,
|
|
AnalysisInputError,
|
|
AnalysisOutputError,
|
|
AnalysisProtocolError,
|
|
AnalysisResourceConstraintError,
|
|
AnalysisTimeoutError,
|
|
AnalysisUnsupportedTargetError,
|
|
} from "../domain/analysisErrorCore.js";
|
|
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
|
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
|
import { err, ok, type Result } from "../domain/result.js";
|
|
import type { JsonValue } from "../domain/jsonValue.js";
|
|
import { jsonObjectSchema } from "../domain/jsonValue.js";
|
|
import {
|
|
hashStableFile,
|
|
stableFileHashCause,
|
|
} from "../filesystem/StableFileHash.js";
|
|
import {
|
|
regularFileCancellation,
|
|
RegularFileCancelled,
|
|
RegularFileCleanupFailure,
|
|
} from "../filesystem/OwnedRegularFile.js";
|
|
import { RegularFileOperationCancelled } from "../filesystem/RegularFile.js";
|
|
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
|
import { createEvidence } from "../domain/evidence.js";
|
|
import {
|
|
PrivateRuntimeRoot,
|
|
type PrivateRuntimeRootOptions,
|
|
} from "../process/PrivateRuntimeRoot.js";
|
|
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
|
import { PrivateRuntimeRootUnavailableError } from "../process/PrivateRuntimeRootUnavailableError.js";
|
|
import {
|
|
adbArguments,
|
|
runAdb,
|
|
runAdbCapture,
|
|
packageChangeRefusal,
|
|
probeAdbPathExistence,
|
|
type AdbRunLimits,
|
|
} from "./AdbCommand.js";
|
|
import {
|
|
AdbConfigurationFailure,
|
|
adbConfigurationAvailability,
|
|
inspectAdbClient,
|
|
resolveAdbBinary,
|
|
type AdbBinarySelection,
|
|
type AdbClientIdentity,
|
|
} from "./AdbConfiguration.js";
|
|
import {
|
|
displayDevicePath,
|
|
parseAdbDevicesOutput,
|
|
parseGetpropOutput,
|
|
parsePackageListOutput,
|
|
parsePmPathOutput,
|
|
} from "./AdbDeviceOutput.js";
|
|
import { adbHostFileName } from "./AdbHostFileName.js";
|
|
import {
|
|
AdbInputSnapshotFailure,
|
|
snapshotAdbInput,
|
|
} from "./AdbInputSnapshot.js";
|
|
import {
|
|
ADB_DIRECTORY_BATCH_SCRIPT,
|
|
parseAdbDirectoryListing,
|
|
} from "./AdbDirectoryListing.js";
|
|
import {
|
|
parseAmStartOutput,
|
|
parseBugreportCopiedPath,
|
|
parseDumpsysPackageOutput,
|
|
parseFeatureListOutput,
|
|
parsePackageChangeOutput,
|
|
parseProcessListOutput,
|
|
parsePngDimensions,
|
|
parseResolveActivityOutput,
|
|
parseServiceListOutput,
|
|
parseSettingsGetOutput,
|
|
parseWindowFocusOutput,
|
|
parseWmDensityOutput,
|
|
parseWmSizeOutput,
|
|
} from "./AdbShellOutput.js";
|
|
|
|
/** Stable identity for the ADB device-acquisition provider. */
|
|
export const ADB_PROVIDER_IDENTITY = {
|
|
id: "adb",
|
|
name: "Android Debug Bridge",
|
|
version: null,
|
|
} as const;
|
|
|
|
const DEVICES_TIMEOUT_MS = 20_000;
|
|
const DEVICES_MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
const SHELL_TIMEOUT_MS = 30_000;
|
|
const GETPROP_MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
|
|
const PACKAGE_LIST_TIMEOUT_MS = 60_000;
|
|
const PACKAGE_LIST_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
|
|
const PM_PATH_TIMEOUT_MS = 30_000;
|
|
const PM_PATH_MAX_OUTPUT_BYTES = 64 * 1024;
|
|
const PULL_TIMEOUT_MS = 600_000;
|
|
const PULL_MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
const LOGCAT_TIMEOUT_MS = 60_000;
|
|
const LOGCAT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
|
|
const DUMPSYS_PACKAGE_TIMEOUT_MS = 60_000;
|
|
const DUMPSYS_PACKAGE_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
|
|
const DUMPSYS_WINDOW_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
|
|
const LISTING_TIMEOUT_MS = 30_000;
|
|
const LISTING_MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
|
|
const SETTINGS_TIMEOUT_MS = 20_000;
|
|
const SETTINGS_MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
const SCREEN_TIMEOUT_MS = 60_000;
|
|
const SCREEN_MAX_OUTPUT_BYTES = 32 * 1024 * 1024;
|
|
const BUGREPORT_TIMEOUT_MS = 600_000;
|
|
const BUGREPORT_MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
const UNPARSED_LINES_REPORTED = 10;
|
|
|
|
/** Carry the exact digest owner through caller-specific partial-result mapping. */
|
|
class AdbDigestFailure extends Error {
|
|
constructor(
|
|
readonly fileFailure:
|
|
| RegularFileCleanupFailure<AdbFileDigest>
|
|
| RegularFileCancelled<AdbFileDigest>,
|
|
readonly bindCleanupError: (error: AnalysisError) => void,
|
|
) {
|
|
super(fileFailure.message, { cause: fileFailure });
|
|
}
|
|
}
|
|
|
|
const hashAdbFile = async (
|
|
path: string,
|
|
operation: string,
|
|
signal: AbortSignal | undefined,
|
|
resources: ArtifactResourceScope,
|
|
) => {
|
|
let closeLease: AnalysisCleanupLease | undefined;
|
|
try {
|
|
return await hashStableFile(path, {
|
|
...(signal === undefined ? {} : { signal }),
|
|
retainFailedClose: (handle) => {
|
|
closeLease = new AnalysisCleanupLease(() => handle.close(), [path]);
|
|
resources.retain({
|
|
kind: "lease",
|
|
resource: `${path} (ADB digest handle)`,
|
|
lease: closeLease,
|
|
});
|
|
},
|
|
});
|
|
} catch (cause: unknown) {
|
|
if (
|
|
cause instanceof RegularFileCleanupFailure ||
|
|
cause instanceof RegularFileCancelled
|
|
)
|
|
throw new AdbDigestFailure(
|
|
cause as
|
|
| RegularFileCleanupFailure<AdbFileDigest>
|
|
| RegularFileCancelled<AdbFileDigest>,
|
|
(error) => {
|
|
closeLease?.bindCleanupError(error);
|
|
},
|
|
);
|
|
if (cause instanceof RegularFileOperationCancelled)
|
|
throw new AnalysisCancelledError(operation, { cause: cause.reason });
|
|
throw cause;
|
|
}
|
|
};
|
|
|
|
const adbDigestError = (
|
|
cause: unknown,
|
|
operation: string,
|
|
partialObservation:
|
|
| AdbAcquiredFilePartialObservation
|
|
| AdbPackagePullPartialObservation,
|
|
): AnalysisError | undefined => {
|
|
if (!(cause instanceof AdbDigestFailure)) return undefined;
|
|
const digestFailure = cause.fileFailure;
|
|
const digestOutcome = digestFailure.outcome;
|
|
if (digestOutcome.kind === "completed")
|
|
partialObservation = partialObservationWithDigest(
|
|
partialObservation,
|
|
digestOutcome.value,
|
|
);
|
|
if (digestFailure instanceof RegularFileCancelled)
|
|
return new AnalysisCancelledError(operation, {
|
|
cause: digestFailure.reason,
|
|
partialObservation,
|
|
});
|
|
const resource = `${digestFailure.path} (ADB digest handle)`;
|
|
const reason =
|
|
digestFailure.cleanupCause instanceof Error
|
|
? digestFailure.cleanupCause.message
|
|
: String(digestFailure.cleanupCause);
|
|
const cleanup = new ProviderCleanupError(
|
|
ADB_PROVIDER_IDENTITY.id,
|
|
[resource],
|
|
{ reason },
|
|
{
|
|
operation,
|
|
cause: digestFailure,
|
|
partialObservation,
|
|
},
|
|
);
|
|
const primaryCause =
|
|
digestOutcome.kind === "failed" ? digestOutcome.cause : undefined;
|
|
const cancellation = regularFileCancellation(digestFailure);
|
|
if (cancellation !== undefined) {
|
|
const error = new AnalysisCancelledError(operation, {
|
|
cause: cancellation.reason,
|
|
cleanup: { reason, resources: [resource] },
|
|
partialObservation,
|
|
});
|
|
cause.bindCleanupError(error);
|
|
return error;
|
|
}
|
|
|
|
const primary =
|
|
primaryCause instanceof AnalysisError
|
|
? primaryCause
|
|
: new AnalysisOutputError(
|
|
operation,
|
|
`The pulled file could not be fully digested or its handle could not be closed: ${digestFailure.path}`,
|
|
{
|
|
cause:
|
|
digestOutcome.kind === "failed" ? primaryCause : digestFailure,
|
|
partialObservation,
|
|
},
|
|
);
|
|
primary.retainPartialObservation(partialObservation);
|
|
const error = analysisErrorWithCleanupFailure(primary, cleanup, operation);
|
|
cause.bindCleanupError(error);
|
|
return error;
|
|
};
|
|
|
|
type AdbFileDigest = Awaited<ReturnType<typeof hashStableFile>>;
|
|
|
|
const partialObservationWithDigest = (
|
|
observation:
|
|
| AdbAcquiredFilePartialObservation
|
|
| AdbPackagePullPartialObservation,
|
|
digest: AdbFileDigest,
|
|
): AdbAcquiredFilePartialObservation | AdbPackagePullPartialObservation => {
|
|
if (observation.operation === "pull_adb_file") {
|
|
const { result } = observation;
|
|
if (!("digest_status" in result)) return observation;
|
|
return {
|
|
...observation,
|
|
result: {
|
|
client: result.client,
|
|
serial: result.serial,
|
|
local_path: result.local_path,
|
|
device_path: result.device_path,
|
|
bytes: digest.bytes,
|
|
sha256: digest.sha256,
|
|
},
|
|
};
|
|
}
|
|
if (observation.operation === "collect_adb_bugreport") {
|
|
const { result } = observation;
|
|
if (!("digest_status" in result)) return observation;
|
|
return {
|
|
...observation,
|
|
result: {
|
|
client: result.client,
|
|
serial: result.serial,
|
|
local_path: result.local_path,
|
|
adb_reported_path: result.adb_reported_path,
|
|
bytes: digest.bytes,
|
|
sha256: digest.sha256,
|
|
},
|
|
};
|
|
}
|
|
return observation;
|
|
};
|
|
|
|
const syncStagedAdbFile = async (
|
|
path: string,
|
|
resources: ArtifactResourceScope,
|
|
operation: string,
|
|
): Promise<void> => {
|
|
let owner: OwnedFileHandle | undefined;
|
|
let primary: unknown;
|
|
try {
|
|
owner = new OwnedFileHandle(await open(path, "r+"));
|
|
await owner.handle.sync();
|
|
} catch (cause: unknown) {
|
|
primary = cause;
|
|
}
|
|
if (owner === undefined) {
|
|
if (primary !== undefined) throw primary;
|
|
return;
|
|
}
|
|
const resource = `${path} (ADB staged-output handle)`;
|
|
const stagedOwner = owner;
|
|
const closeLease = new AnalysisCleanupLease(
|
|
() => stagedOwner.close(),
|
|
[resource],
|
|
);
|
|
const released = await resources.release({
|
|
kind: "lease",
|
|
resource,
|
|
lease: closeLease,
|
|
});
|
|
if (released.kind === "failed") {
|
|
const cleanup = new ProviderCleanupError(
|
|
ADB_PROVIDER_IDENTITY.id,
|
|
[resource],
|
|
{
|
|
reason:
|
|
released.cause instanceof Error
|
|
? released.cause.message
|
|
: String(released.cause),
|
|
},
|
|
{ operation, cause: released.cause },
|
|
);
|
|
let reported: AnalysisError;
|
|
if (primary !== undefined) {
|
|
const failure = new AnalysisOutputError(
|
|
operation,
|
|
`The staged ADB output could not be synchronized: ${path}`,
|
|
{ cause: primary },
|
|
);
|
|
reported = analysisErrorWithCleanupFailure(failure, cleanup, operation);
|
|
} else {
|
|
reported = cleanup;
|
|
}
|
|
closeLease.bindCleanupError(reported);
|
|
throw reported;
|
|
}
|
|
if (primary !== undefined) throw primary;
|
|
};
|
|
|
|
const clientIdentityResult = (
|
|
selection: AdbBinarySelection,
|
|
identity: AdbClientIdentity,
|
|
) => ({
|
|
binary_path: selection.binary,
|
|
path_source: selection.pathSource,
|
|
version: identity.version,
|
|
installed_path: identity.installedPath,
|
|
});
|
|
|
|
type ClientIdentity = ReturnType<typeof clientIdentityResult>;
|
|
type ProviderIdentity = { id: string; name: string; version: string | null };
|
|
|
|
const reported = (
|
|
unparsedLines: readonly string[],
|
|
coverage: "complete" | "partial",
|
|
) => ({
|
|
unparsed_lines: unparsedLines.slice(0, UNPARSED_LINES_REPORTED),
|
|
coverage:
|
|
coverage === "partial" || unparsedLines.length > 0 ? "partial" : "complete",
|
|
});
|
|
|
|
const unparsedOverflowLimitation = (
|
|
unparsedLines: readonly string[],
|
|
): string[] =>
|
|
unparsedLines.length > UNPARSED_LINES_REPORTED
|
|
? [
|
|
`unparsed_lines reports the first ${String(UNPARSED_LINES_REPORTED)} of ${String(unparsedLines.length)} unrecognized lines`,
|
|
]
|
|
: [];
|
|
|
|
/** Materialize the caller's output directory, refusing existing files inside. */
|
|
const prepareOutputDirectory = async (
|
|
operation: string,
|
|
directory: string,
|
|
): Promise<Result<string, AnalysisError>> => {
|
|
const root = resolve(directory);
|
|
try {
|
|
await mkdir(root, { recursive: true });
|
|
await access(root, fsConstants.W_OK);
|
|
} catch (cause) {
|
|
return err(
|
|
new AnalysisOutputError(
|
|
operation,
|
|
`The output directory is not writable or could not be created: ${root}`,
|
|
{ cause },
|
|
),
|
|
);
|
|
}
|
|
return ok(root);
|
|
};
|
|
|
|
const refuseExistingTarget = async (
|
|
operation: string,
|
|
path: string,
|
|
): Promise<AnalysisError | undefined> => {
|
|
if (
|
|
await lstat(path).then(
|
|
() => true,
|
|
() => false,
|
|
)
|
|
)
|
|
return new AnalysisOutputError(
|
|
operation,
|
|
`The output file already exists: ${path}`,
|
|
);
|
|
return undefined;
|
|
};
|
|
|
|
const hardLinksUnsupported = (cause: unknown): boolean =>
|
|
cause instanceof Error &&
|
|
"code" in cause &&
|
|
["EPERM", "EOPNOTSUPP", "ENOTSUP", "ENOSYS", "EXDEV"].includes(
|
|
String(cause.code),
|
|
);
|
|
|
|
const describeAdbFailure = (cause: unknown): string =>
|
|
cause instanceof Error ? cause.message : String(cause);
|
|
|
|
interface ScreenPublicationCloseFailure {
|
|
readonly cause: unknown;
|
|
readonly resource: string;
|
|
bindCleanupError(error: ProviderCleanupError): void;
|
|
}
|
|
|
|
/** Exclusive-create fallback for filesystems that cannot hard-link staged files. */
|
|
const publishScreenExclusively = async (
|
|
outputPath: string,
|
|
bytes: Buffer,
|
|
resources: ArtifactResourceScope,
|
|
operation: string,
|
|
signal: AbortSignal | undefined,
|
|
): Promise<ScreenPublicationCloseFailure | undefined> => {
|
|
if (signal?.aborted === true)
|
|
throw new AnalysisCancelledError(operation, { cause: signal.reason });
|
|
const output = await open(outputPath, "wx", 0o600);
|
|
// Successful exclusive creation is this fallback's cutoff. Finish and sync
|
|
// the visible file despite later cancellation so its receipt remains known.
|
|
const ownedOutput = new OwnedFileHandle(output);
|
|
const handleResource = `${outputPath} (ADB publication handle)`;
|
|
const closeOnly = new AnalysisCleanupLease(
|
|
() => ownedOutput.close(),
|
|
[handleResource],
|
|
);
|
|
const removeOutput = async (
|
|
expected: Awaited<ReturnType<typeof output.stat>>,
|
|
): Promise<void> => {
|
|
let current: Awaited<ReturnType<typeof lstat>>;
|
|
try {
|
|
current = await lstat(outputPath);
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof Error && Reflect.get(cause, "code") === "ENOENT")
|
|
return;
|
|
throw cause;
|
|
}
|
|
if (
|
|
current.dev !== expected.dev ||
|
|
current.ino !== expected.ino ||
|
|
current.mode !== expected.mode
|
|
)
|
|
throw new Error(
|
|
`The screen output changed before cleanup: ${outputPath}`,
|
|
);
|
|
await rm(outputPath);
|
|
};
|
|
let rollbackIdentity: Awaited<ReturnType<typeof output.stat>> | undefined;
|
|
const inspectCloseAndRollback = new AnalysisCleanupLease(async () => {
|
|
rollbackIdentity ??= await output.stat();
|
|
await ownedOutput.close();
|
|
await removeOutput(rollbackIdentity);
|
|
});
|
|
let identity: Awaited<ReturnType<typeof output.stat>>;
|
|
try {
|
|
identity = await output.stat();
|
|
} catch (cause: unknown) {
|
|
resources.retain({
|
|
kind: "lease",
|
|
resource: `${outputPath} (screen output and descriptor)`,
|
|
lease: inspectCloseAndRollback,
|
|
});
|
|
const error = new ProviderCleanupError(
|
|
ADB_PROVIDER_IDENTITY.id,
|
|
[handleResource, outputPath],
|
|
{
|
|
reason:
|
|
"The newly created screen output identity could not be established; its descriptor remains owned for cleanup retry",
|
|
},
|
|
{ operation, cause },
|
|
);
|
|
inspectCloseAndRollback.bindCleanupError(error);
|
|
throw error;
|
|
}
|
|
let writeFailure: { readonly cause: unknown } | undefined;
|
|
try {
|
|
await output.writeFile(bytes);
|
|
await output.sync();
|
|
} catch (cause: unknown) {
|
|
writeFailure = { cause };
|
|
}
|
|
if (writeFailure === undefined) {
|
|
try {
|
|
await ownedOutput.close();
|
|
return undefined;
|
|
} catch (cause: unknown) {
|
|
resources.retain({
|
|
kind: "lease",
|
|
resource: handleResource,
|
|
lease: closeOnly,
|
|
});
|
|
return {
|
|
cause,
|
|
resource: handleResource,
|
|
bindCleanupError(error) {
|
|
closeOnly.bindCleanupError(error);
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
const rollback = new AnalysisCleanupLease(async () => {
|
|
await ownedOutput.close();
|
|
await removeOutput(identity);
|
|
});
|
|
try {
|
|
await ownedOutput.close();
|
|
} catch (closeCause: unknown) {
|
|
resources.retain({ kind: "lease", resource: outputPath, lease: rollback });
|
|
const error = new ProviderCleanupError(
|
|
ADB_PROVIDER_IDENTITY.id,
|
|
[outputPath],
|
|
{ reason: describeAdbFailure(closeCause) },
|
|
{
|
|
operation,
|
|
cause: new AggregateError(
|
|
[writeFailure.cause, closeCause],
|
|
"Screen output write and descriptor cleanup failed",
|
|
{ cause: writeFailure.cause },
|
|
),
|
|
},
|
|
);
|
|
rollback.bindCleanupError(error);
|
|
throw error;
|
|
}
|
|
const removed = await resources.release({
|
|
kind: "lease",
|
|
resource: outputPath,
|
|
lease: rollback,
|
|
});
|
|
if (removed.kind === "failed") {
|
|
const error = new ProviderCleanupError(
|
|
ADB_PROVIDER_IDENTITY.id,
|
|
[outputPath],
|
|
{ reason: describeAdbFailure(removed.cause) },
|
|
{
|
|
operation,
|
|
cause: new AggregateError(
|
|
[writeFailure.cause, removed.cause],
|
|
"Screen output write and rollback failed",
|
|
{ cause: writeFailure.cause },
|
|
),
|
|
},
|
|
);
|
|
rollback.bindCleanupError(error);
|
|
throw error;
|
|
}
|
|
throw writeFailure.cause;
|
|
};
|
|
|
|
const cancellationWithCleanupFailure = (
|
|
primary: AnalysisCancelledError,
|
|
cleanup: AnalysisError,
|
|
resources: readonly string[],
|
|
): AnalysisCancelledError =>
|
|
new AnalysisCancelledError(primary.operation, {
|
|
cause: new AggregateError(
|
|
[primary, cleanup],
|
|
"ADB cancellation cleanup failed",
|
|
{ cause: primary },
|
|
),
|
|
diagnostics: primary.diagnostics,
|
|
...(primary.capturedOutput === undefined
|
|
? {}
|
|
: { capturedOutput: primary.capturedOutput }),
|
|
cleanup: {
|
|
reason: [primary.cleanup?.reason, cleanup.message]
|
|
.filter((reason): reason is string => reason !== undefined)
|
|
.join("; "),
|
|
resources: [...new Set([...primary.cleanupResources, ...resources])],
|
|
},
|
|
partialObservation: primary.partialObservation,
|
|
});
|
|
|
|
/** Publish an ADB-produced archive without replacing an existing caller file. */
|
|
const publishBugreport = async (
|
|
source: string,
|
|
directory: string,
|
|
): Promise<string> => {
|
|
const fileName = basename(source);
|
|
const extension = extname(fileName);
|
|
const stem =
|
|
extension === "" ? fileName : fileName.slice(0, -extension.length);
|
|
for (let suffix = 0; ; suffix += 1) {
|
|
const destination = join(
|
|
directory,
|
|
suffix === 0 ? fileName : `${stem} (${String(suffix)})${extension}`,
|
|
);
|
|
try {
|
|
await copyFile(source, destination, fsConstants.COPYFILE_EXCL);
|
|
return destination;
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof Error && "code" in cause && cause.code === "EEXIST")
|
|
continue;
|
|
throw cause;
|
|
}
|
|
}
|
|
};
|
|
|
|
export interface AdbProviderOptions {
|
|
readonly environment?: Readonly<Record<string, string | undefined>>;
|
|
/** Override runtime-root creation at the provider's owned-resource boundary. */
|
|
readonly createRuntimeRoot?: (
|
|
options?: PrivateRuntimeRootOptions,
|
|
) => Promise<AdbRuntimeRoot>;
|
|
}
|
|
|
|
type AdbRuntimeRoot = Pick<PrivateRuntimeRoot, "path" | "close">;
|
|
|
|
/** A root lease releases its children before removing their containing root. */
|
|
const runtimeRootLease = (
|
|
root: AdbRuntimeRoot,
|
|
children: ArtifactResourceScope,
|
|
): AnalysisCleanupLease =>
|
|
new AnalysisCleanupLease(async () => {
|
|
await children.close();
|
|
await root.close();
|
|
});
|
|
|
|
interface PendingOutputDirectoryCleanup {
|
|
readonly operation: "pull_adb_package" | "collect_adb_bugreport";
|
|
readonly path: string;
|
|
readonly primary: AnalysisError;
|
|
readonly remove: () => Promise<void>;
|
|
readonly lease: AnalysisCleanupLease;
|
|
}
|
|
|
|
interface OutputDirectoryIdentity {
|
|
readonly dev: number;
|
|
readonly ino: number;
|
|
readonly mode: number;
|
|
}
|
|
|
|
const removeOwnedOutputDirectory = async (
|
|
path: string,
|
|
identity: OutputDirectoryIdentity | undefined,
|
|
): Promise<void> => {
|
|
if (identity === undefined)
|
|
throw new Error(
|
|
`Cannot verify the created output directory identity; refusing to remove ${path}`,
|
|
);
|
|
let current: Awaited<ReturnType<typeof lstat>>;
|
|
try {
|
|
current = await lstat(path);
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof Error && Reflect.get(cause, "code") === "ENOENT")
|
|
return;
|
|
throw cause;
|
|
}
|
|
if (
|
|
!current.isDirectory() ||
|
|
current.isSymbolicLink() ||
|
|
current.dev !== identity.dev ||
|
|
current.ino !== identity.ino ||
|
|
current.mode !== identity.mode
|
|
)
|
|
throw new Error(
|
|
`The created output directory was replaced or changed; refusing to remove ${path}`,
|
|
);
|
|
await rm(path, { recursive: true, force: true });
|
|
};
|
|
|
|
const reservePackageDestinations = async (
|
|
operation: string,
|
|
packageName: string,
|
|
packageDirectory: string,
|
|
entries: readonly {
|
|
readonly file_name: string;
|
|
readonly device_path: string;
|
|
}[],
|
|
capturedOutput: {
|
|
readonly stdout: string;
|
|
readonly stderr: string;
|
|
readonly truncated: boolean;
|
|
},
|
|
): Promise<AnalysisOutputError | undefined> => {
|
|
const reservations: {
|
|
readonly path: string;
|
|
readonly file_name: string;
|
|
readonly device_path: string;
|
|
readonly identity: OutputDirectoryIdentity;
|
|
}[] = [];
|
|
for (const entry of entries) {
|
|
const path = join(packageDirectory, entry.file_name);
|
|
try {
|
|
await mkdir(path);
|
|
const created = await lstat(path);
|
|
if (!created.isDirectory() || created.isSymbolicLink())
|
|
throw new Error(`Could not reserve local filename ${entry.file_name}`);
|
|
reservations.push({
|
|
path,
|
|
file_name: entry.file_name,
|
|
device_path: entry.device_path,
|
|
identity: {
|
|
dev: created.dev,
|
|
ino: created.ino,
|
|
mode: created.mode,
|
|
},
|
|
});
|
|
} catch (cause: unknown) {
|
|
const errorCode =
|
|
cause instanceof Error &&
|
|
"code" in cause &&
|
|
typeof cause.code === "string"
|
|
? cause.code
|
|
: null;
|
|
return new AnalysisOutputError(
|
|
operation,
|
|
"local_destination_unavailable",
|
|
{
|
|
cause,
|
|
capturedOutput,
|
|
userMessage: `pm path for ${packageName} reported ${displayDevicePath(entry.device_path)}, whose file name ${displayDevicePath(entry.file_name)} cannot be written to a unique local destination (${errorCode ?? "unknown error"}). No APK was pulled.`,
|
|
remediationAction:
|
|
"Review details.diagnostics and details.captured_output for the refused destination. Correct the output filesystem conflict before retrying; REA does not rename APKs.",
|
|
diagnostics: {
|
|
package_name: packageName,
|
|
device_path: entry.device_path,
|
|
file_name: entry.file_name,
|
|
error_code: errorCode,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
}
|
|
for (const reservation of reservations) {
|
|
try {
|
|
const current = await lstat(reservation.path);
|
|
if (
|
|
!current.isDirectory() ||
|
|
current.isSymbolicLink() ||
|
|
current.dev !== reservation.identity.dev ||
|
|
current.ino !== reservation.identity.ino ||
|
|
current.mode !== reservation.identity.mode
|
|
)
|
|
throw new Error(
|
|
`The local filename reservation changed before release: ${reservation.path}`,
|
|
);
|
|
await rmdir(reservation.path);
|
|
} catch (cause: unknown) {
|
|
const errorCode =
|
|
cause instanceof Error &&
|
|
"code" in cause &&
|
|
typeof cause.code === "string"
|
|
? cause.code
|
|
: null;
|
|
return new AnalysisOutputError(
|
|
operation,
|
|
"local_destination_unavailable",
|
|
{
|
|
cause,
|
|
capturedOutput,
|
|
userMessage: `Could not release the local filename reservation for ${displayDevicePath(reservation.device_path)} (${errorCode ?? "unknown error"}); no APK was pulled.`,
|
|
remediationAction:
|
|
"Review details.diagnostics and details.captured_output for the refused destination. Correct the output filesystem conflict before retrying; REA does not rename APKs.",
|
|
diagnostics: {
|
|
package_name: packageName,
|
|
device_path: reservation.device_path,
|
|
file_name: reservation.file_name,
|
|
error_code: errorCode,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
/**
|
|
* ADB device inspection and acquisition over a caller-selected adb binary.
|
|
*
|
|
* Every operation composes fixed adb argument vectors: caller values occupy
|
|
* positional arguments, escaped when adb forwards them to the device shell. The provider never
|
|
* starts an emulator, installs platform tools, or drives the device UI; the
|
|
* only device mutation is the caller-requested file push, which requires an
|
|
* explicit overwrite choice and verifies its transfer digest.
|
|
*/
|
|
export class AdbProvider {
|
|
private readonly environment: Readonly<Record<string, string | undefined>>;
|
|
readonly #createRuntimeRoot: (
|
|
options?: PrivateRuntimeRootOptions,
|
|
) => Promise<AdbRuntimeRoot>;
|
|
readonly #resources = new ArtifactResourceScope();
|
|
readonly #outputDirectoryRetryLeases = new WeakMap<
|
|
AnalysisError,
|
|
readonly AnalysisCleanupLease[]
|
|
>();
|
|
readonly #pendingOutputDirectoryCleanup =
|
|
new Set<PendingOutputDirectoryCleanup>();
|
|
readonly #activeOperations = new Set<Promise<void>>();
|
|
#closing = false;
|
|
#closePromise: Promise<void> | undefined;
|
|
#outputCleanupPromise: Promise<void> | undefined;
|
|
|
|
constructor(options: AdbProviderOptions = {}) {
|
|
this.environment = options.environment ?? process.env;
|
|
this.#createRuntimeRoot =
|
|
options.createRuntimeRoot ??
|
|
((rootOptions) => PrivateRuntimeRoot.create(rootOptions));
|
|
}
|
|
|
|
private selection(): AdbBinarySelection {
|
|
return resolveAdbBinary(this.environment);
|
|
}
|
|
|
|
/** Probe the selected adb binary; never contacts devices or the server. */
|
|
async inspectAvailability(
|
|
signal?: AbortSignal,
|
|
): Promise<ProviderAvailability> {
|
|
try {
|
|
const selection = this.selection();
|
|
await inspectAdbClient(selection, signal);
|
|
return {
|
|
status: "available",
|
|
code: null,
|
|
reason: null,
|
|
diagnostics: {
|
|
selected_binary: selection.binary,
|
|
path_source: selection.pathSource,
|
|
},
|
|
};
|
|
} catch (cause) {
|
|
if (cause instanceof AdbConfigurationFailure)
|
|
return adbConfigurationAvailability(cause);
|
|
throw cause;
|
|
}
|
|
}
|
|
|
|
/** Retry removal of owned output 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);
|
|
const failures: unknown[] = [];
|
|
const failedLeases = new Set<AnalysisCleanupLease>();
|
|
let allFailuresHaveExactLeaseOwners = true;
|
|
// Directory errors may also depend on a retained digest descriptor.
|
|
// Settle those owners before deciding whether the directory error resolved.
|
|
for (const cleanup of [
|
|
() => this.#resources.close(),
|
|
() => this.#retryOutputDirectoryCleanup(),
|
|
]) {
|
|
try {
|
|
await cleanup();
|
|
} catch (cause: unknown) {
|
|
failures.push(cause);
|
|
const leases =
|
|
(cause instanceof AnalysisError
|
|
? this.#outputDirectoryRetryLeases.get(cause)
|
|
: undefined) ??
|
|
(cause instanceof ArtifactResourceRetryFailure
|
|
? cause.cleanupLeases
|
|
: undefined);
|
|
if (leases === undefined) allFailuresHaveExactLeaseOwners = false;
|
|
for (const lease of leases ?? []) failedLeases.add(lease);
|
|
}
|
|
}
|
|
if (failures.length > 0) {
|
|
const resources = failures.flatMap((cause) =>
|
|
this.#cleanupResources(cause, "ADB provider-owned resource"),
|
|
);
|
|
const error = new ProviderCleanupError(
|
|
ADB_PROVIDER_IDENTITY.id,
|
|
[...new Set(resources)],
|
|
{
|
|
reason: failures
|
|
.map((cause) =>
|
|
cause instanceof Error ? cause.message : String(cause),
|
|
)
|
|
.join("; "),
|
|
},
|
|
{
|
|
operation: "close_binary",
|
|
cause: new AggregateError(
|
|
failures,
|
|
"ADB cleanup remains incomplete",
|
|
),
|
|
},
|
|
);
|
|
if (allFailuresHaveExactLeaseOwners && failedLeases.size > 0)
|
|
new AnalysisCleanupLeaseGroup(error, [...failedLeases]);
|
|
throw error;
|
|
}
|
|
})().finally(() => {
|
|
if (this.#closePromise === closing) this.#closePromise = undefined;
|
|
});
|
|
this.#closePromise = closing;
|
|
return this.#closePromise;
|
|
}
|
|
|
|
#cleanupResources(cause: unknown, resource: string): readonly string[] {
|
|
if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined)
|
|
return cause.cleanup.resources;
|
|
if (cause instanceof AnalysisError && cause.cleanupResources.length > 0)
|
|
return cause.cleanupResources;
|
|
return [resource];
|
|
}
|
|
|
|
#cleanupError(
|
|
operation: string,
|
|
parameters: Record<string, JsonValue>,
|
|
causes: readonly unknown[],
|
|
resources: readonly string[],
|
|
execution?: AnalysisExecution,
|
|
): ProviderCleanupError {
|
|
const partialObservation =
|
|
execution === undefined
|
|
? undefined
|
|
: createEvidence(execution.subject ?? undefined, execution.provider, {
|
|
operation,
|
|
parameters,
|
|
result: execution.result,
|
|
rawResult: execution.rawResult,
|
|
limitations: execution.limitations,
|
|
locations: execution.locations,
|
|
confidence: "observed",
|
|
});
|
|
return new ProviderCleanupError(
|
|
ADB_PROVIDER_IDENTITY.id,
|
|
resources,
|
|
{
|
|
reason: causes
|
|
.map((cause) =>
|
|
cause instanceof Error ? cause.message : String(cause),
|
|
)
|
|
.join("; "),
|
|
},
|
|
{
|
|
operation,
|
|
cause: new AggregateError(causes, "ADB operation cleanup failed"),
|
|
...(partialObservation === undefined ? {} : { partialObservation }),
|
|
},
|
|
);
|
|
}
|
|
|
|
async #withRuntimeRoot(
|
|
operation: string,
|
|
parameters: Record<string, JsonValue>,
|
|
options: PrivateRuntimeRootOptions,
|
|
signal: AbortSignal | undefined,
|
|
work: (
|
|
root: AdbRuntimeRoot,
|
|
children: ArtifactResourceScope,
|
|
) => Promise<Result<AnalysisExecution, AnalysisError>>,
|
|
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
|
let root: AdbRuntimeRoot;
|
|
try {
|
|
root = await this.#createRuntimeRoot(options);
|
|
} catch (cause: unknown) {
|
|
return err(
|
|
cause instanceof AnalysisError
|
|
? cause
|
|
: cause instanceof PrivateRuntimeRootUnavailableError
|
|
? new AnalysisCapabilityUnavailableError(
|
|
ADB_PROVIDER_IDENTITY.id,
|
|
operation,
|
|
cause.message,
|
|
{ cause, retryable: cause.retryable },
|
|
)
|
|
: new AnalysisOutputError(
|
|
operation,
|
|
"Could not create an ADB private workspace",
|
|
{
|
|
cause,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
let result: Result<AnalysisExecution, AnalysisError> | undefined;
|
|
let primary: AnalysisError | undefined;
|
|
const children = new ArtifactResourceScope();
|
|
try {
|
|
result = await work(root, children);
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof AdbInputSnapshotFailure) {
|
|
primary =
|
|
cause.primaryCause instanceof AnalysisError
|
|
? cause.primaryCause
|
|
: new AnalysisOutputError(
|
|
operation,
|
|
`The caller input could not be staged for adb: ${cause.sourcePath}`,
|
|
{ cause: cause.primaryCause ?? cause },
|
|
);
|
|
// Snapshot handle cleanup may recover when the runtime-root lease is
|
|
// drained below. Keep that historical failure linked to the typed
|
|
// primary error while the lease owns and clears live cleanup state.
|
|
primary.retainSecondaryFailure(
|
|
new AnalysisOutputError(
|
|
operation,
|
|
`ADB input snapshot cleanup previously failed for ${cause.sourcePath}`,
|
|
{ cause },
|
|
),
|
|
{},
|
|
);
|
|
} else {
|
|
primary =
|
|
cause instanceof AnalysisError
|
|
? cause
|
|
: new AnalysisProtocolError(`ADB ${operation} failed`, { cause });
|
|
}
|
|
}
|
|
|
|
const rootLease = runtimeRootLease(root, children);
|
|
const released = await this.#resources.release({
|
|
kind: "lease",
|
|
resource: root.path,
|
|
lease: rootLease,
|
|
});
|
|
if (primary === undefined && result?.ok && signal?.aborted === true) {
|
|
const completed = result.value;
|
|
primary = new AnalysisCancelledError(operation, {
|
|
cause: signal.reason,
|
|
partialObservation: createEvidence(
|
|
completed.subject ?? undefined,
|
|
completed.provider,
|
|
{
|
|
operation,
|
|
parameters,
|
|
result: completed.result,
|
|
rawResult: completed.rawResult,
|
|
limitations: completed.limitations,
|
|
locations: completed.locations,
|
|
confidence: "observed",
|
|
},
|
|
),
|
|
});
|
|
}
|
|
if (released.kind === "failed") {
|
|
const cleanupResources = [
|
|
root.path,
|
|
...(released.cause instanceof ArtifactReaderFailure &&
|
|
released.cause.cleanup !== undefined
|
|
? released.cause.cleanup.resources
|
|
: []),
|
|
];
|
|
const cleanup = this.#cleanupError(
|
|
operation,
|
|
parameters,
|
|
[released.cause],
|
|
cleanupResources,
|
|
result?.ok ? result.value : undefined,
|
|
);
|
|
const returnCleanupError = (error: AnalysisError) => {
|
|
rootLease.bindCleanupError(error);
|
|
return err(error);
|
|
};
|
|
if (primary instanceof AnalysisCancelledError) {
|
|
return returnCleanupError(
|
|
cancellationWithCleanupFailure(primary, cleanup, cleanupResources),
|
|
);
|
|
}
|
|
if (primary !== undefined)
|
|
return returnCleanupError(
|
|
analysisErrorWithCleanupFailure(primary, cleanup, operation),
|
|
);
|
|
if (result !== undefined && !result.ok) {
|
|
if (result.error instanceof AnalysisCancelledError) {
|
|
return returnCleanupError(
|
|
cancellationWithCleanupFailure(
|
|
result.error,
|
|
cleanup,
|
|
cleanupResources,
|
|
),
|
|
);
|
|
}
|
|
return returnCleanupError(
|
|
analysisErrorWithCleanupFailure(result.error, cleanup, operation),
|
|
);
|
|
}
|
|
return returnCleanupError(cleanup);
|
|
}
|
|
if (primary !== undefined) return err(primary);
|
|
if (result === undefined)
|
|
return err(
|
|
new AnalysisProtocolError(`ADB ${operation} returned no result`),
|
|
);
|
|
return result;
|
|
}
|
|
|
|
#retainOutputDirectoryCleanup(
|
|
owner: Omit<PendingOutputDirectoryCleanup, "lease">,
|
|
cleanup: ProviderCleanupError,
|
|
): AnalysisError {
|
|
const lease = new AnalysisCleanupLease(async () => {
|
|
await owner.remove();
|
|
if (owner.primary.cleanupIncomplete)
|
|
throw new Error(
|
|
"ADB output directory primary cleanup remains incomplete",
|
|
);
|
|
});
|
|
const error = analysisErrorWithCleanupFailure(
|
|
owner.primary,
|
|
cleanup,
|
|
owner.operation,
|
|
);
|
|
lease.bindCleanupError(error);
|
|
this.#pendingOutputDirectoryCleanup.add({ ...owner, lease });
|
|
return error;
|
|
}
|
|
|
|
#retryOutputDirectoryCleanup(): Promise<void> {
|
|
if (this.#outputCleanupPromise !== undefined)
|
|
return this.#outputCleanupPromise;
|
|
const cleanup = this.#performOutputDirectoryCleanup().finally(() => {
|
|
if (this.#outputCleanupPromise === cleanup)
|
|
this.#outputCleanupPromise = undefined;
|
|
});
|
|
this.#outputCleanupPromise = cleanup;
|
|
return cleanup;
|
|
}
|
|
|
|
async #performOutputDirectoryCleanup(): Promise<void> {
|
|
const failures: AnalysisError[] = [];
|
|
for (const owner of this.#pendingOutputDirectoryCleanup) {
|
|
try {
|
|
await owner.lease.release();
|
|
this.#pendingOutputDirectoryCleanup.delete(owner);
|
|
} catch (cause: unknown) {
|
|
const cleanup = new ProviderCleanupError(
|
|
ADB_PROVIDER_IDENTITY.id,
|
|
[owner.path],
|
|
{
|
|
reason: cause instanceof Error ? cause.message : String(cause),
|
|
},
|
|
{ operation: owner.operation, cause },
|
|
);
|
|
const error = analysisErrorWithCleanupFailure(
|
|
owner.primary,
|
|
cleanup,
|
|
owner.operation,
|
|
);
|
|
owner.lease.bindCleanupError(error);
|
|
failures.push(error);
|
|
}
|
|
}
|
|
if (failures.length > 0) {
|
|
const error =
|
|
failures.length === 1
|
|
? failures[0]!
|
|
: new ProviderCleanupError(
|
|
ADB_PROVIDER_IDENTITY.id,
|
|
[
|
|
...new Set(
|
|
failures.flatMap((failure) => failure.cleanupResources),
|
|
),
|
|
],
|
|
{
|
|
reason:
|
|
"Failed ADB output directories remain after cleanup retry",
|
|
operations: [...this.#pendingOutputDirectoryCleanup].map(
|
|
({ operation }) => operation,
|
|
),
|
|
failures: failures.map(({ message, cleanupResources }) => ({
|
|
message,
|
|
resources: [...cleanupResources],
|
|
})),
|
|
},
|
|
{
|
|
operation: "close_binary",
|
|
cause: new AggregateError(
|
|
failures,
|
|
"Multiple ADB output directory cleanups failed",
|
|
),
|
|
},
|
|
);
|
|
const leases = [...this.#pendingOutputDirectoryCleanup].map(
|
|
({ lease }) => lease,
|
|
);
|
|
this.#outputDirectoryRetryLeases.set(error, leases);
|
|
if (failures.length > 1) new AnalysisCleanupLeaseGroup(error, leases);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async execute(
|
|
request: AdbRequest,
|
|
options?: ExecutionOptions,
|
|
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
|
if (this.#closing)
|
|
return err(new AnalysisCancelledError(request.operation));
|
|
let release!: () => void;
|
|
const active = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
this.#activeOperations.add(active);
|
|
try {
|
|
try {
|
|
await this.#resources.retryCleanup();
|
|
} catch (cause: unknown) {
|
|
const error = this.#cleanupError(
|
|
request.operation,
|
|
jsonObjectSchema.parse(request.input),
|
|
[cause],
|
|
this.#cleanupResources(cause, "ADB provider-owned resource"),
|
|
);
|
|
const leases =
|
|
cause instanceof ArtifactResourceRetryFailure
|
|
? cause.cleanupLeases
|
|
: undefined;
|
|
if (leases !== undefined) new AnalysisCleanupLeaseGroup(error, leases);
|
|
return err(error);
|
|
}
|
|
if (this.#pendingOutputDirectoryCleanup.size > 0) {
|
|
try {
|
|
await this.#retryOutputDirectoryCleanup();
|
|
} catch (cause: unknown) {
|
|
return err(
|
|
cause instanceof AnalysisError
|
|
? cause
|
|
: new ProviderCleanupError(
|
|
ADB_PROVIDER_IDENTITY.id,
|
|
[...this.#pendingOutputDirectoryCleanup].map(
|
|
({ path }) => path,
|
|
),
|
|
{
|
|
reason:
|
|
cause instanceof Error ? cause.message : String(cause),
|
|
},
|
|
{ operation: request.operation, cause },
|
|
),
|
|
);
|
|
}
|
|
}
|
|
return await this.#executeOperation(request, options);
|
|
} finally {
|
|
this.#activeOperations.delete(active);
|
|
release();
|
|
}
|
|
}
|
|
|
|
async #executeOperation(
|
|
request: AdbRequest,
|
|
options?: ExecutionOptions,
|
|
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
|
const signal = options?.signal;
|
|
if (signal?.aborted === true)
|
|
return err(
|
|
new AnalysisCancelledError(request.operation, { cause: signal.reason }),
|
|
);
|
|
let selection: AdbBinarySelection;
|
|
let identity: AdbClientIdentity;
|
|
try {
|
|
selection = this.selection();
|
|
identity = await inspectAdbClient(selection, signal);
|
|
} catch (cause) {
|
|
if (cause instanceof AnalysisError) return err(cause);
|
|
if (cause instanceof AdbConfigurationFailure)
|
|
return err(
|
|
providerConfigurationRefusal(
|
|
ADB_PROVIDER_IDENTITY.id,
|
|
request.operation,
|
|
cause,
|
|
),
|
|
);
|
|
return err(
|
|
new AnalysisProtocolError(
|
|
`adb version probe failed for ${request.operation}`,
|
|
{ cause },
|
|
),
|
|
);
|
|
}
|
|
const client = clientIdentityResult(selection, identity);
|
|
const provider: ProviderIdentity = {
|
|
...ADB_PROVIDER_IDENTITY,
|
|
version: identity.version,
|
|
};
|
|
const { operation } = request;
|
|
const shell = (arguments_: readonly string[], limits: AdbRunLimits) =>
|
|
runAdb(
|
|
operation,
|
|
"serial" in request.input ? request.input.serial : operation,
|
|
selection.binary,
|
|
adbArguments(
|
|
"serial" in request.input ? request.input.serial : undefined,
|
|
["shell", ...arguments_],
|
|
),
|
|
limits,
|
|
signal,
|
|
);
|
|
switch (operation) {
|
|
case "inspect_adb_client":
|
|
return ok(
|
|
createAnalysisExecution(
|
|
{ client, contacted_adb_server: false },
|
|
provider,
|
|
),
|
|
);
|
|
case "list_adb_devices": {
|
|
const ran = await runAdb(
|
|
operation,
|
|
"adb-devices",
|
|
selection.binary,
|
|
["devices", "-l"],
|
|
{
|
|
timeoutMs: DEVICES_TIMEOUT_MS,
|
|
maxOutputBytes: DEVICES_MAX_OUTPUT_BYTES,
|
|
},
|
|
signal,
|
|
);
|
|
if (!ran.ok) return ran;
|
|
const { devices, unparsedLines, daemonStarted } = parseAdbDevicesOutput(
|
|
ran.value.stdout,
|
|
ran.value.stderr,
|
|
);
|
|
if (unparsedLines.length > 0)
|
|
return err(
|
|
new AnalysisProtocolError(
|
|
`adb devices reported an unrecognized line: ${unparsedLines[0]}`,
|
|
),
|
|
);
|
|
return ok(
|
|
createAnalysisExecution(
|
|
{ client, devices, may_have_started_adb_server: daemonStarted },
|
|
provider,
|
|
{
|
|
limitations: [
|
|
"Device kind is inferred from the emulator serial prefix and USB transport metadata, not from device attestation",
|
|
"Kind stays unknown for TCP transports unless the serial is an emulator port",
|
|
],
|
|
},
|
|
),
|
|
);
|
|
}
|
|
case "inspect_adb_device": {
|
|
const ran = await runAdb(
|
|
operation,
|
|
request.input.serial,
|
|
selection.binary,
|
|
adbArguments(request.input.serial, ["shell", "getprop"]),
|
|
{
|
|
timeoutMs: SHELL_TIMEOUT_MS,
|
|
maxOutputBytes: GETPROP_MAX_OUTPUT_BYTES,
|
|
},
|
|
signal,
|
|
);
|
|
if (!ran.ok) return ran;
|
|
const { properties, emulatorObserved } = parseGetpropOutput(
|
|
ran.value.stdout,
|
|
);
|
|
return ok(
|
|
createAnalysisExecution(
|
|
{
|
|
client,
|
|
serial: request.input.serial,
|
|
properties,
|
|
property_count: properties.length,
|
|
emulator_observed: emulatorObserved,
|
|
},
|
|
provider,
|
|
{
|
|
limitations: [
|
|
"Properties are a fixed whitelist of build identity facts, not the full getprop dump",
|
|
"emulator_observed is null when the build does not report ro.kernel.qemu",
|
|
],
|
|
},
|
|
),
|
|
);
|
|
}
|
|
case "list_adb_packages": {
|
|
const scopeFlag =
|
|
request.input.scope === "third_party"
|
|
? "-3"
|
|
: request.input.scope === "system"
|
|
? "-s"
|
|
: null;
|
|
const ran = await shell(
|
|
[
|
|
"pm",
|
|
"list",
|
|
"packages",
|
|
"-f",
|
|
...(scopeFlag === null ? [] : [scopeFlag]),
|
|
],
|
|
{
|
|
timeoutMs: PACKAGE_LIST_TIMEOUT_MS,
|
|
maxOutputBytes: PACKAGE_LIST_MAX_OUTPUT_BYTES,
|
|
},
|
|
);
|
|
if (!ran.ok) return ran;
|
|
const { packages, unparsedLines } = parsePackageListOutput(
|
|
ran.value.stdout,
|
|
);
|
|
return ok(
|
|
createAnalysisExecution(
|
|
{
|
|
client,
|
|
serial: request.input.serial,
|
|
scope: request.input.scope,
|
|
packages,
|
|
...reported(unparsedLines, "complete"),
|
|
},
|
|
provider,
|
|
{
|
|
limitations: [
|
|
"base_apk_device_path is a device filesystem path, not a host-local path",
|
|
...unparsedOverflowLimitation(unparsedLines),
|
|
],
|
|
},
|
|
),
|
|
);
|
|
}
|
|
case "pull_adb_package":
|
|
return this.pullPackage(
|
|
operation,
|
|
selection,
|
|
client,
|
|
provider,
|
|
request.input,
|
|
signal,
|
|
);
|
|
case "read_adb_logcat": {
|
|
const ran = await shell(
|
|
[
|
|
"logcat",
|
|
"-d",
|
|
"-b",
|
|
request.input.buffer,
|
|
"-t",
|
|
String(request.input.count),
|
|
...(request.input.pid === undefined
|
|
? []
|
|
: ["--pid", String(request.input.pid)]),
|
|
],
|
|
{
|
|
timeoutMs: LOGCAT_TIMEOUT_MS,
|
|
maxOutputBytes: LOGCAT_MAX_OUTPUT_BYTES,
|
|
},
|
|
);
|
|
if (!ran.ok) return ran;
|
|
const lines = ran.value.stdout.split(/\r?\n/u);
|
|
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
const bounded = lines.slice(-request.input.count);
|
|
return ok(
|
|
createAnalysisExecution(
|
|
{
|
|
client,
|
|
serial: request.input.serial,
|
|
count: request.input.count,
|
|
buffer: request.input.buffer,
|
|
pid: request.input.pid ?? null,
|
|
lines: bounded,
|
|
total_bytes: ran.value.stdoutBytes,
|
|
coverage:
|
|
bounded.length !== lines.length ||
|
|
bounded.length === request.input.count
|
|
? "partial"
|
|
: "complete",
|
|
},
|
|
provider,
|
|
{
|
|
limitations: [
|
|
"Reads a bounded dump (logcat -d); the newest lines are kept when the count cap trims",
|
|
"coverage is partial when the requested count or capture budget truncated the dump",
|
|
],
|
|
},
|
|
),
|
|
);
|
|
}
|
|
case "inspect_adb_package": {
|
|
const ran = await shell(["dumpsys", "package", request.input.package], {
|
|
timeoutMs: DUMPSYS_PACKAGE_TIMEOUT_MS,
|
|
maxOutputBytes: DUMPSYS_PACKAGE_MAX_OUTPUT_BYTES,
|
|
});
|
|
if (!ran.ok) return ran;
|
|
const stdout = ran.value.stdout;
|
|
if (/Unable to find package:/u.test(stdout) || stdout.trim() === "")
|
|
return err(
|
|
new AnalysisUnsupportedTargetError(
|
|
operation,
|
|
request.input.serial,
|
|
`Package Manager did not report details for ${request.input.package}; it may not be installed for user 0`,
|
|
),
|
|
);
|
|
const details = parseDumpsysPackageOutput(stdout);
|
|
return ok(
|
|
createAnalysisExecution(
|
|
{
|
|
client,
|
|
serial: request.input.serial,
|
|
package_name: request.input.package,
|
|
version_name: details.versionName,
|
|
version_code: details.versionCode,
|
|
first_install_time: details.firstInstallTime,
|
|
last_update_time: details.lastUpdateTime,
|
|
installer_package_name: details.installerPackageName,
|
|
user_id: details.userId,
|
|
pkg_flags: details.pkgFlags,
|
|
requested_permissions_count: details.requestedPermissions,
|
|
coverage:
|
|
details.versionCode === null && details.versionName === null
|
|
? "partial"
|
|
: "complete",
|
|
},
|
|
provider,
|
|
{
|
|
limitations: [
|
|
"Fields are a fixed projection of dumpsys package output; version-dependent sections are ignored rather than guessed",
|
|
"requested_permissions_count counts the listed requested permissions section only",
|
|
],
|
|
},
|
|
),
|
|
);
|
|
}
|
|
case "pull_adb_file":
|
|
return this.pullFile(
|
|
operation,
|
|
selection,
|
|
client,
|
|
provider,
|
|
request.input,
|
|
signal,
|
|
);
|
|
case "push_adb_file":
|
|
return this.pushFile(
|
|
operation,
|
|
selection,
|
|
client,
|
|
provider,
|
|
request.input,
|
|
signal,
|
|
);
|
|
case "capture_adb_screen":
|
|
return this.captureScreen(
|
|
operation,
|
|
selection,
|
|
client,
|
|
provider,
|
|
request.input,
|
|
signal,
|
|
);
|
|
case "collect_adb_bugreport":
|
|
return this.collectBugreport(
|
|
operation,
|
|
selection,
|
|
client,
|
|
provider,
|
|
request.input,
|
|
signal,
|
|
);
|
|
case "list_adb_processes": {
|
|
const ran = await shell(["ps", "-A"], {
|
|
timeoutMs: LISTING_TIMEOUT_MS,
|
|
maxOutputBytes: LISTING_MAX_OUTPUT_BYTES,
|
|
});
|
|
if (!ran.ok) return ran;
|
|
const { columns, processes, unparsedLines } = parseProcessListOutput(
|
|
ran.value.stdout,
|
|
);
|
|
return ok(
|
|
createAnalysisExecution(
|
|
{
|
|
client,
|
|
serial: request.input.serial,
|
|
columns,
|
|
processes,
|
|
...reported(unparsedLines, "complete"),
|
|
},
|
|
provider,
|
|
{ limitations: unparsedOverflowLimitation(unparsedLines) },
|
|
),
|
|
);
|
|
}
|
|
case "list_adb_directory": {
|
|
const ran = await runAdbCapture(
|
|
operation,
|
|
request.input.serial,
|
|
selection.binary,
|
|
adbArguments(request.input.serial, [
|
|
"shell",
|
|
"sh",
|
|
"-c",
|
|
'cd "$1" || exit\nexec find . -mindepth 1 -maxdepth 1 -exec sh -c "$2" rea-list-directory {} +',
|
|
"rea-list-directory",
|
|
request.input.device_path,
|
|
ADB_DIRECTORY_BATCH_SCRIPT,
|
|
]),
|
|
{
|
|
timeoutMs: LISTING_TIMEOUT_MS,
|
|
maxOutputBytes: LISTING_MAX_OUTPUT_BYTES,
|
|
},
|
|
signal,
|
|
);
|
|
if (!ran.ok) return ran;
|
|
const { entries, unparsedLines } = parseAdbDirectoryListing(
|
|
ran.value.stdout,
|
|
);
|
|
return ok(
|
|
createAnalysisExecution(
|
|
{
|
|
client,
|
|
serial: request.input.serial,
|
|
device_path: request.input.device_path,
|
|
entries,
|
|
...reported(unparsedLines, "complete"),
|
|
},
|
|
provider,
|
|
{
|
|
limitations: [
|
|
"Entry kinds derive from stat permission mode; directory contents and link targets may change during capture",
|
|
...unparsedOverflowLimitation(unparsedLines),
|
|
],
|
|
},
|
|
),
|
|
);
|
|
}
|
|
case "list_adb_features": {
|
|
const ran = await shell(["pm", "list", "features"], {
|
|
timeoutMs: LISTING_TIMEOUT_MS,
|
|
maxOutputBytes: LISTING_MAX_OUTPUT_BYTES,
|
|
});
|
|
if (!ran.ok) return ran;
|
|
const { features, unparsedLines } = parseFeatureListOutput(
|
|
ran.value.stdout,
|
|
);
|
|
return ok(
|
|
createAnalysisExecution(
|
|
{
|
|
client,
|
|
serial: request.input.serial,
|
|
features,
|
|
...reported(unparsedLines, "complete"),
|
|
},
|
|
provider,
|
|
{ limitations: unparsedOverflowLimitation(unparsedLines) },
|
|
),
|
|
);
|
|
}
|
|
case "list_adb_services": {
|
|
const ran = await shell(["service", "list"], {
|
|
timeoutMs: LISTING_TIMEOUT_MS,
|
|
maxOutputBytes: LISTING_MAX_OUTPUT_BYTES,
|
|
});
|
|
if (!ran.ok) return ran;
|
|
const { services, unparsedLines } = parseServiceListOutput(
|
|
ran.value.stdout,
|
|
);
|
|
return ok(
|
|
createAnalysisExecution(
|
|
{
|
|
client,
|
|
serial: request.input.serial,
|
|
services,
|
|
...reported(unparsedLines, "complete"),
|
|
},
|
|
provider,
|
|
{ limitations: unparsedOverflowLimitation(unparsedLines) },
|
|
),
|
|
);
|
|
}
|
|
case "inspect_adb_display": {
|
|
const size = await shell(["wm", "size"], {
|
|
timeoutMs: SETTINGS_TIMEOUT_MS,
|
|
maxOutputBytes: SETTINGS_MAX_OUTPUT_BYTES,
|
|
});
|
|
if (!size.ok) return size;
|
|
const density = await shell(["wm", "density"], {
|
|
timeoutMs: SETTINGS_TIMEOUT_MS,
|
|
maxOutputBytes: SETTINGS_MAX_OUTPUT_BYTES,
|
|
});
|
|
if (!density.ok) return density;
|
|
const parsedSize = parseWmSizeOutput(size.value.stdout);
|
|
if (parsedSize.physical === null)
|
|
return err(
|
|
new AnalysisProtocolError(
|
|
"wm size did not report a physical size; the build may use an unsupported format",
|
|
),
|
|
);
|
|
const parsedDensity = parseWmDensityOutput(density.value.stdout);
|
|
return ok(
|
|
createAnalysisExecution(
|
|
{
|
|
client,
|
|
serial: request.input.serial,
|
|
physical_size: parsedSize.physical,
|
|
override_size: parsedSize.override,
|
|
physical_density: parsedDensity.physical,
|
|
override_density: parsedDensity.override,
|
|
},
|
|
provider,
|
|
{
|
|
limitations: [
|
|
"Override values are present only when a build has overridden the physical values",
|
|
],
|
|
},
|
|
),
|
|
);
|
|
}
|
|
case "inspect_adb_window": {
|
|
let section = "windows";
|
|
const ran = await shell(["dumpsys", "window", section], {
|
|
timeoutMs: SHELL_TIMEOUT_MS,
|
|
maxOutputBytes: DUMPSYS_WINDOW_MAX_OUTPUT_BYTES,
|
|
});
|
|
if (!ran.ok) return ran;
|
|
let { focusedWindow, focusedApp } = parseWindowFocusOutput(
|
|
ran.value.stdout,
|
|
);
|
|
if (focusedWindow === null && focusedApp === null) {
|
|
section = "displays";
|
|
const fallback = await shell(["dumpsys", "window", section], {
|
|
timeoutMs: SHELL_TIMEOUT_MS,
|
|
maxOutputBytes: DUMPSYS_WINDOW_MAX_OUTPUT_BYTES,
|
|
});
|
|
if (!fallback.ok) return fallback;
|
|
({ focusedWindow, focusedApp } = parseWindowFocusOutput(
|
|
fallback.value.stdout,
|
|
));
|
|
}
|
|
const declared = focusedWindow !== null || focusedApp !== null;
|
|
return ok(
|
|
createAnalysisExecution(
|
|
{
|
|
client,
|
|
serial: request.input.serial,
|
|
focused_window: focusedWindow?.value ?? null,
|
|
focused_app: focusedApp?.value ?? null,
|
|
observed_lines: [
|
|
...(focusedWindow === null ? [] : [focusedWindow.rawLine]),
|
|
...(focusedApp === null ? [] : [focusedApp.rawLine]),
|
|
],
|
|
},
|
|
provider,
|
|
{
|
|
limitations: [
|
|
"Focus values are the window manager's declarations at read time; no interaction occurred",
|
|
declared
|
|
? `Focus declarations were read from \`dumpsys window ${section}\``
|
|
: "Neither `dumpsys window windows` nor `dumpsys window displays` declared mCurrentFocus or mFocusedApp; focus is unknown, not absent",
|
|
],
|
|
},
|
|
),
|
|
);
|
|
}
|
|
case "read_adb_setting": {
|
|
const ran = await shell(
|
|
["settings", "get", request.input.namespace, request.input.key],
|
|
{
|
|
timeoutMs: SETTINGS_TIMEOUT_MS,
|
|
maxOutputBytes: SETTINGS_MAX_OUTPUT_BYTES,
|
|
},
|
|
);
|
|
if (!ran.ok) return ran;
|
|
const { value, deviceReportedNull } = parseSettingsGetOutput(
|
|
ran.value.stdout,
|
|
);
|
|
return ok(
|
|
createAnalysisExecution(
|
|
{
|
|
client,
|
|
serial: request.input.serial,
|
|
namespace: request.input.namespace,
|
|
key: request.input.key,
|
|
value,
|
|
device_reported_null: deviceReportedNull,
|
|
},
|
|
provider,
|
|
{
|
|
limitations: [
|
|
"Android prints the literal string null for absent keys; that is reported as a device-side null observation",
|
|
],
|
|
},
|
|
),
|
|
);
|
|
}
|
|
case "resolve_adb_packages": {
|
|
const ran = await shell(["pm", "list", "packages", "-f"], {
|
|
timeoutMs: PACKAGE_LIST_TIMEOUT_MS,
|
|
maxOutputBytes: PACKAGE_LIST_MAX_OUTPUT_BYTES,
|
|
});
|
|
if (!ran.ok) return ran;
|
|
const { packages, unparsedLines } = parsePackageListOutput(
|
|
ran.value.stdout,
|
|
);
|
|
const needle = request.input.query.toLowerCase();
|
|
const matches = packages.filter((entry) =>
|
|
entry.package_name.toLowerCase().includes(needle),
|
|
);
|
|
return ok(
|
|
createAnalysisExecution(
|
|
{
|
|
client,
|
|
serial: request.input.serial,
|
|
query: request.input.query,
|
|
matches,
|
|
exact_match:
|
|
matches.length === 1 &&
|
|
matches[0]!.package_name.toLowerCase() === needle,
|
|
coverage: unparsedLines.length > 0 ? "partial" : "complete",
|
|
},
|
|
provider,
|
|
{
|
|
limitations: [
|
|
"Matching is a case-insensitive substring over package names; application display labels are not part of pm output",
|
|
...unparsedOverflowLimitation(unparsedLines),
|
|
],
|
|
},
|
|
),
|
|
);
|
|
}
|
|
case "install_adb_package":
|
|
return this.installPackage(
|
|
operation,
|
|
selection,
|
|
client,
|
|
provider,
|
|
request.input,
|
|
signal,
|
|
);
|
|
case "uninstall_adb_package":
|
|
return this.uninstallPackage(
|
|
operation,
|
|
selection,
|
|
client,
|
|
provider,
|
|
request.input,
|
|
signal,
|
|
);
|
|
case "start_adb_app":
|
|
return this.startApp(
|
|
operation,
|
|
selection,
|
|
client,
|
|
provider,
|
|
request.input,
|
|
signal,
|
|
);
|
|
case "start_adb_activity":
|
|
return this.startActivity(
|
|
operation,
|
|
selection,
|
|
client,
|
|
provider,
|
|
request.input,
|
|
signal,
|
|
);
|
|
case "stop_adb_app":
|
|
return this.stopApp(
|
|
operation,
|
|
selection,
|
|
client,
|
|
provider,
|
|
request.input,
|
|
signal,
|
|
);
|
|
}
|
|
}
|
|
|
|
private async pullPackage(
|
|
operation: "pull_adb_package",
|
|
selection: AdbBinarySelection,
|
|
client: ClientIdentity,
|
|
provider: ProviderIdentity,
|
|
input: {
|
|
readonly serial: string;
|
|
readonly package: string;
|
|
readonly output_directory: string;
|
|
},
|
|
signal: AbortSignal | undefined,
|
|
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
|
const { serial, package: packageName } = input;
|
|
const paths = await runAdb(
|
|
operation,
|
|
serial,
|
|
selection.binary,
|
|
adbArguments(serial, ["shell", "pm", "path", packageName]),
|
|
{
|
|
timeoutMs: PM_PATH_TIMEOUT_MS,
|
|
maxOutputBytes: PM_PATH_MAX_OUTPUT_BYTES,
|
|
},
|
|
signal,
|
|
);
|
|
if (!paths.ok) return paths;
|
|
const parsed = parsePmPathOutput(paths.value.stdout);
|
|
const capturedOutput = {
|
|
stdout: paths.value.stdout,
|
|
stderr: paths.value.stderr,
|
|
truncated: false,
|
|
};
|
|
if (parsed.rejectedPaths.length > 0) {
|
|
const first = parsed.rejectedPaths[0]!;
|
|
const otherCount = parsed.rejectedPaths.length - 1;
|
|
return err(
|
|
new AnalysisOutputError(operation, "unsafe_device_path", {
|
|
capturedOutput,
|
|
userMessage: `pm path for ${packageName} reported an APK path that cannot be written as a file on this host: ${displayDevicePath(first.device_path)} (${first.reason})${otherCount > 0 ? ` and ${String(otherCount)} other path(s)` : ""}. No APK was pulled.`,
|
|
remediationAction:
|
|
"Review details.diagnostics for the refused device paths and details.captured_output for the retained pm path output. REA does not rename device-reported APK paths; correct the device path policy before retrying.",
|
|
diagnostics: {
|
|
package_name: packageName,
|
|
rejected_paths: parsed.rejectedPaths.map(
|
|
({ device_path, reason }) => ({
|
|
device_path,
|
|
reason,
|
|
}),
|
|
),
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
if (parsed.paths.length === 0 && parsed.unparsedLines.length === 0)
|
|
return err(
|
|
new AnalysisUnsupportedTargetError(
|
|
operation,
|
|
serial,
|
|
`Package Manager reported no APK paths for ${packageName}; it may not be installed for user 0`,
|
|
),
|
|
);
|
|
if (parsed.unparsedLines.length > 0)
|
|
return err(
|
|
new AnalysisProtocolError(
|
|
`pm path reported an unrecognized line: ${parsed.unparsedLines[0]}`,
|
|
{ capturedOutput },
|
|
),
|
|
);
|
|
const destinationNames = new Set<string>();
|
|
for (const entry of parsed.paths) {
|
|
if (destinationNames.has(entry.file_name))
|
|
return err(
|
|
new AnalysisOutputError(operation, "duplicate_local_destination", {
|
|
capturedOutput,
|
|
userMessage: `pm path for ${packageName} reported multiple APK paths with the same local file name ${displayDevicePath(entry.file_name)}; no APK was pulled.`,
|
|
remediationAction:
|
|
"Review details.diagnostics and details.captured_output for the conflicting device paths. REA does not rename device-reported paths; correct Package Manager's path response before retrying.",
|
|
diagnostics: {
|
|
package_name: packageName,
|
|
file_name: entry.file_name,
|
|
device_paths: parsed.paths
|
|
.filter((candidate) => candidate.file_name === entry.file_name)
|
|
.map((candidate) => candidate.device_path),
|
|
},
|
|
}),
|
|
);
|
|
destinationNames.add(entry.file_name);
|
|
}
|
|
const directory = await prepareOutputDirectory(
|
|
operation,
|
|
input.output_directory,
|
|
);
|
|
if (!directory.ok) return directory;
|
|
const packageDirectory = join(directory.value, packageName);
|
|
let packageDirectoryCreated = false;
|
|
let packageDirectoryIdentity: OutputDirectoryIdentity | undefined;
|
|
try {
|
|
if (
|
|
await lstat(packageDirectory).then(
|
|
() => true,
|
|
() => false,
|
|
)
|
|
)
|
|
throw new Error(
|
|
`The package output directory already exists: ${packageDirectory}`,
|
|
);
|
|
await mkdir(packageDirectory);
|
|
packageDirectoryCreated = true;
|
|
const created = await lstat(packageDirectory);
|
|
if (!created.isDirectory() || created.isSymbolicLink())
|
|
throw new Error(
|
|
`The created package output is not a regular directory: ${packageDirectory}`,
|
|
);
|
|
packageDirectoryIdentity = {
|
|
dev: created.dev,
|
|
ino: created.ino,
|
|
mode: created.mode,
|
|
};
|
|
} catch (cause) {
|
|
const primary = new AnalysisOutputError(
|
|
operation,
|
|
cause instanceof Error ? cause.message : String(cause),
|
|
{ cause },
|
|
);
|
|
if (!packageDirectoryCreated) return err(primary);
|
|
const owner = {
|
|
operation,
|
|
path: packageDirectory,
|
|
primary,
|
|
remove: () =>
|
|
removeOwnedOutputDirectory(
|
|
packageDirectory,
|
|
packageDirectoryIdentity,
|
|
),
|
|
};
|
|
const cleanup = new ProviderCleanupError(
|
|
ADB_PROVIDER_IDENTITY.id,
|
|
[packageDirectory],
|
|
{
|
|
reason: "Could not establish an owned package directory identity",
|
|
},
|
|
{ operation, cause },
|
|
);
|
|
return err(this.#retainOutputDirectoryCleanup(owner, cleanup));
|
|
}
|
|
const failOwnedPackage = async (
|
|
primary: AnalysisError,
|
|
): Promise<Result<AnalysisExecution, AnalysisError>> => {
|
|
const owner = {
|
|
operation,
|
|
path: packageDirectory,
|
|
primary,
|
|
remove: () =>
|
|
removeOwnedOutputDirectory(
|
|
packageDirectory,
|
|
packageDirectoryIdentity,
|
|
),
|
|
};
|
|
try {
|
|
await owner.remove();
|
|
} catch (cause: unknown) {
|
|
const cleanup = new ProviderCleanupError(
|
|
ADB_PROVIDER_IDENTITY.id,
|
|
[packageDirectory],
|
|
{ reason: cause instanceof Error ? cause.message : String(cause) },
|
|
{ operation, cause },
|
|
);
|
|
return err(this.#retainOutputDirectoryCleanup(owner, cleanup));
|
|
}
|
|
return err(primary);
|
|
};
|
|
const destinationError = await reservePackageDestinations(
|
|
operation,
|
|
packageName,
|
|
packageDirectory,
|
|
parsed.paths,
|
|
capturedOutput,
|
|
);
|
|
if (destinationError !== undefined)
|
|
return failOwnedPackage(destinationError);
|
|
const artifacts: {
|
|
device_path: string;
|
|
file_name: string;
|
|
local_path: string;
|
|
bytes: number;
|
|
sha256: string;
|
|
role: "base" | "split" | "unknown";
|
|
role_basis: "file_name";
|
|
}[] = [];
|
|
const failures: {
|
|
device_path: string | null;
|
|
stage: "resolve_paths" | "pull" | "digest";
|
|
message: string;
|
|
cause: unknown;
|
|
}[] = [];
|
|
const observedFailures = () =>
|
|
failures.map(({ device_path, stage, message }) => ({
|
|
device_path,
|
|
stage,
|
|
message,
|
|
}));
|
|
const cancelledPackagePull = (
|
|
error: AnalysisCancelledError,
|
|
localPath: string,
|
|
devicePath: string,
|
|
stage: "pull" | "digest",
|
|
) => {
|
|
error.retainPartialObservation({
|
|
provider_id: "adb",
|
|
operation,
|
|
result: {
|
|
client,
|
|
serial,
|
|
package_name: packageName,
|
|
output_directory: directory.value,
|
|
artifacts,
|
|
failures: [
|
|
...observedFailures(),
|
|
{
|
|
device_path: devicePath,
|
|
stage,
|
|
message:
|
|
stage === "pull"
|
|
? `Pull cancelled; any incomplete file is retained at ${localPath}. Remaining APKs were not attempted.`
|
|
: `Digesting the pulled file was cancelled; the file is retained at ${localPath}. Remaining APKs were not attempted.`,
|
|
},
|
|
],
|
|
coverage: "partial",
|
|
},
|
|
});
|
|
return err(error);
|
|
};
|
|
for (const entry of parsed.paths) {
|
|
const localPath = join(packageDirectory, entry.file_name);
|
|
const pulled = await runAdb(
|
|
operation,
|
|
serial,
|
|
selection.binary,
|
|
adbArguments(serial, ["pull", entry.device_path, localPath]),
|
|
{ timeoutMs: PULL_TIMEOUT_MS, maxOutputBytes: PULL_MAX_OUTPUT_BYTES },
|
|
signal,
|
|
);
|
|
if (!pulled.ok) {
|
|
if (pulled.error instanceof AnalysisCancelledError)
|
|
return cancelledPackagePull(
|
|
pulled.error,
|
|
localPath,
|
|
entry.device_path,
|
|
"pull",
|
|
);
|
|
failures.push({
|
|
device_path: entry.device_path,
|
|
stage: "pull",
|
|
message: pulled.error.message,
|
|
cause: pulled.error,
|
|
});
|
|
continue;
|
|
}
|
|
try {
|
|
const digest = await hashAdbFile(
|
|
localPath,
|
|
operation,
|
|
signal,
|
|
this.#resources,
|
|
);
|
|
artifacts.push({
|
|
device_path: entry.device_path,
|
|
file_name: entry.file_name,
|
|
local_path: localPath,
|
|
bytes: digest.bytes,
|
|
sha256: digest.sha256,
|
|
role: entry.role,
|
|
role_basis: "file_name",
|
|
});
|
|
} catch (cause: unknown) {
|
|
const digestFailure =
|
|
cause instanceof AdbDigestFailure ? cause.fileFailure : undefined;
|
|
const completedDigest =
|
|
digestFailure?.outcome.kind === "completed"
|
|
? digestFailure.outcome.value
|
|
: undefined;
|
|
const partialObservation: AdbPackagePullPartialObservation = {
|
|
provider_id: "adb",
|
|
operation,
|
|
result: {
|
|
client,
|
|
serial,
|
|
package_name: packageName,
|
|
output_directory: directory.value,
|
|
artifacts:
|
|
completedDigest === undefined
|
|
? artifacts
|
|
: [
|
|
...artifacts,
|
|
{
|
|
device_path: entry.device_path,
|
|
file_name: entry.file_name,
|
|
local_path: localPath,
|
|
bytes: completedDigest.bytes,
|
|
sha256: completedDigest.sha256,
|
|
role: entry.role,
|
|
role_basis: "file_name",
|
|
},
|
|
],
|
|
failures: [
|
|
...observedFailures(),
|
|
{
|
|
device_path: entry.device_path,
|
|
stage: "digest",
|
|
message:
|
|
completedDigest === undefined
|
|
? `The pulled file remains at ${localPath}, but its digest and descriptor cleanup are unconfirmed.`
|
|
: digestFailure instanceof RegularFileCancelled
|
|
? `The pulled file digest and descriptor cleanup are confirmed; the request was cancelled: ${localPath}`
|
|
: `The pulled file digest is known, but its descriptor cleanup is unconfirmed: ${localPath}`,
|
|
},
|
|
],
|
|
coverage: "partial",
|
|
},
|
|
};
|
|
const cleanupError = adbDigestError(
|
|
cause,
|
|
operation,
|
|
partialObservation,
|
|
);
|
|
if (cleanupError !== undefined) return err(cleanupError);
|
|
if (cause instanceof AnalysisCancelledError)
|
|
return cancelledPackagePull(
|
|
cause,
|
|
localPath,
|
|
entry.device_path,
|
|
"digest",
|
|
);
|
|
const physicalCause = stableFileHashCause(cause);
|
|
failures.push({
|
|
device_path: entry.device_path,
|
|
stage: "digest",
|
|
message: `The pulled file could not be read back after pulling: ${localPath}: ${physicalCause instanceof Error ? physicalCause.message : String(physicalCause)}`,
|
|
cause,
|
|
});
|
|
}
|
|
}
|
|
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",
|
|
),
|
|
},
|
|
);
|
|
return failOwnedPackage(primary);
|
|
}
|
|
return ok(
|
|
createAnalysisExecution(
|
|
{
|
|
client,
|
|
serial,
|
|
package_name: packageName,
|
|
output_directory: directory.value,
|
|
artifacts,
|
|
failures: observedFailures(),
|
|
coverage: failures.length === 0 ? "complete" : "partial",
|
|
},
|
|
provider,
|
|
{
|
|
limitations: [
|
|
"APK role names derive from the pulled file names (base.apk, split_*.apk)",
|
|
"Pulled artifacts are host-local copies; the device retains the originals",
|
|
],
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
private async pullFile(
|
|
operation: "pull_adb_file",
|
|
selection: AdbBinarySelection,
|
|
client: ClientIdentity,
|
|
provider: ProviderIdentity,
|
|
input: {
|
|
readonly serial: string;
|
|
readonly device_path: string;
|
|
readonly output_directory: string;
|
|
},
|
|
signal: AbortSignal | undefined,
|
|
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
|
const derived = adbHostFileName(input.device_path);
|
|
if (!derived.ok)
|
|
return err(
|
|
new AnalysisInputError(operation, undefined, [
|
|
{
|
|
path: ["device_path"],
|
|
reason: "invalid_value",
|
|
message: `The device path ${JSON.stringify(input.device_path)} does not name a file that can be written on this host (${derived.reason}); REA never renames a device-reported file.`,
|
|
},
|
|
]),
|
|
);
|
|
const directory = await prepareOutputDirectory(
|
|
operation,
|
|
input.output_directory,
|
|
);
|
|
if (!directory.ok) return directory;
|
|
const localPath = join(directory.value, derived.fileName);
|
|
const existing = await refuseExistingTarget(operation, localPath);
|
|
if (existing !== undefined) return err(existing);
|
|
const pulled = await runAdb(
|
|
operation,
|
|
input.serial,
|
|
selection.binary,
|
|
adbArguments(input.serial, ["pull", input.device_path, localPath]),
|
|
{ timeoutMs: PULL_TIMEOUT_MS, maxOutputBytes: PULL_MAX_OUTPUT_BYTES },
|
|
signal,
|
|
);
|
|
if (!pulled.ok) return pulled;
|
|
try {
|
|
const digest = await hashAdbFile(
|
|
localPath,
|
|
operation,
|
|
signal,
|
|
this.#resources,
|
|
);
|
|
return ok(
|
|
createAnalysisExecution(
|
|
{
|
|
client,
|
|
serial: input.serial,
|
|
device_path: input.device_path,
|
|
local_path: localPath,
|
|
bytes: digest.bytes,
|
|
sha256: digest.sha256,
|
|
},
|
|
provider,
|
|
{
|
|
limitations: [
|
|
"The pulled file is a host-local copy; the device retains the original",
|
|
],
|
|
},
|
|
),
|
|
);
|
|
} catch (cause) {
|
|
const partialObservation: AdbAcquiredFilePartialObservation = {
|
|
provider_id: "adb",
|
|
operation,
|
|
result: {
|
|
client,
|
|
serial: input.serial,
|
|
local_path: localPath,
|
|
device_path: input.device_path,
|
|
digest_status: "unknown",
|
|
},
|
|
};
|
|
const cleanupError = adbDigestError(cause, operation, partialObservation);
|
|
if (cleanupError !== undefined) return err(cleanupError);
|
|
if (cause instanceof AnalysisCancelledError) {
|
|
cause.retainPartialObservation(partialObservation);
|
|
return err(cause);
|
|
}
|
|
return err(
|
|
new AnalysisOutputError(
|
|
operation,
|
|
`The pulled file could not be read back after pulling: ${localPath}`,
|
|
{ cause },
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
private async pushFile(
|
|
operation: "push_adb_file",
|
|
selection: AdbBinarySelection,
|
|
client: ClientIdentity,
|
|
provider: ProviderIdentity,
|
|
input: {
|
|
readonly serial: string;
|
|
readonly local_path: string;
|
|
readonly device_path: string;
|
|
readonly overwrite: boolean;
|
|
},
|
|
signal: AbortSignal | undefined,
|
|
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
|
const localPath = resolve(input.local_path);
|
|
const info = await stat(localPath).then(
|
|
(value) => value,
|
|
() => null,
|
|
);
|
|
if (info === null || !info.isFile())
|
|
return err(
|
|
new AnalysisInputError(operation, {
|
|
cause: new Error(`The local file does not exist: ${localPath}`),
|
|
}),
|
|
);
|
|
return this.#withRuntimeRoot(
|
|
operation,
|
|
{
|
|
serial: input.serial,
|
|
local_path: localPath,
|
|
device_path: input.device_path,
|
|
overwrite: input.overwrite,
|
|
},
|
|
{ prefix: "rea-adb-input-" },
|
|
signal,
|
|
async (root, resources) => {
|
|
const digest = await snapshotAdbInput(
|
|
localPath,
|
|
root,
|
|
resources,
|
|
operation,
|
|
signal,
|
|
);
|
|
const existsProbe = await probeAdbPathExistence(
|
|
operation,
|
|
input.serial,
|
|
input.device_path,
|
|
selection.binary,
|
|
{ timeoutMs: SETTINGS_TIMEOUT_MS, maxOutputBytes: 1024 },
|
|
signal,
|
|
);
|
|
if (!existsProbe.ok) return existsProbe;
|
|
const deviceExists = existsProbe.value;
|
|
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", digest.path, input.device_path]),
|
|
{ timeoutMs: PULL_TIMEOUT_MS, maxOutputBytes: PULL_MAX_OUTPUT_BYTES },
|
|
signal,
|
|
);
|
|
if (!pushed.ok) return pushed;
|
|
const receipt = (
|
|
deviceSha256: string | null,
|
|
source: "device_sha256sum" | "unavailable",
|
|
extra: 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 a private snapshot of the caller's file to the device",
|
|
...extra,
|
|
],
|
|
},
|
|
);
|
|
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 completed = receipt(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(
|
|
createEvidence(
|
|
completed.subject ?? undefined,
|
|
completed.provider,
|
|
{
|
|
operation,
|
|
parameters: input,
|
|
result: completed.result,
|
|
rawResult: completed.rawResult,
|
|
limitations: completed.limitations,
|
|
locations: completed.locations,
|
|
confidence: "observed",
|
|
},
|
|
),
|
|
);
|
|
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)
|
|
return err(
|
|
new AnalysisArtifactChangedError(
|
|
operation,
|
|
input.device_path,
|
|
`The device-reported digest does not match the staged local file: ${localPath}`,
|
|
{
|
|
partialObservation: createEvidence(
|
|
completed.subject ?? undefined,
|
|
completed.provider,
|
|
{
|
|
operation,
|
|
parameters: input,
|
|
result: completed.result,
|
|
rawResult: completed.rawResult,
|
|
limitations: completed.limitations,
|
|
locations: completed.locations,
|
|
confidence: "observed",
|
|
},
|
|
),
|
|
},
|
|
),
|
|
);
|
|
return ok(completed);
|
|
},
|
|
);
|
|
}
|
|
|
|
private async installPackage(
|
|
operation: "install_adb_package",
|
|
selection: AdbBinarySelection,
|
|
client: ClientIdentity,
|
|
provider: ProviderIdentity,
|
|
input: {
|
|
readonly serial: string;
|
|
readonly apk_path: string;
|
|
readonly replace: boolean;
|
|
},
|
|
signal: AbortSignal | undefined,
|
|
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
|
const apkPath = resolve(input.apk_path);
|
|
const info = await stat(apkPath).then(
|
|
(value) => value,
|
|
() => null,
|
|
);
|
|
if (info === null || !info.isFile())
|
|
return err(
|
|
new AnalysisInputError(operation, {
|
|
cause: new Error(`The local APK does not exist: ${apkPath}`),
|
|
}),
|
|
);
|
|
return this.#withRuntimeRoot(
|
|
operation,
|
|
{
|
|
serial: input.serial,
|
|
apk_path: apkPath,
|
|
replace: input.replace,
|
|
},
|
|
{ prefix: "rea-adb-apk-" },
|
|
signal,
|
|
async (root, resources) => {
|
|
const digest = await snapshotAdbInput(
|
|
apkPath,
|
|
root,
|
|
resources,
|
|
operation,
|
|
signal,
|
|
);
|
|
const installed = await runAdb(
|
|
operation,
|
|
input.serial,
|
|
selection.binary,
|
|
adbArguments(input.serial, [
|
|
"install",
|
|
...(input.replace ? ["-r"] : []),
|
|
digest.path,
|
|
]),
|
|
{ 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 identifies the private host snapshot",
|
|
"Whether an existing package was replaced is unknown; replace_requested records only the adb install mode",
|
|
],
|
|
},
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
private async uninstallPackage(
|
|
operation: "uninstall_adb_package",
|
|
selection: AdbBinarySelection,
|
|
client: ClientIdentity,
|
|
provider: ProviderIdentity,
|
|
input: { readonly serial: string; readonly package: string },
|
|
signal: AbortSignal | undefined,
|
|
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
|
const uninstalled = await runAdb(
|
|
operation,
|
|
input.serial,
|
|
selection.binary,
|
|
adbArguments(input.serial, [
|
|
"shell",
|
|
"pm",
|
|
"uninstall",
|
|
"--user",
|
|
"0",
|
|
input.package,
|
|
]),
|
|
{
|
|
timeoutMs: PACKAGE_LIST_TIMEOUT_MS,
|
|
maxOutputBytes: PULL_MAX_OUTPUT_BYTES,
|
|
},
|
|
signal,
|
|
);
|
|
if (!uninstalled.ok)
|
|
return (
|
|
packageChangeRefusal(operation, input.serial, uninstalled.error) ??
|
|
uninstalled
|
|
);
|
|
const verdict = parsePackageChangeOutput(uninstalled.value.stdout);
|
|
if (verdict.status === "failure")
|
|
return err(
|
|
new AnalysisUnsupportedTargetError(
|
|
operation,
|
|
input.serial,
|
|
verdict.reason ?? "the device refused the removal",
|
|
),
|
|
);
|
|
return ok(
|
|
createAnalysisExecution(
|
|
{
|
|
client,
|
|
serial: input.serial,
|
|
package_name: input.package,
|
|
},
|
|
provider,
|
|
{
|
|
limitations: [
|
|
"Uninstall explicitly requests user 0 without data retention (-k); device enforcement is not independently verified",
|
|
],
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
private async startApp(
|
|
operation: "start_adb_app",
|
|
selection: AdbBinarySelection,
|
|
client: ClientIdentity,
|
|
provider: ProviderIdentity,
|
|
input: { readonly serial: string; readonly package: string },
|
|
signal: AbortSignal | undefined,
|
|
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
|
const resolved = await runAdb(
|
|
operation,
|
|
input.serial,
|
|
selection.binary,
|
|
adbArguments(input.serial, [
|
|
"shell",
|
|
"cmd",
|
|
"package",
|
|
"resolve-activity",
|
|
"--brief",
|
|
"-c",
|
|
"android.intent.category.LAUNCHER",
|
|
input.package,
|
|
]),
|
|
{ timeoutMs: SHELL_TIMEOUT_MS, maxOutputBytes: PM_PATH_MAX_OUTPUT_BYTES },
|
|
signal,
|
|
);
|
|
if (!resolved.ok) return resolved;
|
|
const component = parseResolveActivityOutput(resolved.value.stdout);
|
|
if (component === null)
|
|
return err(
|
|
new AnalysisUnsupportedTargetError(
|
|
operation,
|
|
input.serial,
|
|
`No launcher activity is declared for ${input.package}`,
|
|
),
|
|
);
|
|
return this.runAmStart(
|
|
operation,
|
|
selection,
|
|
client,
|
|
provider,
|
|
input.serial,
|
|
["-n", component],
|
|
{
|
|
client,
|
|
serial: input.serial,
|
|
package_name: input.package,
|
|
component,
|
|
},
|
|
signal,
|
|
);
|
|
}
|
|
|
|
private async startActivity(
|
|
operation: "start_adb_activity",
|
|
selection: AdbBinarySelection,
|
|
client: ClientIdentity,
|
|
provider: ProviderIdentity,
|
|
input: {
|
|
readonly serial: string;
|
|
readonly action: string;
|
|
readonly data_uri?: string | undefined;
|
|
readonly component?: string | undefined;
|
|
readonly extras: readonly {
|
|
readonly key: string;
|
|
readonly type: "string" | "boolean" | "int" | "long" | "float";
|
|
readonly value: string;
|
|
}[];
|
|
},
|
|
signal: AbortSignal | undefined,
|
|
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
|
const extraFlags: Record<
|
|
"string" | "boolean" | "int" | "long" | "float",
|
|
string
|
|
> = {
|
|
string: "--es",
|
|
boolean: "--ez",
|
|
int: "--ei",
|
|
long: "--el",
|
|
float: "--ef",
|
|
};
|
|
for (const extra of input.extras)
|
|
if (extra.type === "boolean" && !/^(true|false)$/u.test(extra.value))
|
|
return err(
|
|
new AnalysisInputError(operation, {
|
|
cause: new Error(
|
|
`Boolean extra ${extra.key} must be true or false`,
|
|
),
|
|
}),
|
|
);
|
|
else if (
|
|
(extra.type === "int" || extra.type === "long") &&
|
|
!/^-?\d+$/u.test(extra.value)
|
|
)
|
|
return err(
|
|
new AnalysisInputError(operation, {
|
|
cause: new Error(
|
|
`${extra.type === "int" ? "Integer" : "Long"} extra ${extra.key} must be an integer`,
|
|
),
|
|
}),
|
|
);
|
|
else if (extra.type === "float" && !/^-?\d+(\.\d+)?$/u.test(extra.value))
|
|
return err(
|
|
new AnalysisInputError(operation, {
|
|
cause: new Error(`Float extra ${extra.key} must be a number`),
|
|
}),
|
|
);
|
|
const arguments_ = [
|
|
"-a",
|
|
input.action,
|
|
...(input.data_uri === undefined ? [] : ["-d", input.data_uri]),
|
|
...(input.component === undefined ? [] : ["-n", input.component]),
|
|
...input.extras.flatMap((extra) => [
|
|
extraFlags[extra.type],
|
|
extra.key,
|
|
extra.value,
|
|
]),
|
|
];
|
|
return this.runAmStart(
|
|
operation,
|
|
selection,
|
|
client,
|
|
provider,
|
|
input.serial,
|
|
arguments_,
|
|
{
|
|
client,
|
|
serial: input.serial,
|
|
action: input.action,
|
|
data_uri: input.data_uri ?? null,
|
|
component: input.component ?? null,
|
|
extras: input.extras.map((extra) => ({ ...extra })),
|
|
},
|
|
signal,
|
|
);
|
|
}
|
|
|
|
private async runAmStart(
|
|
operation: "start_adb_app" | "start_adb_activity",
|
|
selection: AdbBinarySelection,
|
|
client: ClientIdentity,
|
|
provider: ProviderIdentity,
|
|
serial: string,
|
|
arguments_: readonly string[],
|
|
resultFields: Record<string, unknown>,
|
|
signal: AbortSignal | undefined,
|
|
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
|
const started = await runAdb(
|
|
operation,
|
|
serial,
|
|
selection.binary,
|
|
adbArguments(serial, ["shell", "am", "start", "-W", ...arguments_]),
|
|
{ timeoutMs: SHELL_TIMEOUT_MS, maxOutputBytes: PM_PATH_MAX_OUTPUT_BYTES },
|
|
signal,
|
|
);
|
|
if (!started.ok) return started;
|
|
const verdict = parseAmStartOutput(
|
|
started.value.stdout,
|
|
started.value.stderr,
|
|
);
|
|
const result = {
|
|
...resultFields,
|
|
started_activity: verdict.startedActivity,
|
|
reported_activity: verdict.activity,
|
|
reported_status: verdict.reportedStatus,
|
|
};
|
|
if (verdict.status === "refused") {
|
|
const partialObservation: AdbActivityStartPartialObservation =
|
|
operation === "start_adb_app"
|
|
? {
|
|
provider_id: "adb",
|
|
operation,
|
|
result: adbResultSchemas.start_adb_app.parse(result),
|
|
}
|
|
: {
|
|
provider_id: "adb",
|
|
operation,
|
|
result: adbResultSchemas.start_adb_activity.parse(result),
|
|
};
|
|
return err(
|
|
new AnalysisUnsupportedTargetError(
|
|
operation,
|
|
serial,
|
|
verdict.reason ?? "am start refused the intent",
|
|
{
|
|
capturedOutput: {
|
|
stdout: started.value.stdout,
|
|
stderr: started.value.stderr,
|
|
truncated: false,
|
|
},
|
|
partialObservation,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
return ok(
|
|
createAnalysisExecution(result, provider, {
|
|
rawResult: {
|
|
stdout: started.value.stdout,
|
|
stderr: started.value.stderr,
|
|
},
|
|
limitations: [
|
|
"Starting an activity changes device state; am start -W waits for launch completion but no lifecycle observation is captured",
|
|
],
|
|
}) as AnalysisExecution,
|
|
);
|
|
}
|
|
|
|
private async stopApp(
|
|
operation: "stop_adb_app",
|
|
selection: AdbBinarySelection,
|
|
client: ClientIdentity,
|
|
provider: ProviderIdentity,
|
|
input: { readonly serial: string; readonly package: string },
|
|
signal: AbortSignal | undefined,
|
|
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
|
const stopped = await runAdb(
|
|
operation,
|
|
input.serial,
|
|
selection.binary,
|
|
adbArguments(input.serial, ["shell", "am", "force-stop", input.package]),
|
|
{ timeoutMs: SHELL_TIMEOUT_MS, maxOutputBytes: PM_PATH_MAX_OUTPUT_BYTES },
|
|
signal,
|
|
);
|
|
if (!stopped.ok) return stopped;
|
|
return ok(
|
|
createAnalysisExecution(
|
|
{
|
|
client,
|
|
serial: input.serial,
|
|
package_name: input.package,
|
|
},
|
|
provider,
|
|
{
|
|
limitations: [
|
|
"Force-stop succeeds even for packages with no running process; it reports completion, not prior state",
|
|
],
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
private async captureScreen(
|
|
operation: "capture_adb_screen",
|
|
selection: AdbBinarySelection,
|
|
client: ClientIdentity,
|
|
provider: ProviderIdentity,
|
|
input: { readonly serial: string; readonly output_directory: string },
|
|
signal: AbortSignal | undefined,
|
|
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
|
const directory = await prepareOutputDirectory(
|
|
operation,
|
|
input.output_directory,
|
|
);
|
|
if (!directory.ok) return directory;
|
|
const localPath = join(directory.value, "screen.png");
|
|
const existing = await refuseExistingTarget(operation, localPath);
|
|
if (existing !== undefined) return err(existing);
|
|
const captured = await runAdbCapture(
|
|
operation,
|
|
input.serial,
|
|
selection.binary,
|
|
adbArguments(input.serial, ["exec-out", "screencap", "-p"]),
|
|
{ timeoutMs: SCREEN_TIMEOUT_MS, maxOutputBytes: SCREEN_MAX_OUTPUT_BYTES },
|
|
signal,
|
|
);
|
|
if (!captured.ok) return captured;
|
|
if (signal?.aborted === true)
|
|
return err(
|
|
new AnalysisCancelledError(operation, { cause: signal.reason }),
|
|
);
|
|
const dimensions = parsePngDimensions(captured.value.stdout);
|
|
if (dimensions.width === null)
|
|
return err(
|
|
new AnalysisProtocolError(
|
|
"screencap did not return a PNG payload; the device may not expose a display",
|
|
),
|
|
);
|
|
return this.#withRuntimeRoot(
|
|
operation,
|
|
{ serial: input.serial, output_directory: directory.value },
|
|
{ parent: directory.value, prefix: ".rea-screen-" },
|
|
signal,
|
|
async (stage, resources) => {
|
|
let publicationCloseFailure: ScreenPublicationCloseFailure | undefined;
|
|
let usedExclusiveFallback = false;
|
|
try {
|
|
const stagedPath = join(stage.path, "screen.png");
|
|
await writeFile(stagedPath, captured.value.stdout, { flag: "wx" });
|
|
await syncStagedAdbFile(stagedPath, resources, operation);
|
|
if (signal?.aborted === true)
|
|
return err(
|
|
new AnalysisCancelledError(operation, { cause: signal.reason }),
|
|
);
|
|
try {
|
|
// A same-filesystem hard link atomically publishes without replacing
|
|
// caller output. Its success is the publication cutoff.
|
|
await link(stagedPath, localPath);
|
|
} catch (cause: unknown) {
|
|
if (!hardLinksUnsupported(cause)) throw cause;
|
|
// Exclusive creation prevents replacement, but makes the path
|
|
// visible while the fallback writes the complete file.
|
|
usedExclusiveFallback = true;
|
|
publicationCloseFailure =
|
|
(await publishScreenExclusively(
|
|
localPath,
|
|
captured.value.stdout,
|
|
resources,
|
|
operation,
|
|
signal,
|
|
)) ?? undefined;
|
|
}
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof AnalysisError) return err(cause);
|
|
return err(
|
|
new AnalysisOutputError(
|
|
operation,
|
|
`The screen output could not be published exclusively: ${localPath}`,
|
|
{ cause },
|
|
),
|
|
);
|
|
}
|
|
const sha256 = createHash("sha256")
|
|
.update(captured.value.stdout)
|
|
.digest("hex");
|
|
const completed = createAnalysisExecution(
|
|
{
|
|
client,
|
|
serial: input.serial,
|
|
local_path: localPath,
|
|
bytes: captured.value.stdout.byteLength,
|
|
sha256,
|
|
width: dimensions.width,
|
|
height: dimensions.height,
|
|
},
|
|
provider,
|
|
{
|
|
limitations: [
|
|
"The capture is one frame at read time; no recording or interaction occurred",
|
|
...(usedExclusiveFallback
|
|
? [
|
|
"The exclusive-create fallback makes the output path visible while its bytes are being written",
|
|
]
|
|
: []),
|
|
],
|
|
},
|
|
);
|
|
if (publicationCloseFailure !== undefined) {
|
|
const cleanupError = this.#cleanupError(
|
|
operation,
|
|
{ serial: input.serial, output_directory: directory.value },
|
|
[publicationCloseFailure.cause],
|
|
[publicationCloseFailure.resource],
|
|
completed,
|
|
);
|
|
publicationCloseFailure.bindCleanupError(cleanupError);
|
|
return err(cleanupError);
|
|
}
|
|
return ok(completed);
|
|
},
|
|
);
|
|
}
|
|
|
|
private async collectBugreport(
|
|
operation: "collect_adb_bugreport",
|
|
selection: AdbBinarySelection,
|
|
client: ClientIdentity,
|
|
provider: ProviderIdentity,
|
|
input: { readonly serial: string; readonly output_directory: string },
|
|
signal: AbortSignal | undefined,
|
|
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
|
const directory = await prepareOutputDirectory(
|
|
operation,
|
|
input.output_directory,
|
|
);
|
|
if (!directory.ok) return directory;
|
|
let stagingDirectory: string;
|
|
let stagingIdentity: OutputDirectoryIdentity | undefined;
|
|
let retainedObservation: AdbAcquiredFilePartialObservation | undefined;
|
|
let publishedPath: string | undefined;
|
|
try {
|
|
stagingDirectory = await mkdtemp(
|
|
join(directory.value, ".rea-bugreport-"),
|
|
);
|
|
} catch (cause) {
|
|
return err(
|
|
new AnalysisOutputError(
|
|
operation,
|
|
"The bugreport staging directory could not be created",
|
|
{ cause },
|
|
),
|
|
);
|
|
}
|
|
const collectedResult = await (async (): Promise<
|
|
Result<AnalysisExecution, AnalysisError>
|
|
> => {
|
|
let reportedPath: string | null = null;
|
|
try {
|
|
const created = await lstat(stagingDirectory);
|
|
if (!created.isDirectory() || created.isSymbolicLink())
|
|
throw new Error(
|
|
`The created bugreport staging output is not a regular directory: ${stagingDirectory}`,
|
|
);
|
|
stagingIdentity = {
|
|
dev: created.dev,
|
|
ino: created.ino,
|
|
mode: created.mode,
|
|
};
|
|
const collected = await runAdb(
|
|
operation,
|
|
input.serial,
|
|
selection.binary,
|
|
adbArguments(input.serial, ["bugreport", stagingDirectory]),
|
|
{
|
|
timeoutMs: BUGREPORT_TIMEOUT_MS,
|
|
maxOutputBytes: BUGREPORT_MAX_OUTPUT_BYTES,
|
|
},
|
|
signal,
|
|
);
|
|
if (!collected.ok) return collected;
|
|
reportedPath = parseBugreportCopiedPath(collected.value.stdout);
|
|
if (reportedPath === null)
|
|
return err(
|
|
new AnalysisProtocolError(
|
|
"adb bugreport did not report an archive path; the build may use an unsupported format",
|
|
),
|
|
);
|
|
if (dirname(resolve(reportedPath)) !== stagingDirectory)
|
|
return err(
|
|
new AnalysisProtocolError(
|
|
`adb bugreport reported an archive outside its staging directory: ${reportedPath}`,
|
|
),
|
|
);
|
|
const archive = await lstat(reportedPath);
|
|
if (!archive.isFile() || archive.isSymbolicLink())
|
|
return err(
|
|
new AnalysisProtocolError(
|
|
`adb bugreport did not report a regular archive file: ${reportedPath}`,
|
|
),
|
|
);
|
|
retainedObservation = {
|
|
provider_id: "adb",
|
|
operation,
|
|
result: {
|
|
client,
|
|
serial: input.serial,
|
|
local_path: reportedPath,
|
|
adb_reported_path: reportedPath,
|
|
digest_status: "unknown",
|
|
},
|
|
};
|
|
try {
|
|
publishedPath = await publishBugreport(reportedPath, directory.value);
|
|
} catch (cause) {
|
|
return err(
|
|
new AnalysisOutputError(
|
|
operation,
|
|
`The reported bugreport archive could not be published: ${reportedPath}`,
|
|
{ cause, partialObservation: retainedObservation },
|
|
),
|
|
);
|
|
}
|
|
retainedObservation = {
|
|
...retainedObservation,
|
|
result: { ...retainedObservation.result, local_path: publishedPath },
|
|
};
|
|
const digest = await hashAdbFile(
|
|
publishedPath,
|
|
operation,
|
|
signal,
|
|
this.#resources,
|
|
);
|
|
const report = {
|
|
client,
|
|
serial: input.serial,
|
|
local_path: publishedPath,
|
|
bytes: digest.bytes,
|
|
sha256: digest.sha256,
|
|
adb_reported_path: reportedPath,
|
|
};
|
|
retainedObservation = {
|
|
provider_id: "adb",
|
|
operation,
|
|
result: report,
|
|
};
|
|
return ok(
|
|
createAnalysisExecution(report, provider, {
|
|
limitations: [
|
|
"The archive is device-generated: dumpstate, dumpsys, and logcat content is the device's own report",
|
|
],
|
|
}),
|
|
);
|
|
} catch (cause) {
|
|
const cleanupError =
|
|
retainedObservation === undefined
|
|
? undefined
|
|
: adbDigestError(cause, operation, retainedObservation);
|
|
if (cleanupError !== undefined) return err(cleanupError);
|
|
if (cause instanceof AnalysisCancelledError) {
|
|
if (retainedObservation !== undefined)
|
|
cause.retainPartialObservation(retainedObservation);
|
|
return err(cause);
|
|
}
|
|
return err(
|
|
new AnalysisOutputError(
|
|
operation,
|
|
`The reported bugreport archive could not be read back: ${reportedPath ?? stagingDirectory}`,
|
|
{ cause, partialObservation: retainedObservation },
|
|
),
|
|
);
|
|
}
|
|
})();
|
|
// A completed acquisition becomes caller evidence even if publication fails.
|
|
// Its staging directory must not enter automatic cleanup or cleanup retries.
|
|
if (retainedObservation !== undefined && publishedPath === undefined)
|
|
return collectedResult;
|
|
try {
|
|
await removeOwnedOutputDirectory(stagingDirectory, stagingIdentity);
|
|
} catch (cause) {
|
|
const primary = collectedResult.ok
|
|
? new AnalysisOutputError(
|
|
operation,
|
|
"The archive was published, but its staging directory could not be removed",
|
|
)
|
|
: collectedResult.error;
|
|
if (retainedObservation !== undefined)
|
|
primary.retainPartialObservation(retainedObservation);
|
|
return err(
|
|
this.#retainOutputDirectoryCleanup(
|
|
{
|
|
operation,
|
|
path: stagingDirectory,
|
|
primary,
|
|
remove: () =>
|
|
removeOwnedOutputDirectory(stagingDirectory, stagingIdentity),
|
|
},
|
|
new ProviderCleanupError(
|
|
ADB_PROVIDER_IDENTITY.id,
|
|
[stagingDirectory],
|
|
{ reason: cause instanceof Error ? cause.message : String(cause) },
|
|
{ operation, cause },
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return collectedResult;
|
|
}
|
|
}
|