mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
5b014a739e
resolve_adb_packages marked coverage partial when pm list packages printed unrecognized rows, but dropped the rows themselves and could still emit a limitation describing an unparsed_lines field the result never carried. ADB parsers now own unrecognized-line facts as a bounded sample (first ten) plus the exact total, and a single provider projection derives unparsed_lines, coverage, and the truncation limitation from those facts for every parsed listing and the package search. The resolver result schema now carries unparsed_lines, matching list_adb_packages through CLI and MCP. Fixes #5843 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
289 lines
9.2 KiB
TypeScript
289 lines
9.2 KiB
TypeScript
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: { sample: [], total: 0 },
|
|
});
|
|
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);
|
|
}
|
|
},
|
|
);
|