diff --git a/docs/adb-device-analysis.md b/docs/adb-device-analysis.md index 416e78161f..fb2ce9aac7 100644 --- a/docs/adb-device-analysis.md +++ b/docs/adb-device-analysis.md @@ -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 diff --git a/src/adb/AdbDirectoryListing.ts b/src/adb/AdbDirectoryListing.ts new file mode 100644 index 0000000000..a8c055ad6f --- /dev/null +++ b/src/adb/AdbDirectoryListing.ts @@ -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")}`; diff --git a/src/adb/AdbProvider.test.ts b/src/adb/AdbProvider.test.ts index 9ea0f026b4..f9fb6f5201 100644 --- a/src/adb/AdbProvider.test.ts +++ b/src/adb/AdbProvider.test.ts @@ -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( diff --git a/src/adb/AdbProvider.ts b/src/adb/AdbProvider.ts index eec3169786..2a64930a56 100644 --- a/src/adb/AdbProvider.ts +++ b/src/adb/AdbProvider.ts @@ -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> => { +): Promise> => { 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> => - 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 +): Promise> => { + 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, diff --git a/src/adb/AdbShellOutput.ts b/src/adb/AdbShellOutput.ts index a67cf8e89a..97720366f4 100644 --- a/src/adb/AdbShellOutput.ts +++ b/src/adb/AdbShellOutput.ts @@ -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; diff --git a/src/contracts/adb/adbToolContracts.ts b/src/contracts/adb/adbToolContracts.ts index 30377069a5..0ebd39790f 100644 --- a/src/contracts/adb/adbToolContracts.ts +++ b/src/contracts/adb/adbToolContracts.ts @@ -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), diff --git a/src/domain/adb/adbDeviceAnalysis.ts b/src/domain/adb/adbDeviceAnalysis.ts index 37c72dbca9..fd956dde98 100644 --- a/src/domain/adb/adbDeviceAnalysis.ts +++ b/src/domain/adb/adbDeviceAnalysis.ts @@ -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( diff --git a/src/process/ExecFileOutput.test.ts b/src/process/ExecFileOutput.test.ts index f4d690e336..78d87800a3 100644 --- a/src/process/ExecFileOutput.test.ts +++ b/src/process/ExecFileOutput.test.ts @@ -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, [ diff --git a/src/process/ExecFileOutput.ts b/src/process/ExecFileOutput.ts index dd6aaae620..08046d83d3 100644 --- a/src/process/ExecFileOutput.ts +++ b/src/process/ExecFileOutput.ts @@ -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"), + })); diff --git a/tests/boundary/process/adbDirectoryListing.test.ts b/tests/boundary/process/adbDirectoryListing.test.ts new file mode 100644 index 0000000000..e060144dbe --- /dev/null +++ b/tests/boundary/process/adbDirectoryListing.test.ts @@ -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 => { + 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); + } + }, +);