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:
morluto committed 2026-10-12 13:06:51 +08:00
1 parent 3bcc68c037
commit 3bdc8730bd
10 files changed
+552 -180

No files matched your search

+4 -1
View File
@@ -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
+108
View File
@@ -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
View File
@@ -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
View File
@@ -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,
-59
View File
@@ -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;
+1 -1
View File
@@ -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),
+3 -1
View File
@@ -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(
+11 -1
View File
@@ -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, [
+19 -8
View File
@@ -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);
}
},
);