diff --git a/docs/apktool-resource-analysis.md b/docs/apktool-resource-analysis.md index 7a9c5fc3a0..d894e7808c 100644 --- a/docs/apktool-resource-analysis.md +++ b/docs/apktool-resource-analysis.md @@ -63,7 +63,15 @@ select Android's `values-pt-rBR`; Android qualifiers such as `pt-rBR` and entries containing nested markup (``, ``) 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-` that has a strings table. + exist. `locales` lists observed regular `strings.xml` tables whose exact + `values-` 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. diff --git a/src/apktool/ApktoolDecodedInventory.ts b/src/apktool/ApktoolDecodedInventory.ts index 16b6b91e98..d10ee30e05 100644 --- a/src/apktool/ApktoolDecodedInventory.ts +++ b/src/apktool/ApktoolDecodedInventory.ts @@ -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; +} + /** 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, + ), + }; }; diff --git a/src/apktool/ApktoolProvider.test.ts b/src/apktool/ApktoolProvider.test.ts index 5a932a6302..0c0044f4b0 100644 --- a/src/apktool/ApktoolProvider.test.ts +++ b/src/apktool/ApktoolProvider.test.ts @@ -131,7 +131,7 @@ const decodeStub = ( (name) => `printf '%s\\n' 'Wrong' > "$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(); + } +}); diff --git a/src/apktool/ApktoolProvider.ts b/src/apktool/ApktoolProvider.ts index 0ed7c919d9..51a8eb557d 100644 --- a/src/apktool/ApktoolProvider.ts +++ b/src/apktool/ApktoolProvider.ts @@ -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`, diff --git a/src/apktool/ApktoolWorkspace.lifecycle.test.ts b/src/apktool/ApktoolWorkspace.lifecycle.test.ts index ceee8ea00b..0ddb601628 100644 --- a/src/apktool/ApktoolWorkspace.lifecycle.test.ts +++ b/src/apktool/ApktoolWorkspace.lifecycle.test.ts @@ -16,12 +16,17 @@ import { ApktoolWorkspace } from "./ApktoolWorkspace.js"; vi.mock("node:fs/promises", async (original) => { const actual = await original(); - return { ...actual, open: vi.fn(actual.open) }; + return { + ...actual, + open: vi.fn(actual.open), + opendir: vi.fn(actual.opendir), + }; }); const actual = await vi.importActual("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"), + "", + ); + 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"), ""); + 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(); + } +}); diff --git a/src/apktool/ApktoolWorkspace.ts b/src/apktool/ApktoolWorkspace.ts index 4d4655f545..78c742295d 100644 --- a/src/apktool/ApktoolWorkspace.ts +++ b/src/apktool/ApktoolWorkspace.ts @@ -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; diff --git a/src/contracts/apktool/apktoolToolContracts.ts b/src/contracts/apktool/apktoolToolContracts.ts index efe8e14762..40270ab4b7 100644 --- a/src/contracts/apktool/apktoolToolContracts.ts +++ b/src/contracts/apktool/apktoolToolContracts.ts @@ -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( diff --git a/src/domain/apktool/apktoolResourceAnalysis.test.ts b/src/domain/apktool/apktoolResourceAnalysis.test.ts new file mode 100644 index 0000000000..63b205fb33 --- /dev/null +++ b/src/domain/apktool/apktoolResourceAnalysis.test.ts @@ -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); + }, +); diff --git a/src/domain/apktool/apktoolResourceAnalysis.ts b/src/domain/apktool/apktoolResourceAnalysis.ts index 4cbca07459..fe1dd52f55 100644 --- a/src/domain/apktool/apktoolResourceAnalysis.ts +++ b/src/domain/apktool/apktoolResourceAnalysis.ts @@ -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