mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(adb): preserve directory identities and share byte capture
Use Android 8-compatible batched find/stat with NUL-framed names and raw symlink targets. Preserve malformed or invalid UTF-8 records as partial evidence with exact source bytes. List hidden direct children without synthetic navigation entries. Share process byte capture with UTF-8 callers and screenshots, removing duplicate timeout and cancellation supervision. Verified 35 focused process, ADB, CLI and MCP tests; compatibility checked against AOSP Oreo sources. Live Android device/emulator verification was not run.
This commit is contained in:
1 parent
3bcc68c037
commit
3bdc8730bd
10 files changed
+552
-180
No files matched your search
@@ -88,7 +88,7 @@ existing analysis families directly, for example
|
||||
| `list_adb_packages` | read | `pm list packages -f` with scope; base APK device paths |
|
||||
| `resolve_adb_packages` | read | Case-insensitive package-name substring search with exact-match flag |
|
||||
| `inspect_adb_package` | read | `dumpsys package` projection: versions, install times, installer, flags, permission count |
|
||||
| `list_adb_directory` | read | `ls -la`: kinds, permissions, owner, group, sizes, symlink targets |
|
||||
| `list_adb_directory` | read | NUL-framed immediate children, including hidden entries: kinds, permissions, owner, group, sizes, symlink targets |
|
||||
| `pull_adb_package` | read | Complete split APK set from `pm path`, each file digested |
|
||||
| `pull_adb_file` | read | One absolute device path pulled and digested |
|
||||
| `capture_adb_screen` | read | One `screencap -p` frame (exec-out, no device-side file) with dimensions |
|
||||
@@ -102,6 +102,9 @@ existing analysis families directly, for example
|
||||
|
||||
## Interpret the result
|
||||
|
||||
- **Directory names retain whitespace and newlines.** Symlink targets do too,
|
||||
including trailing newlines. Records containing invalid UTF-8 remain as
|
||||
labelled hex in `unparsed_lines` and make directory coverage partial.
|
||||
- **Inferences carry their basis.** Device `kind` reports
|
||||
`kind_basis` (serial prefix or USB transport); APK `role` reports
|
||||
`role_basis: file_name`; `emulator_observed` is the build's own
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { isUtf8 } from "node:buffer";
|
||||
|
||||
interface AdbDirectoryEntry {
|
||||
readonly name: string;
|
||||
readonly kind: "directory" | "file" | "symlink" | "other";
|
||||
readonly permissions: string | null;
|
||||
readonly owner: string | null;
|
||||
readonly group: string | null;
|
||||
readonly bytes: number | null;
|
||||
readonly date: string | null;
|
||||
readonly link_target: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List direct children with Android 8 Toybox's batched `find -exec` and
|
||||
* `stat -c` support. Names and link targets are NUL-framed; neither is ever
|
||||
* inserted into a line-oriented metadata record or command substitution.
|
||||
*/
|
||||
export const ADB_DIRECTORY_BATCH_SCRIPT = String.raw`metadata=$(stat -c '%A|%U|%G|%s|%y' "$@") || exit
|
||||
printf '%s\n' "$metadata" | {
|
||||
while IFS= read -r metadata; do
|
||||
[ "$#" -gt 0 ] || exit 1
|
||||
path=$1
|
||||
shift
|
||||
printf '%s\000%s\000' "$metadata" "${"${path##*/}"}"
|
||||
case "$metadata" in
|
||||
l*) readlink -n "$path" || exit 1 ;;
|
||||
esac
|
||||
printf '\000'
|
||||
done
|
||||
[ "$#" -eq 0 ]
|
||||
}`;
|
||||
|
||||
const METADATA =
|
||||
/^([bcdlps-][rwxstST-]{9})\|([^|\n]+)\|([^|\n]+)\|(\d+)\|([^|\n]+)$/u;
|
||||
|
||||
const parseEntry = (
|
||||
metadata: string,
|
||||
name: string,
|
||||
linkTarget: string,
|
||||
): AdbDirectoryEntry | null => {
|
||||
const match = METADATA.exec(metadata);
|
||||
if (match === null || name === "" || name.includes("/")) return null;
|
||||
const permissions = match[1]!;
|
||||
const bytes = Number(match[4]!);
|
||||
if (!Number.isSafeInteger(bytes)) return null;
|
||||
|
||||
const isSymlink = permissions.startsWith("l");
|
||||
if (isSymlink ? linkTarget === "" : linkTarget !== "") return null;
|
||||
|
||||
return {
|
||||
name,
|
||||
kind: permissions.startsWith("d")
|
||||
? "directory"
|
||||
: isSymlink
|
||||
? "symlink"
|
||||
: permissions.startsWith("-")
|
||||
? "file"
|
||||
: "other",
|
||||
permissions,
|
||||
owner: match[2]!,
|
||||
group: match[3]!,
|
||||
bytes,
|
||||
date: match[5]!,
|
||||
link_target: isSymlink ? linkTarget : null,
|
||||
};
|
||||
};
|
||||
|
||||
/** Parse metadata/name/target NUL triples from `ADB_DIRECTORY_BATCH_SCRIPT`. */
|
||||
export const parseAdbDirectoryListing = (
|
||||
stdout: Buffer,
|
||||
): {
|
||||
entries: readonly AdbDirectoryEntry[];
|
||||
unparsedLines: readonly string[];
|
||||
} => {
|
||||
const entries: AdbDirectoryEntry[] = [];
|
||||
const unparsedLines: string[] = [];
|
||||
let offset = 0;
|
||||
while (offset < stdout.length) {
|
||||
const metadataEnd = stdout.indexOf(0, offset);
|
||||
const nameEnd = metadataEnd < 0 ? -1 : stdout.indexOf(0, metadataEnd + 1);
|
||||
const targetEnd = nameEnd < 0 ? -1 : stdout.indexOf(0, nameEnd + 1);
|
||||
if (targetEnd < 0) {
|
||||
unparsedLines.push(describeUnparsedRecord(stdout.subarray(offset)));
|
||||
break;
|
||||
}
|
||||
const rawRecord = stdout.subarray(offset, targetEnd + 1);
|
||||
if (!isUtf8(rawRecord)) {
|
||||
unparsedLines.push(describeUnparsedRecord(rawRecord));
|
||||
} else {
|
||||
const entry = parseEntry(
|
||||
stdout.toString("utf8", offset, metadataEnd),
|
||||
stdout.toString("utf8", metadataEnd + 1, nameEnd),
|
||||
stdout.toString("utf8", nameEnd + 1, targetEnd),
|
||||
);
|
||||
if (entry === null) unparsedLines.push(rawRecord.toString("utf8"));
|
||||
else entries.push(entry);
|
||||
}
|
||||
offset = targetEnd + 1;
|
||||
}
|
||||
|
||||
return { entries, unparsedLines };
|
||||
};
|
||||
|
||||
const describeUnparsedRecord = (record: Buffer): string =>
|
||||
isUtf8(record)
|
||||
? record.toString("utf8")
|
||||
: `Invalid UTF-8 directory record (hex): ${record.toString("hex")}`;
|
||||
+69
-37
@@ -199,7 +199,7 @@ const shellStub = (
|
||||
second === undefined
|
||||
? `[ "$4" = "${first}" ]`
|
||||
: `[ "$4" = "${first}" ] && [ "$5" = "${second}" ]`;
|
||||
return ` if ${guard}; then printf '%b' '${output.replace(/'/gu, "'\\''")}'; exit 0; fi`;
|
||||
return ` if ${guard}; then printf '%b' '${output.replaceAll("\\", "\\\\").replaceAll("\0", "\\000").replace(/'/gu, "'\\''")}'; exit 0; fi`;
|
||||
})
|
||||
.join("\n");
|
||||
return () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
@@ -780,42 +780,74 @@ it("lists processes, features, and services through fixed commands", async () =>
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists a device directory with kinds and symlink targets", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
shellStub({
|
||||
ls: "total 4\n-rw-rw---- u0_a1 u0_a1 4096 2026-01-02 11:00 app.db\nlrwxrwxrwx root root 2026-01-01 10:00 lib -> /apex/com.android.art/lib\n",
|
||||
}),
|
||||
);
|
||||
const result = await evidenceResult(providerOver(binary), {
|
||||
operation: "list_adb_directory",
|
||||
input: {
|
||||
serial: "emulator-5554",
|
||||
device_path: "/data/data/com.example.app",
|
||||
},
|
||||
});
|
||||
expect(result.entries).toEqual([
|
||||
{
|
||||
name: "app.db",
|
||||
kind: "file",
|
||||
permissions: "-rw-rw----",
|
||||
owner: "u0_a1",
|
||||
group: "u0_a1",
|
||||
bytes: 4096,
|
||||
date: "2026-01-02 11:00",
|
||||
link_target: null,
|
||||
},
|
||||
{
|
||||
name: "lib",
|
||||
kind: "symlink",
|
||||
permissions: "lrwxrwxrwx",
|
||||
owner: "root",
|
||||
group: "root",
|
||||
bytes: null,
|
||||
date: "2026-01-01 10:00",
|
||||
link_target: "/apex/com.android.art/lib",
|
||||
},
|
||||
]);
|
||||
});
|
||||
it.each(["complete", "truncated"] as const)(
|
||||
"preserves framed directory identities with %s output",
|
||||
async (variant) => {
|
||||
const date = "2026-01-02 11:00:00.000000000 +0000";
|
||||
const names = [
|
||||
"app.db",
|
||||
" app.db ",
|
||||
" ",
|
||||
"one -> two -> three",
|
||||
"line\r",
|
||||
"line\u2028break",
|
||||
"foo\n-rw-rw---- root root 0 2026-01-01 10:00 ghost",
|
||||
"literal\\n",
|
||||
];
|
||||
const links = [
|
||||
["lib", "/apex/com.android.art/lib"],
|
||||
[" lib ", " /apex/lib "],
|
||||
["one -> two\n", "three -> four\n\n"],
|
||||
] as const;
|
||||
const truncated = `-rw-rw----|u0_a1|u0_a1|4096|${date}\0unfinished`;
|
||||
const output = [
|
||||
...names.map(
|
||||
(name) => `-rw-rw----|u0_a1|u0_a1|4096|${date}\0${name}\0\0`,
|
||||
),
|
||||
...links.map(
|
||||
([name, target]) =>
|
||||
`lrwxrwxrwx|root|root|${Buffer.byteLength(target)}|${date}\0${name}\0${target}\0`,
|
||||
),
|
||||
...(variant === "truncated" ? [truncated] : []),
|
||||
].join("");
|
||||
const { binary } = await writeAdbStub(shellStub({ sh: output }));
|
||||
const result = await evidenceResult(providerOver(binary), {
|
||||
operation: "list_adb_directory",
|
||||
input: {
|
||||
serial: "emulator-5554",
|
||||
device_path: "/data/data/com.example.app",
|
||||
},
|
||||
});
|
||||
expect(result.entries).toEqual([
|
||||
...names.map((name) => ({
|
||||
name,
|
||||
kind: "file",
|
||||
permissions: "-rw-rw----",
|
||||
owner: "u0_a1",
|
||||
group: "u0_a1",
|
||||
bytes: 4096,
|
||||
date,
|
||||
link_target: null,
|
||||
})),
|
||||
...links.map(([name, target]) => ({
|
||||
name,
|
||||
kind: "symlink",
|
||||
permissions: "lrwxrwxrwx",
|
||||
owner: "root",
|
||||
group: "root",
|
||||
bytes: Buffer.byteLength(target),
|
||||
date,
|
||||
link_target: target,
|
||||
})),
|
||||
]);
|
||||
expect(result.unparsed_lines).toEqual(
|
||||
variant === "truncated" ? [truncated] : [],
|
||||
);
|
||||
expect(result.coverage).toBe(
|
||||
variant === "truncated" ? "partial" : "complete",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("inspects the display and window focus", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
|
||||
+49
-72
@@ -1,4 +1,3 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import {
|
||||
@@ -38,7 +37,7 @@ import { err, ok, type Result } from "../domain/result.js";
|
||||
import { hashStableFile } from "../filesystem/StableFileHash.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import {
|
||||
execFileOutput,
|
||||
execFileBytes,
|
||||
execFileOutputFailure,
|
||||
} from "../process/ExecFileOutput.js";
|
||||
import {
|
||||
@@ -56,12 +55,15 @@ import {
|
||||
parsePmPathOutput,
|
||||
} from "./AdbDeviceOutput.js";
|
||||
import { adbHostFileName } from "./AdbHostFileName.js";
|
||||
import {
|
||||
ADB_DIRECTORY_BATCH_SCRIPT,
|
||||
parseAdbDirectoryListing,
|
||||
} from "./AdbDirectoryListing.js";
|
||||
import {
|
||||
parseAmStartOutput,
|
||||
parseBugreportCopiedPath,
|
||||
parseDumpsysPackageOutput,
|
||||
parseFeatureListOutput,
|
||||
parseLsOutput,
|
||||
parsePackageChangeOutput,
|
||||
parseProcessListOutput,
|
||||
parsePngDimensions,
|
||||
@@ -105,7 +107,6 @@ const BUGREPORT_TIMEOUT_MS = 600_000;
|
||||
const BUGREPORT_MAX_OUTPUT_BYTES = 1024 * 1024;
|
||||
const STDERR_EXCERPT_BYTES = 512;
|
||||
const UNPARSED_LINES_REPORTED = 10;
|
||||
const STOP_GRACE_MS = 5_000;
|
||||
|
||||
const hashAdbFile = async (
|
||||
path: string,
|
||||
@@ -144,19 +145,19 @@ interface AdbRunLimits {
|
||||
}
|
||||
|
||||
/** Run one bounded adb invocation and keep its exact failure shape. */
|
||||
const runAdb = async (
|
||||
const runAdbCapture = async (
|
||||
operation: string,
|
||||
targetPath: string,
|
||||
binary: string,
|
||||
arguments_: readonly string[],
|
||||
limits: AdbRunLimits,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<Result<{ stdout: string; stderr: string }, AnalysisError>> => {
|
||||
): Promise<Result<{ stdout: Buffer; stderr: Buffer }, AnalysisError>> => {
|
||||
if (signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
try {
|
||||
return ok(
|
||||
await execFileOutput(binary, arguments_, {
|
||||
await execFileBytes(binary, arguments_, {
|
||||
timeout: limits.timeoutMs,
|
||||
maxBuffer: limits.maxOutputBytes,
|
||||
stopSignal: "SIGTERM",
|
||||
@@ -223,61 +224,17 @@ const runAdb = async (
|
||||
}
|
||||
};
|
||||
|
||||
/** Capture one adb invocation's raw binary stdout (screencap PNG). */
|
||||
const runAdbBinary = (
|
||||
operation: string,
|
||||
binary: string,
|
||||
arguments_: readonly string[],
|
||||
limits: AdbRunLimits,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<Result<Buffer, AnalysisError>> =>
|
||||
new Promise((settle) => {
|
||||
if (signal?.aborted === true)
|
||||
return settle(err(new AnalysisCancelledError(operation)));
|
||||
let stopped: "abort" | "timeout" | undefined;
|
||||
let deadline: NodeJS.Timeout | undefined;
|
||||
let grace: NodeJS.Timeout | undefined;
|
||||
const child = execFile(
|
||||
binary,
|
||||
[...arguments_],
|
||||
{
|
||||
encoding: "buffer" as const,
|
||||
maxBuffer: limits.maxOutputBytes,
|
||||
killSignal: "SIGTERM",
|
||||
timeout: 0,
|
||||
},
|
||||
(error, stdout) => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
clearTimeout(deadline);
|
||||
clearTimeout(grace);
|
||||
if (stopped === "abort")
|
||||
return settle(err(new AnalysisCancelledError(operation)));
|
||||
if (stopped === "timeout")
|
||||
return settle(
|
||||
err(new AnalysisTimeoutError(operation, limits.timeoutMs)),
|
||||
);
|
||||
if (error !== null)
|
||||
return settle(
|
||||
err(
|
||||
new AnalysisProtocolError(
|
||||
`adb failed for ${operation}: ${error.message}`,
|
||||
{ cause: error },
|
||||
),
|
||||
),
|
||||
);
|
||||
settle(ok(stdout as Buffer));
|
||||
},
|
||||
);
|
||||
const stop = (reason: "abort" | "timeout"): void => {
|
||||
if (stopped !== undefined) return;
|
||||
stopped = reason;
|
||||
child.kill("SIGTERM");
|
||||
grace = setTimeout(() => child.kill("SIGKILL"), STOP_GRACE_MS);
|
||||
};
|
||||
const onAbort = (): void => stop("abort");
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
deadline = setTimeout(() => stop("timeout"), limits.timeoutMs);
|
||||
/** Decode textual adb protocols after the shared byte capture and error boundary. */
|
||||
const runAdb = async (
|
||||
...arguments_: Parameters<typeof runAdbCapture>
|
||||
): Promise<Result<{ stdout: string; stderr: string }, 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"),
|
||||
});
|
||||
};
|
||||
|
||||
const clientIdentityResult = (
|
||||
selection: AdbBinarySelection,
|
||||
@@ -947,12 +904,29 @@ export class AdbProvider {
|
||||
);
|
||||
}
|
||||
case "list_adb_directory": {
|
||||
const ran = await shell(["ls", "-la", request.input.device_path], {
|
||||
timeoutMs: LISTING_TIMEOUT_MS,
|
||||
maxOutputBytes: LISTING_MAX_OUTPUT_BYTES,
|
||||
});
|
||||
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 } = parseLsOutput(ran.value.stdout);
|
||||
const { entries, unparsedLines } = parseAdbDirectoryListing(
|
||||
ran.value.stdout,
|
||||
);
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
@@ -965,7 +939,7 @@ export class AdbProvider {
|
||||
provider,
|
||||
{
|
||||
limitations: [
|
||||
"Entry kinds derive from the permission string's type character",
|
||||
"Entry kinds derive from stat permission mode; directory contents and link targets may change during capture",
|
||||
...unparsedOverflowLimitation(unparsedLines),
|
||||
],
|
||||
},
|
||||
@@ -2046,15 +2020,16 @@ export class AdbProvider {
|
||||
const localPath = join(directory.value, "screen.png");
|
||||
const existing = await refuseExistingTarget(operation, localPath);
|
||||
if (existing !== undefined) return err(existing);
|
||||
const captured = await runAdbBinary(
|
||||
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;
|
||||
const dimensions = parsePngDimensions(captured.value);
|
||||
const dimensions = parsePngDimensions(captured.value.stdout);
|
||||
if (dimensions.width === null)
|
||||
return err(
|
||||
new AnalysisProtocolError(
|
||||
@@ -2062,7 +2037,7 @@ export class AdbProvider {
|
||||
),
|
||||
);
|
||||
try {
|
||||
await writeFile(localPath, captured.value, { flag: "wx" });
|
||||
await writeFile(localPath, captured.value.stdout, { flag: "wx" });
|
||||
} catch (cause) {
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
@@ -2072,14 +2047,16 @@ export class AdbProvider {
|
||||
),
|
||||
);
|
||||
}
|
||||
const sha256 = createHash("sha256").update(captured.value).digest("hex");
|
||||
const sha256 = createHash("sha256")
|
||||
.update(captured.value.stdout)
|
||||
.digest("hex");
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
client,
|
||||
serial: input.serial,
|
||||
local_path: localPath,
|
||||
bytes: captured.value.byteLength,
|
||||
bytes: captured.value.stdout.byteLength,
|
||||
sha256,
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
|
||||
@@ -53,65 +53,6 @@ export const parseProcessListOutput = (
|
||||
return { columns, processes, unparsedLines };
|
||||
};
|
||||
|
||||
export interface AdbDirectoryEntry {
|
||||
readonly name: string;
|
||||
readonly kind: "directory" | "file" | "symlink" | "other";
|
||||
readonly permissions: string | null;
|
||||
readonly owner: string | null;
|
||||
readonly group: string | null;
|
||||
readonly bytes: number | null;
|
||||
readonly date: string | null;
|
||||
readonly link_target: string | null;
|
||||
}
|
||||
|
||||
const LS_LINE =
|
||||
/^([bcdlps-][rwxstST-]{9})\s+(?:(\d+)\s+)?(\S+)\s+(\S+)\s+(?:(\d+)\s+)?([\d-]{10})\s+([\d:]{4,8})\s+(.+)$/u;
|
||||
|
||||
/** Parse `ls -la` output, including `total` headers and symlink targets. */
|
||||
export const parseLsOutput = (
|
||||
stdout: string,
|
||||
): {
|
||||
entries: readonly AdbDirectoryEntry[];
|
||||
unparsedLines: readonly string[];
|
||||
} => {
|
||||
const entries: AdbDirectoryEntry[] = [];
|
||||
const unparsedLines: string[] = [];
|
||||
for (const rawLine of stdout.split(/\r?\n/u)) {
|
||||
const line = rawLine.trimEnd();
|
||||
if (line === "" || line.startsWith("total ")) continue;
|
||||
const match = LS_LINE.exec(line);
|
||||
if (match === null) {
|
||||
if (line.trim() !== "") unparsedLines.push(line);
|
||||
continue;
|
||||
}
|
||||
const permissions = match[1]!;
|
||||
let name = match[8]!.trim();
|
||||
let linkTarget: string | null = null;
|
||||
const linkArrow = name.indexOf(" -> ");
|
||||
if (linkArrow !== -1 && permissions.startsWith("l")) {
|
||||
linkTarget = name.slice(linkArrow + 4);
|
||||
name = name.slice(0, linkArrow);
|
||||
}
|
||||
entries.push({
|
||||
name,
|
||||
kind: permissions.startsWith("d")
|
||||
? "directory"
|
||||
: permissions.startsWith("l")
|
||||
? "symlink"
|
||||
: permissions.startsWith("-")
|
||||
? "file"
|
||||
: "other",
|
||||
permissions,
|
||||
owner: match[3] ?? null,
|
||||
group: match[4] ?? null,
|
||||
bytes: match[5] === undefined ? null : Number(match[5]),
|
||||
date: `${match[6] ?? ""} ${match[7] ?? ""}`.trim() || null,
|
||||
link_target: linkTarget,
|
||||
});
|
||||
}
|
||||
return { entries, unparsedLines };
|
||||
};
|
||||
|
||||
export interface AdbFeatureEntry {
|
||||
readonly name: string;
|
||||
readonly version: number | null;
|
||||
|
||||
@@ -174,7 +174,7 @@ export const ADB_TOOL_CONTRACTS = [
|
||||
name: "list_adb_directory",
|
||||
...toolContractMetadata("list_adb_directory"),
|
||||
description:
|
||||
"List one absolute device directory (`ls -la`): names, kinds, permissions, owner, group, sizes, dates, and symlink targets. Recognizes the standard errors for absent paths and denied reads. Read-only.",
|
||||
"List the immediate children of one absolute device directory, including hidden entries: names, kinds, permissions, owner, group, sizes, dates, and symlink targets. Names and targets preserve embedded newlines. Recognizes the standard errors for absent paths and denied reads. Read-only.",
|
||||
kind: "adb-provider",
|
||||
inputSchema: adbInputSchemas.list_adb_directory,
|
||||
outputSchema: evidenceResultOf(adbResultSchemas.list_adb_directory),
|
||||
|
||||
@@ -149,7 +149,9 @@ export const adbInputSchemas = {
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^\/[^\x00]*$/u, "Device path must be absolute")
|
||||
.describe("Absolute device directory path to list (ls -la)"),
|
||||
.describe(
|
||||
"Absolute device directory path to list, including hidden children",
|
||||
),
|
||||
}),
|
||||
list_adb_features: z.strictObject({
|
||||
serial: deviceSerialSchema.describe(
|
||||
|
||||
@@ -5,9 +5,19 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
||||
|
||||
import { execFileOutput } from "./ExecFileOutput.js";
|
||||
import { execFileBytes, execFileOutput } from "./ExecFileOutput.js";
|
||||
|
||||
describe("execFileOutput", () => {
|
||||
it("preserves captured bytes for callers that own the output format", async () => {
|
||||
const result = await execFileBytes(process.execPath, [
|
||||
"-e",
|
||||
"process.stdout.write(Buffer.from([0x00, 0xff, 0x41])); process.stderr.write(Buffer.from([0xfe]));",
|
||||
]);
|
||||
|
||||
expect(result.stdout).toEqual(Buffer.from([0x00, 0xff, 0x41]));
|
||||
expect(result.stderr).toEqual(Buffer.from([0xfe]));
|
||||
});
|
||||
|
||||
it("captures output larger than Node's default execFile limit", async () => {
|
||||
const marker = "tail-marker";
|
||||
const result = await execFileOutput(process.execPath, [
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {
|
||||
execFile,
|
||||
type ExecFileOptionsWithStringEncoding,
|
||||
type ExecFileOptionsWithBufferEncoding,
|
||||
} from "node:child_process";
|
||||
|
||||
type ExecFileOutputOptions = Omit<
|
||||
ExecFileOptionsWithStringEncoding,
|
||||
ExecFileOptionsWithBufferEncoding,
|
||||
"encoding" | "maxBuffer"
|
||||
> & {
|
||||
readonly maxBuffer?: number;
|
||||
@@ -100,12 +100,12 @@ export const execFileOutputFailure = (
|
||||
};
|
||||
};
|
||||
|
||||
/** Run a shell-free command while capturing its complete UTF-8 output. */
|
||||
export const execFileOutput = (
|
||||
/** Run a shell-free command while capturing its complete output bytes. */
|
||||
export const execFileBytes = (
|
||||
command: string,
|
||||
arguments_: readonly string[],
|
||||
options: ExecFileOutputOptions = {},
|
||||
): Promise<{ readonly stdout: string; readonly stderr: string }> =>
|
||||
): Promise<{ readonly stdout: Buffer; readonly stderr: Buffer }> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const { stopSignal, signal, timeout, ...execOptions } = options;
|
||||
const invalid = timeout === undefined ? undefined : invalidTimeout(timeout);
|
||||
@@ -134,7 +134,7 @@ export const execFileOutput = (
|
||||
...(timeout === undefined ? {} : { timeout }),
|
||||
}
|
||||
: { killSignal: stopSignal }),
|
||||
encoding: "utf8",
|
||||
encoding: "buffer",
|
||||
maxBuffer: options.maxBuffer ?? Number.POSITIVE_INFINITY,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
@@ -148,8 +148,8 @@ export const execFileOutput = (
|
||||
? timeoutError(command, timeout, error)
|
||||
: error;
|
||||
if (failure !== null) {
|
||||
Reflect.set(failure, "stdout", stdout);
|
||||
Reflect.set(failure, "stderr", stderr);
|
||||
Reflect.set(failure, "stdout", stdout.toString("utf8"));
|
||||
Reflect.set(failure, "stderr", stderr.toString("utf8"));
|
||||
reject(failure);
|
||||
return;
|
||||
}
|
||||
@@ -173,3 +173,14 @@ export const execFileOutput = (
|
||||
stop("timeout");
|
||||
}, timeout);
|
||||
});
|
||||
|
||||
/** Run a shell-free command while capturing its complete UTF-8 output. */
|
||||
export const execFileOutput = (
|
||||
command: string,
|
||||
arguments_: readonly string[],
|
||||
options: ExecFileOutputOptions = {},
|
||||
): Promise<{ readonly stdout: string; readonly stderr: string }> =>
|
||||
execFileBytes(command, arguments_, options).then(({ stdout, stderr }) => ({
|
||||
stdout: stdout.toString("utf8"),
|
||||
stderr: stderr.toString("utf8"),
|
||||
}));
|
||||
@@ -0,0 +1,288 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { chmod, mkdir, readFile, symlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { expect, it } from "vitest";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { AdbProvider } from "../../../src/adb/AdbProvider.js";
|
||||
import { adbResultSchemas } from "../../../src/domain/adb/adbDeviceAnalysis.js";
|
||||
import {
|
||||
ADB_DIRECTORY_BATCH_SCRIPT,
|
||||
parseAdbDirectoryListing,
|
||||
} from "../../../src/adb/AdbDirectoryListing.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const itPosix = it.skipIf(process.platform === "win32");
|
||||
|
||||
// AOSP Toybox stat's documented %A/%U/%G/%s/%y conversions are fixed-format
|
||||
// metadata fields. This shim models those fields from host lstat data; it does
|
||||
// not claim to exercise an Android device's Toybox binary.
|
||||
const statShim = String.raw`#!${process.execPath}
|
||||
const fs = require('node:fs');
|
||||
const args = process.argv.slice(2);
|
||||
fs.appendFileSync(process.env.STAT_CALLS, args.slice(2).length + '\n');
|
||||
const fail = process.env.STAT_FAIL_PATH;
|
||||
for (const path of args.slice(2)) {
|
||||
if (path === fail) {
|
||||
process.stdout.write('-rw-r--r--|1000|1000|1|2026-10-12 00:00:00\n');
|
||||
process.exit(1);
|
||||
}
|
||||
const stat = fs.lstatSync(path);
|
||||
const kind = stat.isSymbolicLink() ? 'l' : stat.isDirectory() ? 'd' : '-';
|
||||
const permissions = kind + 'rw-r--r--';
|
||||
process.stdout.write(permissions + '|1000|1000|' + stat.size + '|2026-10-12 00:00:00\n');
|
||||
}`;
|
||||
|
||||
const readlinkShim = String.raw`#!${process.execPath}
|
||||
const fs = require('node:fs');
|
||||
fs.appendFileSync(process.env.READLINK_CALLS, 'called\n');
|
||||
process.stdout.write(fs.readlinkSync(process.argv[3]));
|
||||
`;
|
||||
|
||||
const makeTool = async (directory: string, name: string, contents: string) => {
|
||||
const path = join(directory, name);
|
||||
await writeFile(path, contents);
|
||||
await chmod(path, 0o755);
|
||||
return path;
|
||||
};
|
||||
|
||||
const shellQuote = (value: string): string =>
|
||||
"'" + value.replaceAll("'", "'\\''") + "'";
|
||||
|
||||
const makeFakeAdb = async (
|
||||
directory: string,
|
||||
toolDirectory: string,
|
||||
calls: string,
|
||||
linkCalls: string,
|
||||
) => {
|
||||
const path = join(directory, "fakeadb");
|
||||
await writeFile(
|
||||
path,
|
||||
`#!/bin/sh
|
||||
if [ "$1" = version ]; then
|
||||
printf 'Android Debug Bridge version 1.0.41\\nVersion 34.0.5-stub\\nInstalled as %s\\n' "$0"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = -s ]; then shift 2; fi
|
||||
[ "$1" = shell ] || exit 90
|
||||
shift
|
||||
export PATH=${shellQuote(`${toolDirectory}:` + (process.env.PATH ?? ""))}
|
||||
export STAT_CALLS=${shellQuote(calls)}
|
||||
export READLINK_CALLS=${shellQuote(linkCalls)}
|
||||
exec /bin/sh -c "$*"
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
await chmod(path, 0o755);
|
||||
return path;
|
||||
};
|
||||
|
||||
const makeRawOutputFakeAdb = async (
|
||||
directory: string,
|
||||
rawOutput: Buffer,
|
||||
): Promise<string> => {
|
||||
const path = join(directory, "fakeadb-raw-output");
|
||||
const encoded = rawOutput.toString("base64");
|
||||
await writeFile(
|
||||
path,
|
||||
`#!/bin/sh
|
||||
if [ "$1" = version ]; then
|
||||
printf 'Android Debug Bridge version 1.0.41\\nVersion 34.0.5-stub\\nInstalled as %s\\n' "$0"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = -s ]; then shift 2; fi
|
||||
[ "$1" = shell ] || exit 90
|
||||
exec ${shellQuote(process.execPath)} -e 'process.stdout.write(Buffer.from("${encoded}", "base64"))'
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
await chmod(path, 0o755);
|
||||
return path;
|
||||
};
|
||||
|
||||
const runScript = async (
|
||||
script: string,
|
||||
paths: readonly string[],
|
||||
environment: NodeJS.ProcessEnv,
|
||||
) => {
|
||||
const { stdout } = await execFileAsync(
|
||||
"/bin/sh",
|
||||
["-c", script, "rea-list-directory", ...paths],
|
||||
{ encoding: "buffer", env: environment, maxBuffer: 64 * 1024 },
|
||||
);
|
||||
return stdout;
|
||||
};
|
||||
|
||||
itPosix(
|
||||
"lists adversarial names through the complete adb provider command",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-adb-directory-listing-");
|
||||
const bin = join(root, "bin");
|
||||
const deviceDirectory = join(root, "device dir ' $");
|
||||
await mkdir(bin);
|
||||
await mkdir(deviceDirectory);
|
||||
const calls = join(root, "stat-calls");
|
||||
const linkCalls = join(root, "readlink-calls");
|
||||
await writeFile(calls, "");
|
||||
await writeFile(linkCalls, "");
|
||||
await makeTool(bin, "stat", statShim);
|
||||
await makeTool(bin, "readlink", readlinkShim);
|
||||
|
||||
const names = [
|
||||
"line\nbreak",
|
||||
"arrow -> tick` quote' space",
|
||||
"carriage\r雪",
|
||||
"tail\n",
|
||||
".hidden",
|
||||
];
|
||||
const paths: string[] = [];
|
||||
for (const name of names) {
|
||||
const path = join(deviceDirectory, name);
|
||||
await writeFile(path, "x");
|
||||
paths.push(path);
|
||||
}
|
||||
const nested = join(deviceDirectory, "nested");
|
||||
await mkdir(nested);
|
||||
await writeFile(join(nested, "ghost"), "not a direct child");
|
||||
paths.push(nested);
|
||||
const target = "target -> `quoted'\n雪\n";
|
||||
const link = join(deviceDirectory, "link\nname");
|
||||
await symlink(target, link);
|
||||
paths.push(link);
|
||||
const binary = await makeFakeAdb(root, bin, calls, linkCalls);
|
||||
|
||||
const execution = await new AdbProvider({
|
||||
environment: { REA_ADB_PATH: binary },
|
||||
}).execute({
|
||||
operation: "list_adb_directory",
|
||||
input: { serial: "test-device", device_path: deviceDirectory },
|
||||
});
|
||||
if (!execution.ok) throw execution.error;
|
||||
const result = adbResultSchemas.list_adb_directory.parse(
|
||||
execution.value.result,
|
||||
);
|
||||
expect(result.device_path).toBe(deviceDirectory);
|
||||
expect(result.unparsed_lines).toEqual([]);
|
||||
expect(result.coverage).toBe("complete");
|
||||
expect(result.entries.map((entry) => entry.name).sort()).toEqual(
|
||||
[...names, "nested", "link\nname"].sort(),
|
||||
);
|
||||
expect(
|
||||
result.entries.find((entry) => entry.name === "link\nname"),
|
||||
).toMatchObject({
|
||||
kind: "symlink",
|
||||
bytes: expect.any(Number),
|
||||
link_target: target,
|
||||
});
|
||||
expect(
|
||||
result.entries.filter((entry) => entry.kind !== "symlink"),
|
||||
).toHaveLength(names.length + 1);
|
||||
expect(
|
||||
result.entries.some((entry) => entry.name === "." || entry.name === ".."),
|
||||
).toBe(false);
|
||||
expect((await readFile(calls, "utf8")).trim().split("\n")).toEqual([
|
||||
String(paths.length),
|
||||
]);
|
||||
expect((await readFile(linkCalls, "utf8")).trim().split("\n")).toEqual([
|
||||
"called",
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
itPosix(
|
||||
"discards a failed stat batch instead of pairing shortened rows with later names",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-adb-directory-stat-failure-",
|
||||
);
|
||||
const bin = join(root, "bin");
|
||||
await mkdir(bin);
|
||||
const calls = join(root, "stat-calls");
|
||||
const linkCalls = join(root, "readlink-calls");
|
||||
await writeFile(calls, "");
|
||||
await writeFile(linkCalls, "");
|
||||
await makeTool(bin, "stat", statShim);
|
||||
await makeTool(bin, "readlink", readlinkShim);
|
||||
const first = join(root, "first");
|
||||
const omitted = join(root, "omitted");
|
||||
const last = join(root, "last");
|
||||
await Promise.all(
|
||||
[first, omitted, last].map((path) => writeFile(path, "x")),
|
||||
);
|
||||
|
||||
const output = await runScript(
|
||||
ADB_DIRECTORY_BATCH_SCRIPT,
|
||||
[first, omitted, last],
|
||||
{
|
||||
...process.env,
|
||||
PATH: `${bin}:${process.env.PATH ?? ""}`,
|
||||
STAT_CALLS: calls,
|
||||
READLINK_CALLS: linkCalls,
|
||||
STAT_FAIL_PATH: omitted,
|
||||
},
|
||||
).catch((cause: unknown) => {
|
||||
if (
|
||||
cause instanceof Error &&
|
||||
"stdout" in cause &&
|
||||
Buffer.isBuffer(cause.stdout)
|
||||
)
|
||||
return cause.stdout;
|
||||
throw cause;
|
||||
});
|
||||
expect(parseAdbDirectoryListing(output)).toEqual({
|
||||
entries: [],
|
||||
unparsedLines: [],
|
||||
});
|
||||
expect((await readFile(calls, "utf8")).trim()).toBe("3");
|
||||
},
|
||||
);
|
||||
|
||||
itPosix(
|
||||
"preserves distinct invalid UTF-8 filenames as partial evidence",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-adb-directory-invalid-utf8-",
|
||||
);
|
||||
const deviceDirectory = join(root, "device");
|
||||
await mkdir(deviceDirectory);
|
||||
const invalidNames = [Buffer.from([0xff]), Buffer.from([0xfe])];
|
||||
|
||||
// macOS rejects raw invalid-UTF-8 path bytes with EILSEQ, so fake adb
|
||||
// returns the exact producer bytes while U+FFFD remains a valid record.
|
||||
const metadata = Buffer.from(
|
||||
"-rw-r--r--|1000|1000|1|2026-10-12 00:00:00\0",
|
||||
);
|
||||
const records = [
|
||||
...invalidNames.map((name) =>
|
||||
Buffer.concat([metadata, name, Buffer.from("\0\0")]),
|
||||
),
|
||||
Buffer.concat([metadata, Buffer.from("\ufffd"), Buffer.from("\0\0")]),
|
||||
];
|
||||
const rawOutput = Buffer.concat(records);
|
||||
const binary = await makeRawOutputFakeAdb(root, rawOutput);
|
||||
const execution = await new AdbProvider({
|
||||
environment: { REA_ADB_PATH: binary },
|
||||
}).execute({
|
||||
operation: "list_adb_directory",
|
||||
input: { serial: "test-device", device_path: deviceDirectory },
|
||||
});
|
||||
if (!execution.ok) throw execution.error;
|
||||
const result = adbResultSchemas.list_adb_directory.parse(
|
||||
execution.value.result,
|
||||
);
|
||||
|
||||
expect(result.entries.map((entry) => entry.name)).toEqual(["\ufffd"]);
|
||||
expect(result.coverage).toBe("partial");
|
||||
expect(result.unparsed_lines).toHaveLength(2);
|
||||
for (const invalidName of invalidNames) {
|
||||
const malformedRecord = Buffer.concat([
|
||||
metadata,
|
||||
invalidName,
|
||||
Buffer.from("\0\0"),
|
||||
]).toString("hex");
|
||||
expect(
|
||||
result.unparsed_lines.some((line) => line.includes(malformedRecord)),
|
||||
).toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user