mirror of
https://github.com/morluto/rea.git
synced 2026-10-31 03:45:39 +00:00
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.
519 lines
18 KiB
TypeScript
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 & 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 & 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: "你好" }]);
|
|
});
|