Files
reverse-engineering-anything/src/adb/AdbCommand.ts
T
2026-10-23 17:43:07 +08:00

369 lines
12 KiB
TypeScript

import {
AnalysisCancelledError,
AnalysisProtocolError,
AnalysisResourceConstraintError,
AnalysisTimeoutError,
AnalysisUnsupportedTargetError,
} from "../domain/analysisErrorCore.js";
import type {
AnalysisCapturedOutput,
AnalysisError,
} from "../domain/analysisErrorBase.js";
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
import { err, ok, type Result } from "../domain/result.js";
import {
execFileBytes,
ExecFileOutputCancelledError,
execFileOutputFailure,
type ExecFileOutputFailure,
} from "../process/ExecFileOutput.js";
import { packageChangeFailureReason } from "./AdbShellOutput.js";
import { isSuccessfulDaemonStartNotice } from "./AdbDeviceOutput.js";
const STDERR_EXCERPT_CHARACTERS = 512;
// Match the known host operand in adb's local-file diagnostics. The suffix is
// part of the format: opening/reading identify local failures explicitly.
const LOCAL_FILE_DIAGNOSTICS = [
["cannot create", ":"],
["cannot create file/directory", ":"],
["cannot stat", ":"],
["cannot open", ":"],
["cannot write", ":"],
["failed to stat local file", ":"],
["failed to read all of", ":"],
["failed to access", ":"],
["failed to stat", ":"],
["failed to open", ":"],
["opening", " locally failed:"],
["reading", " locally failed:"],
] as const;
/** Project parsed command-owner facts without reinterpreting the rejection. */
export const adbCommandCapturedOutput = (
failure: ExecFileOutputFailure,
): AnalysisCapturedOutput => ({
stdout: failure.stdout,
stderr: failure.stderr,
...(failure.stdoutBytes === undefined
? {}
: { stdout_bytes: failure.stdoutBytes }),
...(failure.stderrBytes === undefined
? {}
: { stderr_bytes: failure.stderrBytes }),
exit_code: typeof failure.code === "number" ? failure.code : null,
signal: failure.signal,
truncated: failure.outputTruncated,
});
/** Parsed command failure owned by this adapter, separate from its public tag. */
class AdbProtocolError extends AnalysisProtocolError {
constructor(
message: string,
readonly commandFailure: ExecFileOutputFailure,
cause: unknown,
) {
super(message, {
cause,
capturedOutput: adbCommandCapturedOutput(commandFailure),
});
}
}
/** Return the retained process result only for command failures owned here. */
export const adbCommandOutputFailure = (
error: AnalysisError,
): ExecFileOutputFailure | undefined =>
error instanceof AdbProtocolError ? error.commandFailure : undefined;
/** adb joins shell operands into a remote POSIX command; quote at that boundary. */
const quoteShellOperand = (value: string): string =>
/^[A-Za-z0-9_./:=@+,-]+$/u.test(value)
? value
: "'" + value.replaceAll("'", "'\\''") + "'";
export const adbArguments = (
serial: string | undefined,
arguments_: readonly string[],
): readonly string[] => {
const operands =
arguments_[0] === "shell"
? ["shell", ...arguments_.slice(1).map(quoteShellOperand)]
: [...arguments_];
return serial === undefined ? operands : ["-s", serial, ...operands];
};
export interface AdbRunLimits {
readonly timeoutMs: number;
readonly maxOutputBytes: number;
/**
* Live device observations can shrink between calls. Mutation commands omit
* this so a truncated result stays non-retryable.
*/
readonly volatileOutput?: boolean;
}
/** Keep the selected device separate from the file addressed by the command. */
export interface AdbCommandTarget {
readonly device: string;
/** Caller-addressed file, such as a remote path or the original APK input. */
readonly path?: string;
/** Exact host operand adb consumes, including an owned input snapshot. */
readonly localPath?: string;
}
/** Run one bounded adb invocation and keep its exact failure shape. */
export const runAdbCapture = async (
operation: string,
target: AdbCommandTarget,
binary: string,
arguments_: readonly string[],
limits: AdbRunLimits,
signal: AbortSignal | undefined,
): Promise<Result<{ stdout: Buffer; stderr: Buffer }, AnalysisError>> => {
if (signal?.aborted === true)
return err(new AnalysisCancelledError(operation, { cause: signal.reason }));
try {
return ok(
await execFileBytes(binary, arguments_, {
timeout: limits.timeoutMs,
maxBuffer: limits.maxOutputBytes,
stopSignal: "SIGTERM",
...(signal === undefined ? {} : { signal }),
}),
);
} catch (cause) {
const failure = execFileOutputFailure(cause);
if (cause instanceof ExecFileOutputCancelledError)
return err(
new AnalysisCancelledError(operation, {
cause: cause.cause,
...(failure === undefined
? {}
: { capturedOutput: adbCommandCapturedOutput(failure) }),
}),
);
if (failure === undefined)
return err(
new AnalysisProtocolError(`adb failed for ${operation}`, { cause }),
);
if (failure.outputTruncated) {
const name = `The adb output capture allowance of ${String(limits.maxOutputBytes)} bytes (this release has no runtime override)`;
const allowance =
limits.volatileOutput === true
? {
fixedAllowance: { name, kind: "live" as const },
retryable: true as const,
}
: fixedAllowanceOptions(name, "capture");
return err(
new AnalysisResourceConstraintError(
operation,
"memory",
`adb output exceeded the ${String(limits.maxOutputBytes)}-byte capture budget`,
{ max_output_bytes: limits.maxOutputBytes },
{ cause, ...allowance },
),
);
}
const stderr = failure.stderr.trim();
const excerpt = stderr.slice(0, STDERR_EXCERPT_CHARACTERS);
// Device-state words inside a reported file name are not device verdicts.
// Only leading notices need line parsing. Do not expand a capture-sized
// stderr into an array whose entries can exceed the byte capture budget.
let diagnosticStart = 0;
while (diagnosticStart < stderr.length) {
const newline = stderr.indexOf("\n", diagnosticStart);
const line = stderr
.slice(diagnosticStart, newline === -1 ? stderr.length : newline)
.trimEnd();
if (
!isSuccessfulDaemonStartNotice(line) &&
!/^\* daemon not running(?:; starting now at [^\r\n]*|\. starting it now on port \d+ \*)$/u.test(
line,
)
)
break;
diagnosticStart = newline === -1 ? stderr.length : newline + 1;
}
const diagnostic = stderr
.slice(diagnosticStart)
.replace(
/^(?:adb:\s*)?(?:error:\s*)?(?:(?:failed to get feature set|connect failed):\s*)?/iu,
"",
);
if (
/^(?:unauthorized\b|device(?: '[^'\n]*')? (?:unauthorized\b|offline\b|not found\b|still authorizing\b)|still authorizing\b|more than one device\b)/iu.test(
diagnostic,
)
)
return err(
new AnalysisUnsupportedTargetError(operation, target.device, excerpt, {
cause,
}),
);
if (
/No such file or directory|Permission denied|read: Permission denied/iu.test(
diagnostic,
)
) {
const localPath = target.localPath;
const path =
localPath !== undefined &&
LOCAL_FILE_DIAGNOSTICS.some(([verb, suffix]) =>
[localPath, `'${localPath}'`, `"${localPath}"`].some((operand) =>
diagnostic.startsWith(`${verb} ${operand}${suffix}`),
),
)
? localPath
: (target.path ?? target.device);
return err(
new AnalysisUnsupportedTargetError(operation, path, excerpt, { cause }),
);
}
if (failure.killed)
return err(
new AnalysisTimeoutError(operation, limits.timeoutMs, {
cause,
userMessage: `adb ${operation} reached its ${String(limits.timeoutMs)} ms deadline (deadlineExpired). This release has no runtime override.`,
remediationAction: `This ${String(limits.timeoutMs)} ms adb deadline has no runtime override. Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`,
}),
);
return err(
new AdbProtocolError(
excerpt === ""
? `adb exited with code ${String(failure.code)} for ${operation}`
: excerpt,
failure,
cause,
),
);
}
};
/** Decode textual adb protocols after the shared byte capture and error boundary. */
export const runAdb = async (
...arguments_: Parameters<typeof runAdbCapture>
): Promise<
Result<{ stdout: string; stderr: string; stdoutBytes: number }, AnalysisError>
> => {
const captured = await runAdbCapture(...arguments_);
if (!captured.ok) return captured;
return ok({
stdout: captured.value.stdout.toString("utf8"),
stderr: captured.value.stderr.toString("utf8"),
// Byte count of the captured output before UTF-8 decoding, so malformed
// sequences replaced by U+FFFD still report their true captured size.
stdoutBytes: captured.value.stdout.byteLength,
});
};
/**
* adb exits nonzero when the device refuses a package change, printing the
* `Failure [...]` verdict to stderr (install) or stdout (uninstall). Read the
* refusal from that failed run so it is not reported as unreadable output.
*/
export const packageChangeRefusal = (
operation: string,
serial: string,
error: AnalysisError,
): Result<never, AnalysisError> | undefined => {
if (!(error instanceof AdbProtocolError)) return undefined;
const failure = error.commandFailure;
const reason = packageChangeFailureReason(
`${failure.stdout}\n${failure.stderr}`,
);
return reason === null
? undefined
: err(
new AnalysisUnsupportedTargetError(operation, serial, reason, {
cause: error,
}),
);
};
/** Only the clean false result of test -e establishes absence. */
export const probeAdbPathExistence = async (
operation: string,
serial: string,
path: string,
binary: string,
limits: AdbRunLimits,
signal: AbortSignal | undefined,
): Promise<Result<boolean, AnalysisError>> => {
return probeAdbPath(operation, serial, path, binary, limits, signal, "-e");
};
/** Only the clean true/false result of test -d establishes directory status. */
export const probeAdbPathDirectory = async (
operation: string,
serial: string,
path: string,
binary: string,
limits: AdbRunLimits,
signal: AbortSignal | undefined,
): Promise<Result<boolean, AnalysisError>> =>
probeAdbPath(operation, serial, path, binary, limits, signal, "-d");
const probeAdbPath = async (
operation: string,
serial: string,
path: string,
binary: string,
limits: AdbRunLimits,
signal: AbortSignal | undefined,
predicate: "-d" | "-e",
): Promise<Result<boolean, AnalysisError>> => {
const fact =
predicate === "-d" ? "destination_directory" : "destination_existence";
const probed = await runAdbCapture(
operation,
{ device: serial, path },
binary,
adbArguments(serial, ["shell", "test", predicate, path]),
{ ...limits, volatileOutput: true },
signal,
);
if (probed.ok) {
if (probed.value.stdout.length === 0 && probed.value.stderr.length === 0)
return ok(true);
return err(
new AnalysisProtocolError(
`The ADB path ${predicate === "-d" ? "directory" : "existence"} probe returned unexpected output; destination status is unknown`,
{
diagnostics: { [fact]: "unknown" },
capturedOutput: {
stdout: probed.value.stdout.toString("utf8"),
stderr: probed.value.stderr.toString("utf8"),
stdout_bytes: probed.value.stdout.length,
stderr_bytes: probed.value.stderr.length,
exit_code: 0,
signal: null,
truncated: false,
},
},
),
);
}
const error = probed.error;
if (!(error instanceof AdbProtocolError)) return probed;
const failure = error.commandFailure;
if (
failure.code === 1 &&
failure.signal === null &&
!failure.killed &&
!failure.outputTruncated &&
failure.collectionFailure !== true &&
failure.stdout === "" &&
failure.stderr === ""
)
return ok(false);
return err(
new AnalysisProtocolError(error.message, {
cause: error.cause,
diagnostics: { [fact]: "unknown" },
capturedOutput: adbCommandCapturedOutput(failure),
}),
);
};