fix(apktool): disclose tables outside exact locale selection

Inventory observed entries through the owned artifact traversal, preserve other configuration names, and disclose non-regular tables with partial coverage. Share request alias normalization with exact table classification and preserve filesystem failures.

Adapt the disclosure policy from #4738. Correct the fixture command separator so all advertised tables are actually created.

Validation: 164 focused tests; check:fast; test discovery; docs:check. Two regression controls fail on the baseline. Twenty-four tiny authored CLI/MCP comparisons; no real Apktool or Windows verification.

Fixes #4612

Co-authored-by: Oke Iyanuoluwa E. <oke.iyanuoluwa12@gmail.com>
This commit is contained in:
morlutoandOke Iyanuoluwa E. committed 2026-10-17 22:55:08 +08:00
1 parent cc038da218
commit 98886adddf
9 files changed
+322 -35

No files matched your search

+9 -1
View File
@@ -63,7 +63,15 @@ select Android's `values-pt-rBR`; Android qualifiers such as `pt-rBR` and
entries containing nested markup (`<b>`, `<xliff:g>`) are counted as
unparsed rather than flattened — the limitation names the count. The
default table is bounded to 2000 entries with partial coverage when more
exist; `locales` lists every `values-<locale>` that has a strings table.
exist. `locales` lists observed regular `strings.xml` tables whose exact
`values-<locale>` directory is selectable by the request's locale grammar.
Other table directories, including combined qualifiers such as
`values-en-rUS-v21` and `values-mcc310-fr`, are named in limitations with
their source spelling. They are not advertised as selectable locales;
REA does not infer Android's runtime resource matching. Non-regular
strings-table paths are disclosed with partial coverage. Discovery uses
the same bounded traversal as the file count; filesystem failures remain
failures rather than being interpreted as missing tables.
- **Smali is skipped by design** (`apktool d --no-src`): Java-source
recovery belongs to the JADX family. Resource smali edits and rebuild are
a follow-up lane with mutation authority.
+49 -18
View File
@@ -1,40 +1,71 @@
import { access } from "node:fs/promises";
import { join } from "node:path";
import type {
ArtifactEntry,
ArtifactTraversalOmission,
} from "../artifacts/ArtifactReader.js";
import { DirectoryArtifactTraversalLimit } from "../artifacts/DirectoryArtifactReader.js";
import { assertArtifactReadActive } from "../artifacts/ArtifactFileOperation.js";
import { androidLocaleQualifierSchema } from "../domain/apktool/apktoolResourceAnalysis.js";
import { classifyAndroidStringsTable } from "../domain/apktool/apktoolResourceAnalysis.js";
import type { ApktoolWorkspace } from "./ApktoolWorkspace.js";
type TableEntryKind = ArtifactEntry["kind"] | ArtifactTraversalOmission["kind"];
interface StringsTableOmission {
readonly path: string;
readonly kind: Exclude<TableEntryKind, "file">;
}
/** Count files over bounded examined coverage, including omitted special entries. */
export const countDecodedFiles = async (
workspace: ApktoolWorkspace,
root: string,
signal: AbortSignal | undefined,
maximumEntries: number,
): Promise<{ count: number; capped: boolean; locales: readonly string[] }> => {
): Promise<{
count: number;
capped: boolean;
locales: readonly string[];
otherStringsTables: readonly string[];
unreadableStringsTables: readonly StringsTableOmission[];
}> => {
let count = 0;
let capped = false;
const locales: string[] = [];
const otherStringsTables: string[] = [];
const unreadableStringsTables: StringsTableOmission[] = [];
const observeTable = (path: string, kind: TableEntryKind): void => {
const match = /^res\/(values(?:-([^/]*))?)\/strings\.xml$/u.exec(path);
if (match === null) return;
if (kind !== "file") {
unreadableStringsTables.push({ path, kind });
return;
}
const suffix = match[2];
if (suffix === undefined) return;
const table = classifyAndroidStringsTable(suffix);
if (table.kind === "locale") locales.push(table.locale);
else otherStringsTables.push(match[1]!);
};
try {
for await (const entry of workspace.entries(root, signal, maximumEntries)) {
for await (const entry of workspace.entries(
root,
signal,
maximumEntries,
(omission) => observeTable(omission.path, omission.kind),
)) {
assertArtifactReadActive(signal);
observeTable(entry.path, entry.kind);
if (entry.kind === "file") count += 1;
if (entry.kind === "directory") {
const match = /^res\/values-(.+)$/u.exec(entry.path);
if (
match !== null &&
androidLocaleQualifierSchema.safeParse(match[1]).success &&
(await access(join(root, entry.path, "strings.xml")).then(
() => true,
() => false,
))
)
locales.push(match[1]!);
}
}
} catch (cause) {
if (!(cause instanceof DirectoryArtifactTraversalLimit)) throw cause;
capped = true;
}
return { count, capped, locales: locales.sort() };
return {
count,
capped,
locales: locales.sort(),
otherStringsTables: otherStringsTables.sort(),
unreadableStringsTables: unreadableStringsTables.sort((a, b) =>
a.path < b.path ? -1 : a.path > b.path ? 1 : 0,
),
};
};
+65 -1
View File
@@ -131,7 +131,7 @@ const decodeStub = (
(name) =>
`printf '%s\\n' '<resources><string name="wrong">Wrong</string></resources>' > "$5/res/values-${name}/strings.xml"`,
)
.join("\\n ");
.join("\n ");
return () => `if [ "$1" = "--version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "d" ]; then
${
@@ -333,6 +333,7 @@ it("ignores Android resource qualifiers when discovering locales", async () => {
input: { path: apk.path, include_strings: true },
});
expect(result.locales).toEqual(["de"]);
expect(result.decoded_file_count).toBe(7);
});
it.each(["car", "night", "v21"])(
@@ -915,3 +916,66 @@ it("reports launcher disappearance after version admission as a provider failure
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",
"b+sr+Latn+RS+REVISED",
"unknown_CONFIGURATION",
];
const { command, root } = await writeApktoolStub(
decodeStub({ extraValuesDirectories: [...tables, "fr", "b+sr+Latn"] }),
);
const apk = await apkFixture(root);
const provider = providerOver(command);
try {
for (const locale of [undefined, "fr", "b+sr+Latn", "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+sr+Latn", "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();
}
});
+17 -6
View File
@@ -30,7 +30,10 @@ import {
createAnalysisExecutionEvidence,
type AnalysisExecution,
} from "../application/AnalysisProvider.js";
import type { ApktoolRequest } from "../domain/apktool/apktoolResourceAnalysis.js";
import {
androidLocaleDirectorySuffix,
type ApktoolRequest,
} from "../domain/apktool/apktoolResourceAnalysis.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisCancelledError,
@@ -88,9 +91,6 @@ const throwIfCancelled = (
throw new AnalysisCancelledError(operation, { cause: signal.reason });
};
const localeQualifier = (locale: string): string =>
locale.replace(/^([a-z]{2,3})-([A-Z]{2}|[0-9]{3})$/u, "$1-r$2");
interface DecodeLimits {
readonly timeoutMs: number;
readonly maxOutputBytes: number;
@@ -582,7 +582,7 @@ export class ApktoolProvider {
const stringsDirectory =
input.locale === undefined
? "values"
: `values-${localeQualifier(input.locale)}`;
: `values-${androidLocaleDirectorySuffix(input.locale)}`;
const stringsRead = input.include_strings
? await resources.readText(
join(workspace, "res", stringsDirectory, "strings.xml"),
@@ -609,7 +609,8 @@ export class ApktoolProvider {
manifestRead.truncated ||
stringsRead.truncated ||
parsedStrings.unparsedCount > 0 ||
files.capped
files.capped ||
files.unreadableStringsTables.length > 0
? "partial"
: "complete";
result = ok(
@@ -668,6 +669,16 @@ export class ApktoolProvider {
`strings.xml exceeds the ${String(STRINGS_XML_MAX_BYTES)}-byte projection budget`,
]
: []),
...(files.unreadableStringsTables.length === 0
? []
: [
`String tables omitted because they are not regular files: ${files.unreadableStringsTables.map((table) => `${JSON.stringify(table.path)} (${table.kind})`).join(", ")}`,
]),
...(files.otherStringsTables.length === 0
? []
: [
`Other strings tables are not selected by locale; their resource configuration suffixes are preserved without inferring Android runtime matching: ${files.otherStringsTables.map((table) => JSON.stringify(table)).join(", ")}`,
]),
...(files.capped
? [
`Decoded workspace enumeration stopped after ${String(MAX_DECODED_ENTRIES_VISITED)} entries; decoded_file_count and locales may be partial`,
+100 -2
View File
@@ -16,12 +16,17 @@ import { ApktoolWorkspace } from "./ApktoolWorkspace.js";
vi.mock("node:fs/promises", async (original) => {
const actual = await original<typeof import("node:fs/promises")>();
return { ...actual, open: vi.fn(actual.open) };
return {
...actual,
open: vi.fn(actual.open),
opendir: vi.fn(actual.opendir),
};
});
const actual =
await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
afterEach(() => {
vi.mocked(fs.open).mockImplementation(actual.open);
vi.mocked(fs.opendir).mockImplementation(actual.opendir);
});
it.each(["source", "snapshot", "output"] as const)(
@@ -253,10 +258,103 @@ it.skipIf(process.platform === "win32")(
undefined,
6,
);
expect(complete).toEqual({ count: 2, capped: false, locales: [] });
expect(complete).toEqual({
count: 2,
capped: false,
locales: [],
otherStringsTables: [],
unreadableStringsTables: [],
});
} finally {
await workspace.close();
await commands.close();
}
},
);
it.skipIf(process.platform === "win32")(
"inventories only observed regular tables and discloses non-regular and unknown entries",
async () => {
const root = await PrivateRuntimeRoot.create({
prefix: "rea-apktool-tables-test-",
});
const commands = new ApktoolCommandCleanup();
const workspace = new ApktoolWorkspace(root, commands);
const regular = ["de", "en-rUS-v21", "mcc310-fr", "night", "pt-BR", ""];
const nonRegular = ["fr", "it", "es"];
try {
for (const suffix of [...regular, ...nonRegular, "ja"])
await fs.mkdir(join(root.path, "res", `values-${suffix}`), {
recursive: true,
});
for (const suffix of regular)
await fs.writeFile(
join(root.path, "res", `values-${suffix}`, "strings.xml"),
"<resources/>",
);
await fs.symlink(
join(root.path, "res/values-de/strings.xml"),
join(root.path, "res/values-fr/strings.xml"),
);
await promisify(execFile)("mkfifo", [
join(root.path, "res/values-it/strings.xml"),
]);
await fs.mkdir(join(root.path, "res/values-es/strings.xml"));
const result = await countDecodedFiles(
workspace,
root.path,
undefined,
100,
);
expect(result).toEqual({
count: regular.length,
capped: false,
locales: ["de"],
otherStringsTables: [
"values-",
"values-en-rUS-v21",
"values-mcc310-fr",
"values-night",
"values-pt-BR",
],
unreadableStringsTables: [
{ path: "res/values-es/strings.xml", kind: "directory" },
{ path: "res/values-fr/strings.xml", kind: "symlink" },
{ path: "res/values-it/strings.xml", kind: "fifo" },
],
});
} finally {
await workspace.close();
await commands.close();
}
},
);
it("preserves a directory inspection failure instead of treating its locale table as absent", async () => {
const root = await PrivateRuntimeRoot.create({
prefix: "rea-apktool-tables-test-",
});
const commands = new ApktoolCommandCleanup();
const workspace = new ApktoolWorkspace(root, commands);
const blocked = join(root.path, "res/values-fr");
try {
await fs.mkdir(blocked, { recursive: true });
await fs.writeFile(join(blocked, "strings.xml"), "<resources/>");
vi.mocked(fs.opendir).mockImplementation(async (path, options) => {
if (String(path) === blocked)
throw Object.assign(new Error("authored directory inspection denial"), {
code: "EACCES",
});
return actual.opendir(path, options);
});
await expect(
countDecodedFiles(workspace, root.path, undefined, 100),
).rejects.toMatchObject({
reason: "io",
message: expect.stringContaining("authored directory inspection denial"),
});
} finally {
await workspace.close();
await commands.close();
}
});
+14 -6
View File
@@ -1,5 +1,8 @@
import { readArtifactFileChunks } from "../artifacts/readArtifactFileChunks.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import {
ArtifactReaderFailure,
type ArtifactTraversalOptions,
} from "../artifacts/ArtifactReader.js";
import { createHash } from "node:crypto";
import { chmod, open } from "node:fs/promises";
import { join } from "node:path";
@@ -200,16 +203,21 @@ export class ApktoolWorkspace {
}
}
async *entries(path: string, signal?: AbortSignal, maximumEntries?: number) {
async *entries(
path: string,
signal?: AbortSignal,
maximumEntries?: number,
onOmission?: ArtifactTraversalOptions["onOmission"],
) {
const reader = new DirectoryArtifactReader(path);
const owner = { kind: "reader" as const, resource: path, reader };
this.#files.retain(owner);
let failed: { cause: unknown } | undefined;
try {
yield* reader.entries(
signal,
maximumEntries === undefined ? undefined : { maximumEntries },
);
yield* reader.entries(signal, {
...(maximumEntries === undefined ? {} : { maximumEntries }),
...(onOmission === undefined ? {} : { onOmission }),
});
} catch (cause) {
failed = { cause };
throw cause;
@@ -22,7 +22,7 @@ export const APKTOOL_TOOL_CONTRACTS = [
name: "decode_android_resources",
...toolContractMetadata("decode_android_resources"),
description:
"Decode one local APK's resources with `apktool d --no-src` in a provider-owned workspace that is removed after projection: the readable AndroidManifest.xml, apktool.yml version and SDK facts, the app's string resources (default or one locale, bounded to 2000 entries), available string locales, and the decoded file count. The target APK is digest with SHA-256 before decoding; smali is skipped because Java-source recovery belongs to the JADX family.",
"Decode one local APK's resources with `apktool d --no-src` in a provider-owned workspace that is removed after projection: the readable AndroidManifest.xml, apktool.yml version and SDK facts, the app's string resources (default or one locale, bounded to 2000 entries), selectable string locales, and the decoded file count. The target APK is digest with SHA-256 before decoding; smali is skipped because Java-source recovery belongs to the JADX family.",
kind: "apktool-provider",
inputSchema: apktoolInputSchemas.decode_android_resources,
outputSchema: evidenceResultOf(
@@ -0,0 +1,51 @@
import { expect, it } from "vitest";
import {
androidLocaleDirectorySuffix,
classifyAndroidStringsTable,
} from "./apktoolResourceAnalysis.js";
it.each(["de", "pt-rBR", "en-r419", "b+sr+Latn", "b+zh+Hans+CN"])(
"recognizes the exact selectable strings table values-%s",
(suffix) => {
expect(classifyAndroidStringsTable(suffix)).toEqual({
kind: "locale",
locale: suffix,
});
},
);
it.each([
"en-rUS-v21",
"b+zh+Hans+CN-night",
"pt-rBR-sw600dp",
"mcc310-fr",
"mcc310-mnc004-en-rUS-v21",
"car",
"night",
"v21",
"pt-BR",
"en-419",
"b+sr+Latn+RS+REVISED",
"unknown_CONFIGURATION",
"",
])(
"preserves a non-selectable configuration suffix without inferring its locale: %s",
(suffix) => {
expect(classifyAndroidStringsTable(suffix)).toEqual({
kind: "other",
suffix,
});
},
);
it.each([
["pt-BR", "pt-rBR"],
["en-419", "en-r419"],
["pt-rBR", "pt-rBR"],
["b+zh+Hans+CN", "b+zh+Hans+CN"],
])(
"normalizes the request alias %s at the selection owner",
(locale, suffix) => {
expect(androidLocaleDirectorySuffix(locale)).toBe(suffix);
},
);
@@ -30,6 +30,22 @@ const localeQualifierSchema = z
/** Locale language tags admitted by Android resource table selection. */
export const androidLocaleQualifierSchema = localeQualifierSchema;
/** Normalize an admitted request alias at the locale-selection owner. */
export const androidLocaleDirectorySuffix = (locale: string): string =>
locale.replace(/^([a-z]{2,3})-([A-Z]{2}|[0-9]{3})$/u, "$1-r$2");
/** Exact selectable table names are distinct from whole resource configurations. */
export const classifyAndroidStringsTable = (
suffix: string,
):
| { readonly kind: "locale"; readonly locale: string }
| { readonly kind: "other"; readonly suffix: string } => {
const parsed = androidLocaleQualifierSchema.safeParse(suffix);
return parsed.success && androidLocaleDirectorySuffix(parsed.data) === suffix
? { kind: "locale", locale: parsed.data }
: { kind: "other", suffix };
};
/**
* Caller intent for Apktool-backed resource decoding. Apktool is
* bring-your-own: REA never installs it, and decoding happens in a