Files
reverse-engineering-anything/src/apktool/ApktoolProvider.test.ts
T
morluto d4bd65c53c fix(providers)!: preserve readiness, coverage, and diagnostic ownership
Share tagged readiness observations, retain partial provider facts, authenticate bridge identity, bound diagnostics, and preserve cancellation and lost-session reasons. Fail closed for unparsed MIPS ABI attributes and macOS saved Hopper databases without a script-capable bridge route.

Reviewed fixes: #2010, #2108, #2513, #2695, #2731, #2771, #2828, #2867, #2868, #2870, #2880, #2919, #2945, #2961, #2981, #2998, #3000, #3002.

Validation: 285 focused source/boundary tests passed. Real Ghidra, IDA, Linux Hopper, and Windows provider runs were not repeated.

BREAKING CHANGE: MIPS targets with unresolved GNU ABI attributes and saved Hopper databases on macOS are refused rather than advertised as supported.
2026-10-14 08:28:14 +08:00

519 lines
18 KiB
TypeScript

import { createHash } from "node:crypto";
import {
chmod,
mkdtemp,
readFile,
rm,
truncate,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, onTestFinished, test } from "vitest";
import { ApktoolProvider } from "./ApktoolProvider.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { apktoolRequestSchema } from "../domain/apktool/apktoolResourceAnalysis.js";
import type { ApktoolRequest } from "../domain/apktool/apktoolResourceAnalysis.js";
const it = test.skipIf(process.platform === "win32");
const VERSION_BLOCK = "printf '2.7.0-stub\\n'";
const REAL_YML = [
"!!brut.androlib.meta.MetaInfo",
"sdkInfo:",
" minSdkVersion: '24'",
" targetSdkVersion: '34'",
"versionInfo:",
" versionCode: '7'",
" versionName: 1.2.3",
"",
].join("\n");
const REAL_MANIFEST = [
'<?xml version="1.0" encoding="utf-8" standalone="no"?>',
'<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.rea.apktool.probe">',
' <application android:label="@string/app_name" android:debuggable="true"/>',
"</manifest>",
"",
].join("\n");
const REAL_STRINGS = [
'<?xml version="1.0" encoding="utf-8"?>',
"<resources>",
' <string name="api_endpoint">https://api.example.com/v1</string>',
' <string name="app_name">ApktoolProbe</string>',
"</resources>",
"",
].join("\n");
const DE_STRINGS = [
'<?xml version="1.0" encoding="utf-8"?>',
"<resources>",
' <string name="app_name">ApktoolProbe DE</string>',
"</resources>",
"",
].join("\n");
const writeApktoolStub = async (
body: (root: string) => string,
): Promise<{ readonly command: string; readonly root: string }> => {
const root = await mkdtemp(join("/tmp", "rea-apktool-stub-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const command = join(root, "apktool");
await writeFile(command, `#!/bin/sh\n${body(root)}\n`, { mode: 0o755 });
await chmod(command, 0o755);
return { command, root };
};
const providerOver = (command: string): ApktoolProvider =>
new ApktoolProvider({
environment: { REA_APKTOOL_COMMAND: command },
});
const evidenceResult = async (
provider: ApktoolProvider,
request: ApktoolRequest,
): Promise<Record<string, unknown>> => {
const result = await provider.execute(request);
if (!result.ok) throw result.error;
return result.value.result as Record<string, unknown>;
};
const refusedBy = async (
provider: ApktoolProvider,
request: ApktoolRequest,
): Promise<ReturnType<typeof projectAnalysisError>> => {
const result = await provider.execute(request);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected a refusal");
return projectAnalysisError(result.error);
};
/** Stub writing a real-shaped decoded workspace into the -o directory. */
const decodeStub = (
options: {
readonly withStrings?: boolean;
readonly withGerman?: boolean;
readonly failDecode?: boolean;
readonly ymlFile?: string;
readonly manifestFile?: string;
readonly stringsFile?: string;
readonly stringsXml?: string;
readonly extraValuesDirectories?: readonly string[];
} = {},
): ((root: string) => string) => {
const withStrings = options.withStrings ?? true;
const withGerman = options.withGerman ?? true;
const extraValuesDirectories = options.extraValuesDirectories ?? [];
const stringsXml = options.stringsXml ?? REAL_STRINGS;
const ymlWrite =
options.ymlFile === undefined
? `printf '%s\\n' '${REAL_YML.replace(/'/gu, "'\\''")}' > "$5/apktool.yml"`
: `cp '${options.ymlFile}' "$5/apktool.yml"`;
const manifestWrite =
options.manifestFile === undefined
? `printf '%s\\n' '${REAL_MANIFEST.replace(/'/gu, "'\\''")}' > "$5/AndroidManifest.xml"`
: `cp '${options.manifestFile}' "$5/AndroidManifest.xml"`;
const stringsWrite =
options.stringsFile === undefined
? `printf '%s\\n' '${stringsXml.replace(/'/gu, "'\\''")}' > "$5/res/values/strings.xml"`
: `cp '${options.stringsFile}' "$5/res/values/strings.xml"`;
const qualifierStrings = extraValuesDirectories
.map(
(name) =>
`printf '%s\\n' '<resources><string name="wrong">Wrong</string></resources>' > "$5/res/values-${name}/strings.xml"`,
)
.join("\\n ");
return () => `if [ "$1" = "--version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "d" ]; then
${
options.failDecode === true
? `printf 'Exception in thread "main" brut.androlib.AndrolibException: Could not decode the APK\\n' >&2; exit 1;`
: `${ymlWrite}
${manifestWrite}
mkdir -p "$5/res/values"${withGerman ? ' "$5/res/values-de"' : ""}${extraValuesDirectories.map((name) => ` "$5/res/values-${name}"`).join("")}
${withStrings ? stringsWrite : ""}
${withGerman ? `printf '%s\\n' '${DE_STRINGS.replace(/'/gu, "'\\''")}' > "$5/res/values-de/strings.xml"` : ""}
${qualifierStrings}
printf 'I: Using Apktool on stub\\n'
exit 0`
}
fi
printf 'unexpected arguments: %s\\n' "$@"
exit 1`;
};
const apkFixture = async (
root: string,
): Promise<{
readonly path: string;
readonly bytes: number;
readonly sha256: string;
}> => {
const path = join(root, "probe.apk");
const bytes = "apk-bytes-for-apktool";
await writeFile(path, bytes);
return {
path,
bytes: bytes.length,
sha256: createHash("sha256").update(bytes).digest("hex"),
};
};
it("inspects the apktool launcher", async () => {
const { command } = await writeApktoolStub(
() => `if [ "$1" = "--version" ]; then ${VERSION_BLOCK}; exit 0; fi`,
);
const result = await evidenceResult(providerOver(command), {
operation: "inspect_apktool_client",
input: {},
});
expect(result.client).toEqual({
command,
command_source: "environment",
apktool_version: "2.7.0-stub",
});
});
it("decodes resources with target identity, metadata, and strings", async () => {
const { command, root } = await writeApktoolStub(decodeStub());
const apk = await apkFixture(root);
const result = await evidenceResult(providerOver(command), {
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true },
});
expect(result.target).toEqual({
path: apk.path,
bytes: apk.bytes,
sha256: apk.sha256,
});
expect(result.metadata).toEqual({
version_name: "1.2.3",
version_code: "7",
min_sdk_version: "24",
target_sdk_version: "34",
package_name: "com.rea.apktool.probe",
});
expect(result.manifest).toContain('package="com.rea.apktool.probe"');
expect(result.strings).toEqual([
{ name: "api_endpoint", value: "https://api.example.com/v1" },
{ name: "app_name", value: "ApktoolProbe" },
]);
expect(result.locales).toEqual(["de"]);
expect(result.locale).toBeNull();
expect(result.decoded_file_count).toBeGreaterThan(2);
expect(result.coverage).toBe("complete");
});
it.each([
{
name: "malformed UTF-8",
bytes: Buffer.concat([
Buffer.from("versionInfo:\n versionName: com."),
Buffer.from([0xff]),
Buffer.from(".app\n"),
]),
valid: false,
},
{
name: "a literal replacement character",
bytes: Buffer.from("versionInfo:\n versionName: com.�.app\n", "utf8"),
valid: true,
},
])("handles $name in producer metadata", async ({ bytes, valid }) => {
const { command, root } = await writeApktoolStub((root) =>
decodeStub({ ymlFile: join(root, "malformed-apktool.yml") })(root),
);
await writeFile(join(root, "malformed-apktool.yml"), bytes);
const apk = await apkFixture(root);
if (valid) {
const result = await evidenceResult(providerOver(command), {
operation: "decode_android_resources",
input: { path: apk.path, include_strings: false },
});
expect(result.metadata).toMatchObject({ version_name: "com.�.app" });
expect(result.coverage).toBe("complete");
return;
}
const result = await providerOver(command).execute({
operation: "decode_android_resources",
input: { path: apk.path, include_strings: false },
});
if (result.ok) throw new Error("expected invalid producer output");
const projected = projectAnalysisError(result.error);
expect(projected).toMatchObject({ code: "unreadable_output" });
expect(result.error.message).toContain("invalid UTF-8");
expect(result.error.message).toContain("apktool.yml");
});
it("preserves empty resource strings and reports markup omissions as partial", async () => {
const stringsXml =
'<resources><string name="empty"/><string name="raw"> A &amp; B </string><string name="styled">Hello <b>world</b></string></resources>';
const { command, root } = await writeApktoolStub(decodeStub({ stringsXml }));
const apk = await apkFixture(root);
const result = await evidenceResult(providerOver(command), {
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true },
});
expect(result.strings).toEqual([
{ name: "empty", value: "" },
{ name: "raw", value: " A &amp; B " },
]);
expect(result.coverage).toBe("partial");
});
it("ignores Android resource qualifiers when discovering locales", async () => {
const { command, root } = await writeApktoolStub(
decodeStub({ extraValuesDirectories: ["car", "night", "v21"] }),
);
const apk = await apkFixture(root);
const result = await evidenceResult(providerOver(command), {
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true },
});
expect(result.locales).toEqual(["de"]);
});
it.each(["car", "night", "v21"])(
"rejects %s as a requested locale",
(locale) => {
expect(
apktoolRequestSchema.safeParse({
operation: "decode_android_resources",
input: { path: "/tmp/probe.apk", locale },
}).success,
).toBe(false);
},
);
it("projects one locale's strings on request", async () => {
const { command, root } = await writeApktoolStub(decodeStub());
const apk = await apkFixture(root);
const result = await evidenceResult(providerOver(command), {
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true, locale: "de" },
});
expect(result.locale).toBe("de");
expect(result.strings).toEqual([
{ name: "app_name", value: "ApktoolProbe DE" },
]);
});
it("notes a missing locale table instead of failing", async () => {
const { command, root } = await writeApktoolStub(decodeStub());
const apk = await apkFixture(root);
const result = await evidenceResult(providerOver(command), {
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true, locale: "fr" },
});
expect(result.strings).toEqual([]);
});
it("notes a missing default strings table without strings", async () => {
const { command, root } = await writeApktoolStub(
decodeStub({ withStrings: false }),
);
const apk = await apkFixture(root);
const result = await evidenceResult(providerOver(command), {
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true },
});
expect(result.strings).toEqual([]);
});
it("reports apktool's own refusal for undecodable targets", async () => {
const { command, root } = await writeApktoolStub(
decodeStub({ failDecode: true }),
);
const apk = await apkFixture(root);
const projected = await refusedBy(providerOver(command), {
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true },
});
expect(projected).toMatchObject({ code: "unsupported_target" });
expect(projected.message).toContain("Could not decode");
});
it("reports a missing launcher as a capability gap with recovery", async () => {
const provider = new ApktoolProvider({
environment: { REA_APKTOOL_COMMAND: "/nonexistent/apktool" },
});
const availability = await provider.inspectAvailability();
expect(availability).toMatchObject({
status: "unavailable",
code: "executable_missing",
});
const projected = await refusedBy(provider, {
operation: "inspect_apktool_client",
input: {},
});
expect(projected).toMatchObject({ code: "capability_unavailable" });
expect(projected.details).toMatchObject({
provider_id: "apktool",
reason: expect.stringContaining("REA_APKTOOL_COMMAND") as unknown,
});
});
it("rejects a target that is not a readable file", async () => {
const { command, root } = await writeApktoolStub(decodeStub());
const projected = await refusedBy(providerOver(command), {
operation: "decode_android_resources",
input: { path: join(root, "absent.apk"), include_strings: true },
});
expect(projected).toMatchObject({ code: "invalid_request" });
});
it("cancels before spawning anything", async () => {
const { command, root } = await writeApktoolStub(
(root) => `: > ${join(root, "spawned")}
if [ "$1" = "--version" ]; then ${VERSION_BLOCK}; exit 0; fi`,
);
const controller = new AbortController();
controller.abort();
const result = await providerOver(command).execute(
{
operation: "decode_android_resources",
input: { path: "/targets/x.apk", include_strings: true },
},
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cancelled",
});
expect(
await readFile(join(root, "spawned")).then(
() => true,
() => false,
),
).toBe(false);
});
it("uses an owned APK snapshot and framework cache and resolves regional locales", async () => {
const { command, root } = await writeApktoolStub((root) =>
decodeStub()(root).replace(
'if [ "$1" = "d" ]; then',
`if [ "$1" = "d" ]; then
[ "$6" = "--frame-path" ] || exit 7
[ -d "$7" ] || exit 8
printf 'changed original' > "${join(root, "probe.apk")}"
cat "$8" > "${join(root, "snapshot-bytes")}"
mkdir -p "$5/res/values-pt-rBR"
printf '<resources><string name="greeting">Olá</string></resources>' > "$5/res/values-pt-rBR/strings.xml"`,
),
);
const apk = await apkFixture(root);
const result = await evidenceResult(providerOver(command), {
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true, locale: "pt-BR" },
});
expect(result.target).toMatchObject({
sha256: apk.sha256,
bytes: apk.bytes,
path: apk.path,
});
expect(await readFile(join(root, "snapshot-bytes"), "utf8")).toBe(
"apk-bytes-for-apktool",
);
expect(result.strings).toEqual([{ name: "greeting", value: "Olá" }]);
});
it("marks an oversized strings document partial without claiming it is missing", async () => {
const { command, root } = await writeApktoolStub((root) =>
decodeStub({ stringsFile: join(root, "large-strings.xml") })(root),
);
await writeFile(join(root, "large-strings.xml"), "");
await truncate(join(root, "large-strings.xml"), 9 * 1024 * 1024);
const apk = await apkFixture(root);
const result = await providerOver(command).execute({
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true },
});
if (!result.ok) throw result.error;
expect(result.value.result).toMatchObject({
coverage: "partial",
strings: [],
});
expect(result.value.limitations.join(" ")).toContain("exceeds");
expect(result.value.limitations.join(" ")).not.toContain("No default");
});
describe("truncated manifest prefixes", () => {
it.each([
{
name: "a retained prefix ending after the root element",
manifest: `<manifest package="com.rea.large"></manifest>${" ".repeat(512 * 1024 + 1)}`,
},
{
name: "a retained prefix ending before the root element closes",
manifest: `<manifest package="com.rea.large">${" ".repeat(512 * 1024 + 1)}</manifest>`,
},
])("retains $name without parsing its package", async ({ manifest }) => {
const { command, root } = await writeApktoolStub((root) =>
decodeStub({ manifestFile: join(root, "large-AndroidManifest.xml") })(
root,
),
);
await writeFile(join(root, "large-AndroidManifest.xml"), manifest);
const apk = await apkFixture(root);
const result = await providerOver(command).execute({
operation: "decode_android_resources",
input: { path: apk.path, include_strings: false },
});
if (!result.ok) throw result.error;
expect(result.value.result).toMatchObject({
coverage: "partial",
manifest: manifest.slice(0, 512 * 1024),
metadata: { package_name: null, version_name: "1.2.3" },
});
expect(result.value.limitations.join(" ")).toContain(
"manifest is truncated",
);
});
});
it("retains failed workspace cleanup and retries it on close", async () => {
const { command, root } = await writeApktoolStub(
decodeStub({ failDecode: true }),
);
const apk = await apkFixture(root);
let attempts = 0;
let owned: string | undefined;
const provider = new ApktoolProvider({
environment: { REA_APKTOOL_COMMAND: command },
removeWorkspace: async (path) => {
owned = path;
if (++attempts === 1) throw new Error("busy");
await rm(path, { recursive: true, force: true });
},
});
const result = await provider.execute({
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true },
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error.cleanup?.resources).toContain(owned);
await provider.close();
expect(attempts).toBe(2);
});
it("accepts a BCP47 resource qualifier through the public request schema", async () => {
const { command, root } = await writeApktoolStub((root) =>
decodeStub()(root).replace(
" printf 'I: Using Apktool on stub",
' mkdir -p "$5/res/values-b+zh+Hans+CN"\n printf \'<resources><string name="greeting">你好</string></resources>\' > "$5/res/values-b+zh+Hans+CN/strings.xml"\n printf \'I: Using Apktool on stub',
),
);
const apk = await apkFixture(root);
const request = apktoolRequestSchema.parse({
operation: "decode_android_resources",
input: { path: apk.path, locale: "b+zh+Hans+CN" },
});
const result = await evidenceResult(providerOver(command), request);
expect(result.strings).toEqual([{ name: "greeting", value: "你好" }]);
});