mirror of
https://github.com/morluto/rea.git
synced 2026-11-03 13:26:10 +00:00
Add opt-in string_tables_only and CLI --string-tables-only to keep selected strings once in typed table facts. Preserve existing defaults and legacy replies; charge aggregate output capacity for the copies actually requested. Validate shared CLI/MCP behavior with independent XML oracles, three real APKs, 94 source tests, nine compiled boundary tests, 36 completed MCP tasks and three CLI parity tasks. Preserve full task estimates, negative timings, resource admission refusal and verification limitations in the measured report. Fixes #4982 Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
1661 lines
56 KiB
TypeScript
1661 lines
56 KiB
TypeScript
import { createDeferred } from "../../tests/fixtures/binarySession.js";
|
|
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
chmod,
|
|
mkdtemp,
|
|
readFile,
|
|
stat,
|
|
rm,
|
|
truncate,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { describe, expect, onTestFinished, test, vi } from "vitest";
|
|
|
|
import { ApktoolProvider } from "./ApktoolProvider.js";
|
|
import { ApktoolWorkspace } from "./ApktoolWorkspace.js";
|
|
import { ApktoolResourceAnalysisService } from "../application/apktool/ApktoolResourceAnalysisService.js";
|
|
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
|
import {
|
|
apktoolRequestSchema,
|
|
apktoolResultSchemas,
|
|
} 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-dirty\\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("returns table-only facts for a single selected primary table", async () => {
|
|
const { command, root } = await writeApktoolStub(decodeStub());
|
|
const apk = await apkFixture(root);
|
|
const provider = providerOver(command);
|
|
onTestFinished(() => provider.close());
|
|
const outcome = await new ApktoolResourceAnalysisService(provider).execute(
|
|
"decode_android_resources",
|
|
{ path: apk.path, string_tables_only: true },
|
|
);
|
|
if (!outcome.ok) throw outcome.error;
|
|
expect(outcome.value).toMatchObject({
|
|
parameters: { path: apk.path, string_tables_only: true },
|
|
subject: { digest: { sha256: apk.sha256 } },
|
|
normalized_result: {
|
|
strings: [],
|
|
string_tables: [
|
|
{
|
|
locale: null,
|
|
status: "present",
|
|
strings: [
|
|
{ name: "api_endpoint", value: "https://api.example.com/v1" },
|
|
{ name: "app_name", value: "ApktoolProbe" },
|
|
],
|
|
total_count: 2,
|
|
unparsed_count: 0,
|
|
coverage: "complete",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
[false, false],
|
|
[true, false],
|
|
[false, true],
|
|
[true, true],
|
|
])(
|
|
"retains completed decode facts when a later locale read fails (cleanup denied: %s, late cancellation: %s)",
|
|
async (denyCleanup, lateCancellation) => {
|
|
const { command, root } = await writeApktoolStub((path) =>
|
|
decodeStub()(path).replace(
|
|
" printf 'I: Using Apktool on stub",
|
|
" printf '\\377' > \"$5/res/values-de/strings.xml\"\n printf 'I: Using Apktool on stub",
|
|
),
|
|
);
|
|
const apk = await apkFixture(root);
|
|
let denied = denyCleanup;
|
|
const controller = new AbortController();
|
|
const provider = new ApktoolProvider({
|
|
environment: { REA_APKTOOL_COMMAND: command },
|
|
removeWorkspace: async (path) => {
|
|
if (lateCancellation) controller.abort({ stage: "cleanup" });
|
|
if (denied) throw new Error("authored removal denial");
|
|
await rm(path, { recursive: true, force: true });
|
|
},
|
|
});
|
|
try {
|
|
const result = await provider.execute(
|
|
{
|
|
operation: "decode_android_resources",
|
|
input: { path: apk.path, include_strings: true, locales: ["de"] },
|
|
},
|
|
{ signal: controller.signal },
|
|
);
|
|
if (result.ok) throw new Error("Expected invalid UTF-8 failure");
|
|
expect((result.error.cleanupPrimary ?? result.error).message).toContain(
|
|
"UTF-8",
|
|
);
|
|
expect(result.error.cleanupIncomplete).toBe(denyCleanup);
|
|
const observation = {
|
|
operation: "decode_android_resources",
|
|
parameters: { path: apk.path, locales: ["de"] },
|
|
subject: { local_path: apk.path, digest: { sha256: apk.sha256 } },
|
|
normalized_result: {
|
|
metadata: { package_name: "com.rea.apktool.probe" },
|
|
manifest: `${REAL_MANIFEST}\n`,
|
|
strings: [
|
|
{ name: "api_endpoint", value: "https://api.example.com/v1" },
|
|
{ name: "app_name", value: "ApktoolProbe" },
|
|
],
|
|
string_tables: [{ locale: null, status: "present", total_count: 2 }],
|
|
coverage: "partial",
|
|
},
|
|
};
|
|
expect(result.error.partialObservation).toMatchObject(observation);
|
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
|
details: { partial_observation: observation },
|
|
});
|
|
denied = false;
|
|
await provider.close();
|
|
expect(result.error.cleanupIncomplete).toBe(false);
|
|
expect(result.error.partialObservation).toMatchObject(observation);
|
|
} finally {
|
|
denied = false;
|
|
await provider.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each(
|
|
(["manifest-bytes", "manifest-xml", "inventory"] as const).flatMap((stage) =>
|
|
[false, true].map((denied) => [stage, denied] as const),
|
|
),
|
|
)(
|
|
"retains parsed metadata when %s fails before string projection (cleanup denied: %s)",
|
|
async (stage, denyCleanup) => {
|
|
const { command, root } = await writeApktoolStub(decodeStub());
|
|
const apk = await apkFixture(root);
|
|
if (stage === "inventory") {
|
|
const entries = ApktoolWorkspace.prototype.entries;
|
|
const spy = vi
|
|
.spyOn(ApktoolWorkspace.prototype, "entries")
|
|
.mockImplementationOnce(async function* (
|
|
this: ApktoolWorkspace,
|
|
...args
|
|
) {
|
|
for await (const entry of entries.apply(this, args)) {
|
|
yield entry;
|
|
if (entry.kind === "file")
|
|
throw new Error("authored enumeration failure");
|
|
}
|
|
});
|
|
onTestFinished(() => spy.mockRestore());
|
|
} else {
|
|
const manifestFile = join(root, "manifest-control");
|
|
await writeFile(
|
|
manifestFile,
|
|
stage === "manifest-bytes" ? Buffer.from([255]) : "<resources/>",
|
|
);
|
|
await writeFile(
|
|
command,
|
|
`#!/bin/sh\n${decodeStub({ manifestFile })(root)}`,
|
|
{ mode: 0o755 },
|
|
);
|
|
}
|
|
const controller = new AbortController();
|
|
let denied = denyCleanup;
|
|
const provider = new ApktoolProvider({
|
|
environment: { REA_APKTOOL_COMMAND: command },
|
|
removeWorkspace: async (path) => {
|
|
controller.abort({ stage: "final cleanup" });
|
|
if (denied) throw new Error("authored staged removal denial");
|
|
await rm(path, { recursive: true, force: true });
|
|
},
|
|
});
|
|
try {
|
|
const result = await provider.execute(
|
|
{
|
|
operation: "decode_android_resources",
|
|
input: { path: apk.path, include_strings: true },
|
|
},
|
|
{ signal: controller.signal },
|
|
);
|
|
if (result.ok) throw new Error("Expected staged read failure");
|
|
expect((result.error.cleanupPrimary ?? result.error)._tag).toBe(
|
|
"AnalysisProtocolError",
|
|
);
|
|
expect(controller.signal.aborted).toBe(true);
|
|
expect(result.error.cleanupIncomplete).toBe(denyCleanup);
|
|
const observation = result.error.partialObservation;
|
|
expect(observation).toMatchObject({
|
|
operation: "decode_android_resources",
|
|
subject: { local_path: apk.path, digest: { sha256: apk.sha256 } },
|
|
normalized_result: {
|
|
coverage: "partial",
|
|
metadata: {
|
|
version_name: "1.2.3",
|
|
version_code: "7",
|
|
min_sdk_version: "24",
|
|
target_sdk_version: "34",
|
|
},
|
|
},
|
|
});
|
|
const projected = projectAnalysisError(result.error).details
|
|
?.partial_observation;
|
|
expect(projected).toEqual(observation);
|
|
expect(observation).toHaveProperty("normalized_result");
|
|
if (observation === undefined || !("normalized_result" in observation))
|
|
throw new Error("Expected partial Evidence");
|
|
expect(observation.normalized_result).not.toHaveProperty("strings");
|
|
if (stage !== "inventory") {
|
|
expect(observation.normalized_result).not.toHaveProperty(
|
|
"decoded_file_count",
|
|
);
|
|
expect(observation.normalized_result).not.toHaveProperty(
|
|
"metadata.package_name",
|
|
);
|
|
}
|
|
if (stage === "manifest-bytes")
|
|
expect(observation.normalized_result).not.toHaveProperty("manifest");
|
|
if (stage === "manifest-xml")
|
|
expect(observation.normalized_result).toHaveProperty(
|
|
"manifest",
|
|
"<resources/>",
|
|
);
|
|
if (stage === "inventory")
|
|
expect(observation.normalized_result).toMatchObject({
|
|
decoded_file_count: 1,
|
|
metadata: { package_name: "com.rea.apktool.probe" },
|
|
});
|
|
denied = false;
|
|
await provider.close();
|
|
expect(result.error.cleanupIncomplete).toBe(false);
|
|
expect(result.error.partialObservation).toEqual(observation);
|
|
} finally {
|
|
denied = false;
|
|
await provider.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
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-dirty",
|
|
});
|
|
});
|
|
|
|
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("preserves literal U+FFFD in decoded XML with complete coverage", async () => {
|
|
const stringsXml =
|
|
'<resources><string name="good">Hello</string><string name="edge">\uFFFD</string></resources>';
|
|
const { command, root } = await writeApktoolStub((root) =>
|
|
decodeStub({
|
|
stringsXml,
|
|
manifestFile: join(root, "AndroidManifest.xml"),
|
|
})(root),
|
|
);
|
|
const manifest = REAL_MANIFEST.replace("@string/app_name", "\uFFFD");
|
|
await writeFile(join(root, "AndroidManifest.xml"), manifest, "utf8");
|
|
const apk = await apkFixture(root);
|
|
const result = await evidenceResult(providerOver(command), {
|
|
operation: "decode_android_resources",
|
|
input: { path: apk.path, include_strings: true },
|
|
});
|
|
expect(result).toMatchObject({
|
|
manifest,
|
|
metadata: { package_name: "com.rea.apktool.probe" },
|
|
strings: [
|
|
{ name: "good", value: "Hello" },
|
|
{ name: "edge", value: "\uFFFD" },
|
|
],
|
|
coverage: "complete",
|
|
});
|
|
});
|
|
|
|
it("still refuses invalid UTF-8 bytes in decoded strings", async () => {
|
|
const { command, root } = await writeApktoolStub((root) =>
|
|
decodeStub({ stringsFile: join(root, "strings.xml") })(root),
|
|
);
|
|
await writeFile(
|
|
join(root, "strings.xml"),
|
|
Buffer.concat([
|
|
Buffer.from('<resources><string name="bad">'),
|
|
Buffer.from([0xff]),
|
|
Buffer.from("</string></resources>"),
|
|
]),
|
|
);
|
|
const apk = await apkFixture(root);
|
|
const result = await providerOver(command).execute({
|
|
operation: "decode_android_resources",
|
|
input: { path: apk.path, include_strings: true },
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) throw new Error("expected a refusal");
|
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
|
code: "unreadable_output",
|
|
});
|
|
expect(result.error.message).toContain("invalid UTF-8");
|
|
expect(result.error.message).toContain("strings.xml");
|
|
});
|
|
|
|
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"]);
|
|
expect(result.decoded_file_count).toBe(7);
|
|
});
|
|
|
|
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",
|
|
details: { path: apk.path },
|
|
});
|
|
expect(projected.message).toContain(apk.path);
|
|
expect(projected.message).not.toContain(command);
|
|
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("fails closed for unrecognized and unsupported Apktool versions", async () => {
|
|
const invalidOutput = `unexpected Apktool output ${"x".repeat(700)}\n`;
|
|
const unrecognized = await writeApktoolStub(
|
|
() => `printf '%s' '${invalidOutput}'; exit 0`,
|
|
);
|
|
await expect(
|
|
providerOver(unrecognized.command).inspectAvailability(),
|
|
).resolves.toMatchObject({
|
|
status: "unavailable",
|
|
code: "version_unresolved",
|
|
diagnostics: {
|
|
reported_version: invalidOutput.trim(),
|
|
stdout: invalidOutput,
|
|
},
|
|
});
|
|
|
|
const old = await writeApktoolStub(() => `printf '2.6.3\\n'`);
|
|
await expect(
|
|
providerOver(old.command).inspectAvailability(),
|
|
).resolves.toMatchObject({
|
|
status: "unavailable",
|
|
code: "unsupported_version",
|
|
});
|
|
|
|
const prerelease = await writeApktoolStub(() => `printf '2.7.0-beta.1\\n'`);
|
|
await expect(
|
|
providerOver(prerelease.command).inspectAvailability(),
|
|
).resolves.toMatchObject({
|
|
status: "unavailable",
|
|
code: "unsupported_version",
|
|
});
|
|
});
|
|
|
|
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 observation = await new ApktoolResourceAnalysisService(
|
|
providerOver(command),
|
|
).execute("decode_android_resources", {
|
|
path: apk.path,
|
|
include_strings: true,
|
|
locale: "pt-BR",
|
|
});
|
|
if (!observation.ok) throw observation.error;
|
|
expect(observation.value.subject).toMatchObject({
|
|
local_path: apk.path,
|
|
format: "apk",
|
|
digest: { sha256: apk.sha256 },
|
|
});
|
|
expect(observation.value.limitations).not.toContain(
|
|
"Artifact identity is unavailable for this observation.",
|
|
);
|
|
expect(await readFile(apk.path, "utf8")).toBe("changed original");
|
|
const result = apktoolResultSchemas.decode_android_resources.parse(
|
|
observation.value.normalized_result,
|
|
);
|
|
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("shares one decode across default and selected locale tables", async () => {
|
|
const { command, root } = await writeApktoolStub((root) =>
|
|
decodeStub({ withGerman: false })(root).replace(
|
|
'if [ "$1" = "d" ]; then',
|
|
`if [ "$1" = "d" ]; then
|
|
printf 'd\\n' >> "${root}/decode-invocations.log"
|
|
mkdir -p "$5/res/values-de" "$5/res/values-pt-rBR"
|
|
printf '<resources><string name="greeting">Hallo</string></resources>' > "$5/res/values-de/strings.xml"
|
|
printf '<resources><string name="greeting">Olá</string></resources>' > "$5/res/values-pt-rBR/strings.xml"`,
|
|
),
|
|
);
|
|
const apk = await apkFixture(root);
|
|
const observation = await new ApktoolResourceAnalysisService(
|
|
providerOver(command),
|
|
).execute("decode_android_resources", {
|
|
path: apk.path,
|
|
include_strings: true,
|
|
locales: ["de", "pt-BR"],
|
|
});
|
|
if (!observation.ok) throw observation.error;
|
|
const result = apktoolResultSchemas.decode_android_resources.parse(
|
|
observation.value.normalized_result,
|
|
);
|
|
expect(result.string_tables).toEqual([
|
|
{
|
|
locale: null,
|
|
status: "present",
|
|
strings: [
|
|
{ name: "api_endpoint", value: "https://api.example.com/v1" },
|
|
{ name: "app_name", value: "ApktoolProbe" },
|
|
],
|
|
total_count: 2,
|
|
unparsed_count: 0,
|
|
coverage: "complete",
|
|
},
|
|
{
|
|
locale: "de",
|
|
status: "present",
|
|
strings: [{ name: "greeting", value: "Hallo" }],
|
|
total_count: 1,
|
|
unparsed_count: 0,
|
|
coverage: "complete",
|
|
},
|
|
{
|
|
locale: "pt-BR",
|
|
status: "present",
|
|
strings: [{ name: "greeting", value: "Olá" }],
|
|
total_count: 1,
|
|
unparsed_count: 0,
|
|
coverage: "complete",
|
|
},
|
|
]);
|
|
expect(result.strings).toEqual([
|
|
{ name: "api_endpoint", value: "https://api.example.com/v1" },
|
|
{ name: "app_name", value: "ApktoolProbe" },
|
|
]);
|
|
expect(result.locale).toBe(null);
|
|
expect(result.coverage).toBe("complete");
|
|
|
|
const invocations = (
|
|
await readFile(join(root, "decode-invocations.log"), "utf8").catch(() => "")
|
|
)
|
|
.trim()
|
|
.split("\n")
|
|
.filter((line) => line.length > 0);
|
|
expect(invocations).toHaveLength(1);
|
|
});
|
|
|
|
it("reuses normalized directory table for locale aliases without extra reads", async () => {
|
|
const { command, root } = await writeApktoolStub((root) =>
|
|
decodeStub()(root).replace(
|
|
'if [ "$1" = "d" ]; then',
|
|
`if [ "$1" = "d" ]; then
|
|
printf 'd\\n' >> "${root}/decode-invocations.log"
|
|
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 observation = await new ApktoolResourceAnalysisService(
|
|
providerOver(command),
|
|
).execute("decode_android_resources", {
|
|
path: apk.path,
|
|
include_strings: true,
|
|
locale: "pt-BR",
|
|
locales: ["pt-rBR"],
|
|
});
|
|
if (!observation.ok) throw observation.error;
|
|
const result = apktoolResultSchemas.decode_android_resources.parse(
|
|
observation.value.normalized_result,
|
|
);
|
|
expect(result.string_tables).toEqual([
|
|
{
|
|
locale: "pt-BR",
|
|
status: "present",
|
|
strings: [{ name: "greeting", value: "Olá" }],
|
|
total_count: 1,
|
|
unparsed_count: 0,
|
|
coverage: "complete",
|
|
},
|
|
{
|
|
locale: "pt-rBR",
|
|
status: "present",
|
|
strings: [{ name: "greeting", value: "Olá" }],
|
|
total_count: 1,
|
|
unparsed_count: 0,
|
|
coverage: "complete",
|
|
},
|
|
]);
|
|
const invocations = (
|
|
await readFile(join(root, "decode-invocations.log"), "utf8").catch(() => "")
|
|
)
|
|
.trim()
|
|
.split("\n")
|
|
.filter((line) => line.length > 0);
|
|
expect(invocations).toHaveLength(1);
|
|
});
|
|
|
|
it.each([2000, 2001])(
|
|
"retains observed alias counts after exhausting the aggregate entry budget (%i entries)",
|
|
async (entryCount) => {
|
|
const { command, root } = await writeApktoolStub((root) =>
|
|
decodeStub({ withGerman: false, withStrings: false })(root).replace(
|
|
'if [ "$1" = "d" ]; then',
|
|
`if [ "$1" = "d" ]; then
|
|
for suffix in pt-rBR de es it; do
|
|
mkdir -p "$5/res/values-$suffix"
|
|
cp '${join(root, "strings.xml")}' "$5/res/values-$suffix/strings.xml"
|
|
done`,
|
|
),
|
|
);
|
|
const entries = Array.from(
|
|
{ length: entryCount },
|
|
(_, i) => `<string name="s_${String(i)}">value</string>`,
|
|
).join("");
|
|
await writeFile(
|
|
join(root, "strings.xml"),
|
|
`<resources>${entries}<string name="styled"><b>unknown</b></string></resources>`,
|
|
);
|
|
const apk = await apkFixture(root);
|
|
const provider = providerOver(command);
|
|
try {
|
|
const observation = await new ApktoolResourceAnalysisService(
|
|
provider,
|
|
).execute("decode_android_resources", {
|
|
path: apk.path,
|
|
include_strings: true,
|
|
locale: "pt-BR",
|
|
locales: ["de", "es", "it", "pt-rBR"],
|
|
});
|
|
if (!observation.ok) throw observation.error;
|
|
const result = apktoolResultSchemas.decode_android_resources.parse(
|
|
observation.value.normalized_result,
|
|
);
|
|
expect(result.string_tables?.at(-1)).toEqual({
|
|
locale: "pt-rBR",
|
|
status: "skipped",
|
|
strings: [],
|
|
total_count: entryCount,
|
|
unparsed_count: 1,
|
|
coverage: "partial",
|
|
});
|
|
expect(result.strings).toHaveLength(2000);
|
|
expect(
|
|
observation.value.limitations.some((note) =>
|
|
note.startsWith("Locale pt-rBR strings reports the first"),
|
|
),
|
|
).toBe(false);
|
|
expect(result.coverage).toBe("partial");
|
|
} finally {
|
|
await provider.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("skips all string tables when include_strings is false despite locales requested", async () => {
|
|
const { command, root } = await writeApktoolStub((root) =>
|
|
decodeStub({ withGerman: false })(root).replace(
|
|
'if [ "$1" = "d" ]; then',
|
|
`if [ "$1" = "d" ]; then
|
|
mkdir -p "$5/res/values-de"
|
|
printf '<resources><string name="greeting">Hallo</string></resources>' > "$5/res/values-de/strings.xml"`,
|
|
),
|
|
);
|
|
const apk = await apkFixture(root);
|
|
const observation = await new ApktoolResourceAnalysisService(
|
|
providerOver(command),
|
|
).execute("decode_android_resources", {
|
|
path: apk.path,
|
|
include_strings: false,
|
|
locales: ["de"],
|
|
});
|
|
if (!observation.ok) throw observation.error;
|
|
const result = apktoolResultSchemas.decode_android_resources.parse(
|
|
observation.value.normalized_result,
|
|
);
|
|
expect(result.string_tables).toEqual([]);
|
|
expect(result.strings).toEqual([]);
|
|
});
|
|
|
|
it("marks table with styled markup present with partial coverage and unparsed count", async () => {
|
|
const { command, root } = await writeApktoolStub((root) =>
|
|
decodeStub({ withGerman: false })(root).replace(
|
|
'if [ "$1" = "d" ]; then',
|
|
`if [ "$1" = "d" ]; then
|
|
mkdir -p "$5/res/values-de"
|
|
printf '<resources><string name="styled">Hallo <b>Welt</b></string></resources>' > "$5/res/values-de/strings.xml"`,
|
|
),
|
|
);
|
|
const apk = await apkFixture(root);
|
|
const observation = await new ApktoolResourceAnalysisService(
|
|
providerOver(command),
|
|
).execute("decode_android_resources", {
|
|
path: apk.path,
|
|
include_strings: true,
|
|
locales: ["de"],
|
|
});
|
|
if (!observation.ok) throw observation.error;
|
|
const result = apktoolResultSchemas.decode_android_resources.parse(
|
|
observation.value.normalized_result,
|
|
);
|
|
const deTable = result.string_tables?.find((t) => t.locale === "de");
|
|
expect(deTable).toEqual({
|
|
locale: "de",
|
|
status: "present",
|
|
strings: [],
|
|
total_count: 0,
|
|
unparsed_count: 1,
|
|
coverage: "partial",
|
|
});
|
|
});
|
|
|
|
it("skips remaining tables without reading or parsing once aggregate string budget is exhausted", async () => {
|
|
const { command, root } = await writeApktoolStub((root) =>
|
|
decodeStub({
|
|
withGerman: false,
|
|
stringsFile: join(root, "big-strings.xml"),
|
|
})(root).replace(
|
|
'if [ "$1" = "d" ]; then',
|
|
`if [ "$1" = "d" ]; then
|
|
mkdir -p "$5/res/values-de" "$5/res/values-es" "$5/res/values-it" "$5/res/values-fr"
|
|
cp '${join(root, "big-strings.xml")}' "$5/res/values-de/strings.xml"
|
|
cp '${join(root, "big-strings.xml")}' "$5/res/values-es/strings.xml"
|
|
cp '${join(root, "big-strings.xml")}' "$5/res/values-it/strings.xml"
|
|
printf '<resources><broken' > "$5/res/values-fr/strings.xml"`,
|
|
),
|
|
);
|
|
const bigStrings = `<resources>${Array.from({ length: 2000 }, (_, i) => `<string name="s_${String(i)}">val</string>`).join("")}</resources>`;
|
|
await writeFile(join(root, "big-strings.xml"), bigStrings);
|
|
const apk = await apkFixture(root);
|
|
const observation = await new ApktoolResourceAnalysisService(
|
|
providerOver(command),
|
|
).execute("decode_android_resources", {
|
|
path: apk.path,
|
|
include_strings: true,
|
|
locales: ["de", "es", "it", "fr"],
|
|
});
|
|
if (!observation.ok) throw observation.error;
|
|
const result = apktoolResultSchemas.decode_android_resources.parse(
|
|
observation.value.normalized_result,
|
|
);
|
|
const frTable = result.string_tables?.find((t) => t.locale === "fr");
|
|
expect(frTable).toEqual({
|
|
locale: "fr",
|
|
status: "skipped",
|
|
strings: [],
|
|
total_count: null,
|
|
unparsed_count: null,
|
|
coverage: "partial",
|
|
});
|
|
expect(result.coverage).toBe("partial");
|
|
});
|
|
|
|
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: "你好" }]);
|
|
});
|
|
|
|
it("retains completed Evidence and live cleanup until a denied workspace removal recovers", async () => {
|
|
const { command, root } = await writeApktoolStub(decodeStub());
|
|
const apk = await apkFixture(root);
|
|
let denied = true;
|
|
let owned: string | undefined;
|
|
const provider = new ApktoolProvider({
|
|
environment: { REA_APKTOOL_COMMAND: command },
|
|
removeWorkspace: async (path) => {
|
|
owned = path;
|
|
if (denied) throw new Error("authored removal denial");
|
|
await rm(path, { recursive: true, force: true });
|
|
},
|
|
});
|
|
try {
|
|
const result = await provider.execute({
|
|
operation: "decode_android_resources",
|
|
input: { path: apk.path, include_strings: true },
|
|
});
|
|
if (result.ok) throw new Error("Expected cleanup failure");
|
|
expect(result.error.cleanupIncomplete).toBe(true);
|
|
expect(result.error.partialObservation).toMatchObject({
|
|
operation: "decode_android_resources",
|
|
subject: { local_path: apk.path, digest: { sha256: apk.sha256 } },
|
|
normalized_result: {
|
|
metadata: { package_name: "com.rea.apktool.probe" },
|
|
strings: [
|
|
{ name: "api_endpoint", value: "https://api.example.com/v1" },
|
|
{ name: "app_name", value: "ApktoolProbe" },
|
|
],
|
|
},
|
|
});
|
|
const blocked = await provider.execute({
|
|
operation: "inspect_apktool_client",
|
|
input: {},
|
|
});
|
|
if (blocked.ok)
|
|
throw new Error("Expected retained-workspace admission refusal");
|
|
expect(blocked.error.cleanupIncomplete).toBe(true);
|
|
expect(blocked.error.cleanupResources).toContain(owned);
|
|
await expect(provider.close()).rejects.toMatchObject({
|
|
cleanupIncomplete: true,
|
|
});
|
|
denied = false;
|
|
await provider.close();
|
|
expect(result.error.cleanupIncomplete).toBe(false);
|
|
expect(blocked.error.cleanupIncomplete).toBe(false);
|
|
expect(result.error.partialObservation).toMatchObject({
|
|
normalized_result: { target: { sha256: apk.sha256 } },
|
|
});
|
|
} finally {
|
|
denied = false;
|
|
await provider.close();
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
[false, "2.7.0-dirty", "CVE-2024-21633", undefined],
|
|
[true, "2.7.0-dirty", "CVE-2024-21633", undefined],
|
|
[false, "3.0.1", "CVE-2026-39973", undefined],
|
|
[true, "3.0.1", "CVE-2026-39973", undefined],
|
|
[
|
|
false,
|
|
"3.0.1",
|
|
"CVE-2026-39973",
|
|
"<resources><string name=unquoted>bad</string></resources>",
|
|
],
|
|
[
|
|
true,
|
|
"3.0.1",
|
|
"CVE-2026-39973",
|
|
"<resources><string name=unquoted>bad</string></resources>",
|
|
],
|
|
] as const)(
|
|
"preserves completed Evidence and cancellation during workspace cleanup (denied=%s, version=%s, advisory=%s, XML=%s)",
|
|
async (deniedInitially, version, advisory, stringsXml) => {
|
|
const { command, root } = await writeApktoolStub((root) =>
|
|
decodeStub(stringsXml === undefined ? {} : { stringsXml })(root).replace(
|
|
VERSION_BLOCK,
|
|
`printf '%s\\n' '${version}'`,
|
|
),
|
|
);
|
|
const apk = await apkFixture(root);
|
|
const controller = new AbortController();
|
|
const reason = { caller: "during final workspace removal" };
|
|
let denied = deniedInitially;
|
|
const provider = new ApktoolProvider({
|
|
environment: { REA_APKTOOL_COMMAND: command },
|
|
removeWorkspace: async (path) => {
|
|
controller.abort(reason);
|
|
if (denied) throw new Error("authored denial");
|
|
await rm(path, { recursive: true, force: true });
|
|
},
|
|
});
|
|
try {
|
|
const result = await provider.execute(
|
|
{
|
|
operation: "decode_android_resources",
|
|
input: { path: apk.path, include_strings: true },
|
|
},
|
|
{ signal: controller.signal },
|
|
);
|
|
if (result.ok) throw new Error("Expected finalization cancellation");
|
|
const primary = result.error.cleanupPrimary ?? result.error;
|
|
expect(primary._tag).toBe("AnalysisCancelledError");
|
|
expect(primary.cause).toBe(reason);
|
|
expect(result.error.cleanupIncomplete).toBe(deniedInitially);
|
|
expect(result.error.partialObservation).toMatchObject({
|
|
operation: "decode_android_resources",
|
|
normalized_result: { target: { sha256: apk.sha256 } },
|
|
limitations: expect.arrayContaining([
|
|
expect.stringContaining(advisory),
|
|
]),
|
|
});
|
|
if (stringsXml !== undefined)
|
|
expect(result.error.partialObservation).toMatchObject({
|
|
limitations: expect.arrayContaining([
|
|
expect.stringContaining("entry counts are unknown"),
|
|
]),
|
|
});
|
|
denied = false;
|
|
await provider.close();
|
|
expect(result.error.cleanupIncomplete).toBe(false);
|
|
} finally {
|
|
denied = false;
|
|
await provider.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each(["decode", "projection"] as const)(
|
|
"keeps a recorded %s failure primary when cancellation arrives during removal",
|
|
async (stage) => {
|
|
const { command, root } = await writeApktoolStub(
|
|
decodeStub({ failDecode: stage === "decode" }),
|
|
);
|
|
const apk = await apkFixture(root);
|
|
if (stage === "projection") {
|
|
const bad = join(root, "bad.xml");
|
|
await writeFile(bad, Buffer.from([0xff]));
|
|
await writeFile(
|
|
command,
|
|
"#!/bin/sh\n" + decodeStub({ stringsFile: bad })(root),
|
|
{ mode: 0o755 },
|
|
);
|
|
}
|
|
const controller = new AbortController();
|
|
const provider = new ApktoolProvider({
|
|
environment: { REA_APKTOOL_COMMAND: command },
|
|
removeWorkspace: async (path) => {
|
|
controller.abort({ caller: "after decode refusal" });
|
|
await rm(path, { recursive: true, force: true });
|
|
},
|
|
});
|
|
try {
|
|
const result = await provider.execute(
|
|
{
|
|
operation: "decode_android_resources",
|
|
input: { path: apk.path, include_strings: true },
|
|
},
|
|
{ signal: controller.signal },
|
|
);
|
|
if (result.ok) throw new Error("Expected decode refusal");
|
|
expect(controller.signal.aborted).toBe(true);
|
|
expect(result.error._tag).toBe(
|
|
stage === "decode"
|
|
? "AnalysisUnsupportedTargetError"
|
|
: "AnalysisProtocolError",
|
|
);
|
|
if (stage === "decode")
|
|
expect(result.error.capturedOutput?.stderr).toContain(
|
|
"Could not decode the APK",
|
|
);
|
|
expect(result.error.partialObservation).toMatchObject(
|
|
stage === "decode"
|
|
? {
|
|
kind: "file-snapshot-hash",
|
|
path: apk.path,
|
|
sha256: apk.sha256,
|
|
}
|
|
: {
|
|
operation: "decode_android_resources",
|
|
subject: { local_path: apk.path, digest: { sha256: apk.sha256 } },
|
|
normalized_result: {
|
|
coverage: "partial",
|
|
metadata: { package_name: "com.rea.apktool.probe" },
|
|
strings: [],
|
|
},
|
|
},
|
|
);
|
|
} finally {
|
|
await provider.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("releases successful workspace peers once while retaining a denied peer for retry", async () => {
|
|
const { command, root } = await writeApktoolStub(decodeStub());
|
|
const apk = await apkFixture(root);
|
|
let deniedRoot: string | undefined;
|
|
let denied = true;
|
|
const attempts = new Map<string, number>();
|
|
const provider = new ApktoolProvider({
|
|
environment: { REA_APKTOOL_COMMAND: command },
|
|
removeWorkspace: async (path) => {
|
|
attempts.set(path, (attempts.get(path) ?? 0) + 1);
|
|
deniedRoot ??= path;
|
|
if (path === deniedRoot && denied)
|
|
throw new Error("authored peer denial");
|
|
await rm(path, { recursive: true, force: true });
|
|
},
|
|
});
|
|
try {
|
|
const request = {
|
|
operation: "decode_android_resources" as const,
|
|
input: { path: apk.path, include_strings: true },
|
|
};
|
|
const results = await Promise.all([
|
|
provider.execute(request),
|
|
provider.execute(request),
|
|
]);
|
|
expect(results.filter((result) => result.ok)).toHaveLength(1);
|
|
const retained = results.find((result) => !result.ok);
|
|
if (retained === undefined || retained.ok)
|
|
throw new Error("Expected retained peer");
|
|
expect(attempts.size).toBe(2);
|
|
await expect(provider.close()).rejects.toMatchObject({
|
|
cleanupIncomplete: true,
|
|
});
|
|
denied = false;
|
|
await provider.close();
|
|
expect(retained.error.cleanupIncomplete).toBe(false);
|
|
for (const [path, count] of attempts)
|
|
expect(count).toBe(path === deniedRoot ? 3 : 1);
|
|
} finally {
|
|
denied = false;
|
|
await provider.close();
|
|
}
|
|
});
|
|
|
|
it("joins a late workspace allocation during shutdown and removes its owned root", async () => {
|
|
const { command, root } = await writeApktoolStub(decodeStub());
|
|
const apk = await apkFixture(root);
|
|
const created = createDeferred<string>();
|
|
const release = createDeferred<void>();
|
|
const allocate = PrivateRuntimeRoot.create.bind(PrivateRuntimeRoot);
|
|
const allocation = vi
|
|
.spyOn(PrivateRuntimeRoot, "create")
|
|
.mockImplementationOnce(async (options) => {
|
|
const runtime = await allocate(options);
|
|
created.resolve(runtime.path);
|
|
await release.promise;
|
|
return runtime;
|
|
});
|
|
const provider = providerOver(command);
|
|
const pending = provider.execute({
|
|
operation: "decode_android_resources",
|
|
input: { path: apk.path, include_strings: true },
|
|
});
|
|
let closing: Promise<void> | undefined;
|
|
try {
|
|
const owned = await created.promise;
|
|
closing = provider.close();
|
|
release.resolve();
|
|
const result = await pending;
|
|
if (result.ok) throw new Error("Expected shutdown cancellation");
|
|
expect(result.error._tag).toBe("AnalysisCancelledError");
|
|
await closing;
|
|
await expect(stat(owned)).rejects.toMatchObject({ code: "ENOENT" });
|
|
} finally {
|
|
release.resolve();
|
|
await pending;
|
|
allocation.mockRestore();
|
|
await (closing ?? provider.close());
|
|
}
|
|
});
|
|
|
|
it.each(["first-selected.apk", "second-selected.apk"])(
|
|
"attributes a decode refusal to %s without substituting the launcher",
|
|
async (name) => {
|
|
const { command, root } = await writeApktoolStub(
|
|
decodeStub({ failDecode: true }),
|
|
);
|
|
const path = join(root, name);
|
|
await writeFile(path, "authored target");
|
|
const provider = providerOver(command);
|
|
try {
|
|
const projected = await refusedBy(provider, {
|
|
operation: "decode_android_resources",
|
|
input: { path, include_strings: true },
|
|
});
|
|
expect(projected).toMatchObject({
|
|
code: "unsupported_target",
|
|
details: { path, captured_output: { exit_code: 1 } },
|
|
});
|
|
expect(projected.message).toContain(path);
|
|
expect(projected.message).not.toContain(command);
|
|
} finally {
|
|
await provider.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("reports launcher disappearance after version admission as a provider failure", async () => {
|
|
const { command, root } = await writeApktoolStub(
|
|
() =>
|
|
`if [ "$1" = "--version" ]; then rm -- "$0"; ${VERSION_BLOCK}; exit 0; fi`,
|
|
);
|
|
const apk = await apkFixture(root);
|
|
const provider = providerOver(command);
|
|
try {
|
|
const result = await provider.execute({
|
|
operation: "decode_android_resources",
|
|
input: { path: apk.path, include_strings: true },
|
|
});
|
|
if (result.ok) throw new Error("Expected missing launcher");
|
|
expect(result.error).toMatchObject({
|
|
_tag: "ProviderAdapterError",
|
|
diagnostics: { target_path: apk.path, selected_command: command },
|
|
});
|
|
expect(projectAnalysisError(result.error).code).not.toBe(
|
|
"unsupported_target",
|
|
);
|
|
expect(result.error.cause).toBeDefined();
|
|
} finally {
|
|
await provider.close();
|
|
}
|
|
});
|
|
|
|
it("discloses qualified and unfamiliar strings tables while preserving exact locale selection (#4612)", async () => {
|
|
const tables = [
|
|
"en-rUS-v21",
|
|
"b+zh+Hans+CN-night",
|
|
"pt-rBR-sw600dp",
|
|
"mcc310-fr",
|
|
"mcc310-mnc004-en-rUS-v21",
|
|
"fr-land",
|
|
"night",
|
|
"v21",
|
|
"pt-BR",
|
|
"unknown_CONFIGURATION",
|
|
];
|
|
const { command, root } = await writeApktoolStub(
|
|
decodeStub({
|
|
extraValuesDirectories: [
|
|
...tables,
|
|
"fr",
|
|
"b+sr+Latn",
|
|
"b+sr+Latn+RS+REVISED",
|
|
"b+fil+Latn+419+ABCDEFGH",
|
|
],
|
|
}),
|
|
);
|
|
const apk = await apkFixture(root);
|
|
const provider = providerOver(command);
|
|
try {
|
|
for (const locale of [
|
|
undefined,
|
|
"fr",
|
|
"b+sr+Latn",
|
|
"b+sr+Latn+RS+REVISED",
|
|
"b+fil+Latn+419+ABCDEFGH",
|
|
"en-rUS",
|
|
] as const) {
|
|
const result = await provider.execute({
|
|
operation: "decode_android_resources",
|
|
input: {
|
|
path: apk.path,
|
|
include_strings: true,
|
|
...(locale === undefined ? {} : { locale }),
|
|
},
|
|
});
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.result).toMatchObject({
|
|
locales: [
|
|
"b+fil+Latn+419+ABCDEFGH",
|
|
"b+sr+Latn",
|
|
"b+sr+Latn+RS+REVISED",
|
|
"de",
|
|
"fr",
|
|
],
|
|
locale: locale ?? null,
|
|
coverage: "complete",
|
|
});
|
|
expect(result.value.limitations).toContain(
|
|
`Other strings tables are not selected by locale; their resource configuration suffixes are preserved without inferring Android runtime matching: ${tables
|
|
.map((suffix) => JSON.stringify(`values-${suffix}`))
|
|
.sort()
|
|
.join(", ")}`,
|
|
);
|
|
if (locale === undefined)
|
|
expect(result.value.result).toMatchObject({
|
|
strings: [
|
|
{ name: "api_endpoint", value: "https://api.example.com/v1" },
|
|
{ name: "app_name", value: "ApktoolProbe" },
|
|
],
|
|
});
|
|
else if (locale === "en-rUS") {
|
|
expect(result.value.result).toMatchObject({ strings: [] });
|
|
expect(result.value.limitations).toContain(
|
|
"No strings.xml was found for locale en-rUS",
|
|
);
|
|
} else
|
|
expect(result.value.result).toMatchObject({
|
|
strings: [{ name: "wrong", value: "Wrong" }],
|
|
});
|
|
}
|
|
} finally {
|
|
await provider.close();
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
"2.7.0-dirty",
|
|
"2.9.1",
|
|
"2.9.2-rc.1",
|
|
"2.9.2",
|
|
"2.9.2-dirty",
|
|
"3.0.0",
|
|
"3.0.1",
|
|
"3.0.2-rc.1",
|
|
"3.0.2",
|
|
"3.1.0",
|
|
])(
|
|
"retains reported-version limitations for both operations without refusing %s (#3438)",
|
|
async (version) => {
|
|
const { command, root } = await writeApktoolStub((root) =>
|
|
decodeStub()(root).replace(VERSION_BLOCK, `printf '%s\\n' '${version}'`),
|
|
);
|
|
const apk = await apkFixture(root);
|
|
const provider = providerOver(command);
|
|
try {
|
|
const inspect = await provider.execute({
|
|
operation: "inspect_apktool_client",
|
|
input: {},
|
|
});
|
|
const decode = await provider.execute({
|
|
operation: "decode_android_resources",
|
|
input: { path: apk.path, include_strings: true },
|
|
});
|
|
if (!inspect.ok) throw inspect.error;
|
|
if (!decode.ok) throw decode.error;
|
|
const warnings = inspect.value.limitations.filter((limitation) =>
|
|
limitation.includes("CVE-2024-21633"),
|
|
);
|
|
expect(warnings.length).toBe(
|
|
["2.7.0-dirty", "2.9.1", "2.9.2-rc.1"].includes(version) ? 1 : 0,
|
|
);
|
|
if (warnings.length > 0) expect(warnings[0]).toContain("CVE-2024-21633");
|
|
expect(
|
|
inspect.value.limitations.filter((warning) =>
|
|
warning.includes("CVE-2026-39973"),
|
|
).length,
|
|
).toBe(["3.0.0", "3.0.1", "3.0.2-rc.1"].includes(version) ? 1 : 0);
|
|
expect(
|
|
inspect.value.limitations.filter((warning) =>
|
|
warning.includes("GHSA-vgwr-4w3p-xmjv"),
|
|
).length,
|
|
).toBe(
|
|
process.platform === "win32" &&
|
|
[
|
|
"2.7.0-dirty",
|
|
"2.9.1",
|
|
"2.9.2-rc.1",
|
|
"2.9.2",
|
|
"2.9.2-dirty",
|
|
].includes(version)
|
|
? 1
|
|
: 0,
|
|
);
|
|
|
|
for (const warning of inspect.value.limitations)
|
|
expect(decode.value.limitations).toContain(warning);
|
|
expect(inspect.value.result).toMatchObject({
|
|
client: { apktool_version: version },
|
|
});
|
|
expect(decode.value.result).toMatchObject({
|
|
client: { apktool_version: version },
|
|
target: { sha256: apk.sha256 },
|
|
coverage: "complete",
|
|
});
|
|
} finally {
|
|
await provider.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
[
|
|
"malformed",
|
|
'<resources><string name="a">one</string><string name="b">two</resources>',
|
|
[],
|
|
],
|
|
["wrong_root", "<manifest/>", []],
|
|
[
|
|
"unsupported",
|
|
'<resources><string name="a"><b>A</b></string><string>unnamed</string><string name="good">ok</string></resources>',
|
|
[{ name: "good", value: "ok" }],
|
|
],
|
|
] as const)(
|
|
"reports document projection semantics for %s (#4820)",
|
|
async (kind, stringsXml, strings) => {
|
|
const { command, root } = await writeApktoolStub(
|
|
decodeStub({ stringsXml }),
|
|
);
|
|
const apk = await apkFixture(root);
|
|
const provider = providerOver(command);
|
|
try {
|
|
const result = await provider.execute({
|
|
operation: "decode_android_resources",
|
|
input: { path: apk.path, include_strings: true },
|
|
});
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.result).toMatchObject({
|
|
strings,
|
|
target: { sha256: apk.sha256 },
|
|
coverage: "partial",
|
|
});
|
|
const limitations = result.value.limitations.join("\n");
|
|
if (kind === "unsupported") {
|
|
expect(limitations).toContain(
|
|
"String entries omitted due to unsupported markup or missing names: 2",
|
|
);
|
|
expect(limitations).not.toContain("entry counts are unknown");
|
|
} else {
|
|
expect(limitations).toContain(
|
|
'String table "res/values/strings.xml" could not be projected:',
|
|
);
|
|
expect(limitations).toContain("entry counts are unknown");
|
|
expect(limitations).not.toContain("String entries omitted");
|
|
expect(limitations).toContain(
|
|
kind === "wrong_root"
|
|
? "root must be <resources>"
|
|
: "XML is not well-formed",
|
|
);
|
|
}
|
|
} finally {
|
|
await provider.close();
|
|
}
|
|
},
|
|
);
|